MCP Tools, Resources, and Prompts
The engineer was building a code review MCP server for her team. She wanted the AI assistant to be able to read pull requests, check coding style rules, and suggest improvements using the team's preferred review format. She put everything into tools: a get_pr tool, a get_style_rules tool, and a generate_review tool.
The code review assistant worked, but something felt off. Every time the assistant generated a review, it had to call get_style_rules first - fetching the same static document over and over, consuming tokens, adding latency. And every review used a slightly different format: sometimes bullet points, sometimes prose, sometimes a numbered list. The team wanted a consistent format that matched their existing PR review culture, but encoding that in a tool description was clunky. The reviews were good but felt like they came from a different team every time.
Six months into using MCP, she discovered what she had missed: Resources and Prompts. The style guide was not a tool - it was a Resource. It was static data the model should be able to read directly, not a function to call every time. The review format was not documentation in a tool description - it was a Prompt. A template the model could use consistently, with arguments for the PR number and focus area. Refactoring took a day. The assistant became dramatically more consistent, the style guide was no longer fetched redundantly, and new team members could understand what the assistant did just by looking at the registered prompts.
Understanding when to use each primitive is the most important design decision in MCP server development. Get it wrong and you have an overloaded tool interface that re-fetches static data and produces inconsistent outputs. Get it right and your MCP server has a clean, efficient, consistent interface that serves AI assistants well.
This lesson covers all three primitives in depth: what they are, their exact schema and message format, when to use each, and complete Python implementations.
Why Three Primitives
MCP could have defined a single primitive - callable functions - and built everything on top of that. It did not. The three-primitive design reflects a fundamental insight: AI systems interact with external capabilities in three fundamentally different ways.
Action requests (Tools): "Do something and tell me the result." The model needs to take an action - search for something, create something, modify something - and get a result it can reason about. The action takes parameters. The result is non-deterministic or depends on external state.
Data access (Resources): "Give me this data." The model needs to read content - a file, a database record, an API response - that exists independently of the model's request. Reading a resource does not change its state. The content is (relatively) stable.
Template use (Prompts): "Give me a rendered instruction template." The server knows the best way to interact with its tools and wants to encode that as a reusable, parameterized prompt that any AI application can use consistently.
The three primitives map cleanly to three interaction patterns. Using the right primitive for each use case produces cleaner, more efficient, more reliable MCP servers.
:::tip 🎮 Interactive Playground Visualize this concept: Try the MCP Architecture demo on the EngineersOfAI Playground - no code required. :::
Tools: Callable Functions
What Tools Are
Tools are the most commonly used MCP primitive. A tool is a callable function exposed by the MCP server that the AI model can invoke. When a model calls a tool, the MCP server executes a function and returns the result. Tools are how the model interacts with the world: searching databases, calling APIs, creating files, running code, sending messages.
Tools in MCP are directly analogous to function calling / tool use in the Anthropic API - the same JSON Schema-based definition, the same invocation pattern. The difference is that in pure function calling, the tool implementation lives in the AI application. In MCP, the tool implementation lives in the MCP server, making it reusable across any MCP client.
Tool Schema
Every MCP tool has three required fields:
{
"name": "search_github_issues",
"description": "Search for GitHub issues in a repository. Returns matching issues with title, number, state, labels, assignee, creation date, and URL. Use this to find existing issues related to a bug or feature before creating a new one.",
"inputSchema": {
"type": "object",
"properties": {
"repo": {
"type": "string",
"description": "Repository in owner/name format (e.g., 'anthropics/anthropic-sdk-python')"
},
"query": {
"type": "string",
"description": "Search query using GitHub's issue search syntax"
},
"state": {
"type": "string",
"enum": ["open", "closed", "all"],
"default": "open",
"description": "Filter by issue state"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 10,
"description": "Maximum number of results to return"
}
},
"required": ["repo", "query"]
}
}
name: Unique identifier for the tool within this server. Used by the client when calling the tool. Follow conventions: verb_noun or verb_noun_detail (e.g., search_issues, create_pr, get_file_content).
description: The most important field for reliability. The model reads this description to decide when and how to call the tool. Write it as if explaining the tool to a smart junior engineer who has never seen it. Include: what the tool does, what it returns, when to use it, and common use cases.
inputSchema: JSON Schema defining the tool's parameters. Be specific about types, constraints, and descriptions. Optional parameters should have defaults. Enum values should list all acceptable options.
Tool Response Format
A tool call returns content - an array of typed content items:
{
"content": [
{
"type": "text",
"text": "Found 3 matching issues:\n\n1. #1234 [open] 'Fix authentication timeout'\n Assignee: alice\n Created: 2025-01-15\n URL: https://github.com/org/repo/issues/1234\n..."
}
],
"isError": false
}
If the tool fails (application-level error, not a protocol error), return isError: true:
{
"content": [
{
"type": "text",
"text": "Error searching issues: GitHub API rate limit exceeded. Try again in 60 seconds."
}
],
"isError": true
}
Python Tool Implementation
from mcp.server import Server
from mcp.types import Tool, TextContent, CallToolResult
import json
import httpx
server = Server("github-server")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="search_github_issues",
description=(
"Search for GitHub issues in a repository. Returns matching issues "
"with title, number, state, labels, assignee, creation date, and URL. "
"Use this to find existing issues related to a bug or feature before "
"creating a new one."
),
inputSchema={
"type": "object",
"properties": {
"repo": {
"type": "string",
"description": "Repository in owner/name format"
},
"query": {
"type": "string",
"description": "GitHub issue search query"
},
"state": {
"type": "string",
"enum": ["open", "closed", "all"],
"default": "open"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 10
}
},
"required": ["repo", "query"]
}
),
Tool(
name="create_github_issue",
description=(
"Create a new issue in a GitHub repository. Returns the created issue "
"number and URL. Only call this after confirming with the user that a "
"new issue should be created - do not create duplicate issues."
),
inputSchema={
"type": "object",
"properties": {
"repo": {"type": "string"},
"title": {"type": "string", "description": "Issue title (concise, descriptive)"},
"body": {"type": "string", "description": "Issue body in markdown"},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Label names to apply"
}
},
"required": ["repo", "title", "body"]
}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> CallToolResult:
if name == "search_github_issues":
return await search_github_issues(**arguments)
elif name == "create_github_issue":
return await create_github_issue(**arguments)
else:
return CallToolResult(
content=[TextContent(type="text", text=f"Unknown tool: {name}")],
isError=True
)
async def search_github_issues(
repo: str,
query: str,
state: str = "open",
limit: int = 10
) -> CallToolResult:
"""Search GitHub issues using the REST API."""
import os
token = os.environ.get("GITHUB_TOKEN")
if not token:
return CallToolResult(
content=[TextContent(type="text", text="GITHUB_TOKEN not configured")],
isError=True
)
url = "https://api.github.com/search/issues"
params = {
"q": f"{query} repo:{repo} is:issue state:{state}",
"per_page": limit,
}
headers = {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json"
}
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, headers=headers, timeout=10.0)
response.raise_for_status()
data = response.json()
if data["total_count"] == 0:
return CallToolResult(
content=[TextContent(type="text", text=f"No issues found matching '{query}' in {repo}")],
isError=False
)
lines = [f"Found {data['total_count']} issues (showing {len(data['items'])}):\n"]
for item in data["items"]:
labels = ", ".join(l["name"] for l in item.get("labels", []))
lines.append(
f"#{item['number']} [{item['state']}] {item['title']}\n"
f" Labels: {labels or 'none'}\n"
f" Assignee: {item['assignee']['login'] if item.get('assignee') else 'unassigned'}\n"
f" Created: {item['created_at'][:10]}\n"
f" URL: {item['html_url']}\n"
)
return CallToolResult(
content=[TextContent(type="text", text="\n".join(lines))],
isError=False
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 403:
return CallToolResult(
content=[TextContent(type="text", text="GitHub API rate limit exceeded. Try again in 60 seconds.")],
isError=True
)
return CallToolResult(
content=[TextContent(type="text", text=f"GitHub API error: {e.response.status_code}")],
isError=True
)
except Exception as e:
return CallToolResult(
content=[TextContent(type="text", text=f"Unexpected error: {str(e)}")],
isError=True
)
Resources: Readable Data Sources
What Resources Are
Resources are read-only data sources identified by URI. A resource has a URI, a name, a MIME type, and can be read to return its content. Resources do not take parameters and do not perform actions - they expose data that the model can read.
Think of resources as files in a virtual file system that the MCP server exposes. The model can list available resources (discover what files exist) and read individual resources (get their content). Resources are ideal for:
- Configuration and reference data: Style guides, API schemas, system documentation, team conventions
- File system content: Actual files from the local file system
- Database records: Specific records that the model needs to read
- API snapshots: Cached or current state from external APIs (recent Slack messages, open PRs, current deployments)
When to Use Resources vs. Tools
This is the most common design decision confusion in MCP server development.
Use a Resource when:
- The data is (relatively) static or read-only
- The data does not require parameters to identify
- The data is the same every time you read it (or changes infrequently)
- You want the model to be able to "browse" available data
Use a Tool when:
- Retrieving the data requires parameters (search query, filter criteria, date range)
- The data changes based on what you ask for
- Retrieving the data has side effects
- The data is dynamic and requires computation to produce
Example: A company's coding style guide → Resource (it is a document that does not change based on what you ask). Searching for issues related to a specific bug → Tool (requires a search query parameter).
Resource Schema
{
"uri": "github://repos/anthropics/anthropic-sdk-python/CONTRIBUTING.md",
"name": "CONTRIBUTING.md",
"description": "Contribution guidelines for the anthropic-sdk-python repository",
"mimeType": "text/markdown"
}
uri: Unique identifier for the resource. Can be any URI format - common conventions use scheme-based URIs that reflect the source (file://, github://, db://, config://).
name: Human-readable display name. Used in the resources list presented to users.
description: Optional. Helps the model understand what the resource contains and when to read it.
mimeType: Content type of the resource. Common values: text/plain, text/markdown, application/json, text/csv. The model uses this to understand how to interpret the content.
Resource Read Response
{
"contents": [
{
"uri": "github://repos/org/repo/CONTRIBUTING.md",
"mimeType": "text/markdown",
"text": "# Contributing\n\n## Getting Started\n..."
}
]
}
For binary content (images, PDFs), use the blob field with base64-encoded content instead of text.
Resource URI Design
Design URI schemes that are meaningful and discoverable. Good conventions:
file:///absolute/path/to/file # Local files
github://repos/owner/name/path # GitHub content
postgres://db/table_name/record_id # Database records
config://team/style-guide # Configuration documents
slack://channels/general/recent # API data snapshots
Python Resource Implementation
from mcp.server import Server
from mcp.types import Resource, ResourceContents, TextResourceContents
import os
import aiofiles
server = Server("filesystem-server")
ALLOWED_PATHS = ["/home/user/documents", "/tmp/workspace"]
@server.list_resources()
async def list_resources() -> list[Resource]:
"""List available resources - files in allowed directories."""
resources = []
for base_path in ALLOWED_PATHS:
if not os.path.exists(base_path):
continue
for filename in os.listdir(base_path):
filepath = os.path.join(base_path, filename)
if os.path.isfile(filepath):
# Determine MIME type
mime = "text/plain"
if filename.endswith(".md"):
mime = "text/markdown"
elif filename.endswith(".json"):
mime = "application/json"
elif filename.endswith(".csv"):
mime = "text/csv"
elif filename.endswith(".py"):
mime = "text/x-python"
resources.append(Resource(
uri=f"file://{filepath}",
name=filename,
description=f"File at {filepath}",
mimeType=mime
))
return resources
@server.read_resource()
async def read_resource(uri: str) -> list[ResourceContents]:
"""Read a specific resource by URI."""
if not uri.startswith("file://"):
raise ValueError(f"Unsupported URI scheme: {uri}")
filepath = uri[len("file://"):]
# Security: verify path is within allowed directories
filepath = os.path.realpath(filepath)
allowed = any(
filepath.startswith(os.path.realpath(p))
for p in ALLOWED_PATHS
)
if not allowed:
raise PermissionError(f"Path {filepath} is outside allowed directories")
if not os.path.exists(filepath):
raise FileNotFoundError(f"Resource not found: {filepath}")
if not os.path.isfile(filepath):
raise ValueError(f"URI points to a directory, not a file: {filepath}")
# Read file content
async with aiofiles.open(filepath, mode='r', encoding='utf-8', errors='replace') as f:
content = await f.read()
# Determine MIME type from extension
ext = os.path.splitext(filepath)[1].lower()
mime_map = {
".md": "text/markdown",
".json": "application/json",
".csv": "text/csv",
".py": "text/x-python",
".txt": "text/plain",
".yaml": "text/yaml",
".yml": "text/yaml",
}
mime = mime_map.get(ext, "text/plain")
return [
TextResourceContents(
uri=uri,
mimeType=mime,
text=content
)
]
Dynamic Resources
Resources can also be dynamically generated - computed at read time rather than stored files. This is useful for API snapshots and computed summaries:
@server.list_resources()
async def list_resources() -> list[Resource]:
return [
Resource(
uri="slack://channels/engineering/recent-messages",
name="Recent Engineering Channel Messages",
description="Last 50 messages from the #engineering Slack channel (refreshed on read)",
mimeType="text/plain"
),
Resource(
uri="github://repos/org/repo/open-prs-summary",
name="Open PRs Summary",
description="Summary of all open pull requests, refreshed on read",
mimeType="text/markdown"
)
]
@server.read_resource()
async def read_resource(uri: str) -> list[ResourceContents]:
if uri == "slack://channels/engineering/recent-messages":
messages = await fetch_slack_messages("engineering", limit=50)
formatted = "\n".join(
f"[{m['ts']}] {m['user']}: {m['text']}"
for m in messages
)
return [TextResourceContents(uri=uri, mimeType="text/plain", text=formatted)]
elif uri == "github://repos/org/repo/open-prs-summary":
prs = await fetch_open_prs("org/repo")
lines = [f"# Open Pull Requests ({len(prs)} total)\n"]
for pr in prs:
lines.append(f"## PR #{pr['number']}: {pr['title']}\n"
f"Author: {pr['user']['login']} | "
f"Created: {pr['created_at'][:10]}\n"
f"URL: {pr['html_url']}\n")
return [TextResourceContents(uri=uri, mimeType="text/markdown", text="\n".join(lines))]
raise ValueError(f"Unknown resource URI: {uri}")
Prompts: Reusable Templates
What Prompts Are
Prompts are reusable prompt templates that the MCP server exposes for the AI application to use. A prompt has a name, a description, optional arguments (like function parameters), and when retrieved with its arguments filled in, returns a list of messages ready to be added to the conversation.
Prompts encode best-practice instructions for using the server's tools. They are the server developer saying: "Here is the ideal way to interact with this server for this use case." Any MCP client can use these prompts and get consistent, high-quality results without needing to know how to construct the optimal prompt themselves.
When to Use Prompts
Prompts are most valuable in two situations:
-
Complex multi-tool workflows: When using several tools in combination requires specific ordering, context, or instructions that the model needs to follow consistently.
-
Standardized output formats: When your organization wants AI-generated content in a specific format (code review format, incident report template, analysis structure) and you want that format to be consistent across all uses.
Prompts are less commonly used than tools or resources in early MCP deployments, but they become increasingly valuable as organizations mature their MCP usage and want to standardize AI behavior.
Prompt Schema
{
"name": "review_pull_request",
"description": "Generate a structured code review for a GitHub pull request following the team's review standards",
"arguments": [
{
"name": "repo",
"description": "Repository in owner/name format",
"required": true
},
{
"name": "pr_number",
"description": "Pull request number to review",
"required": true
},
{
"name": "focus",
"description": "Specific aspect to focus on: 'security', 'performance', 'correctness', or 'all'",
"required": false
}
]
}
Prompt Get Response
When a client calls prompts/get with arguments, the server returns rendered messages:
{
"description": "Code review for PR #456 in org/repo",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Please review PR #456 in org/repo.\n\nUse the get_pr tool to fetch the PR details and diff, then review it according to these standards:\n\n**Required review sections:**\n1. Summary of changes\n2. Correctness issues (bugs, logic errors)\n3. Security concerns (if any)\n4. Performance considerations (if relevant)\n5. Code style and maintainability\n6. Suggested improvements\n\nFocus area for this review: all\n\nFormat your review as a GitHub comment using markdown. Be specific - reference file names and line numbers. Start with the most important issues."
}
}
]
}
Python Prompt Implementation
from mcp.server import Server
from mcp.types import (
Prompt, PromptArgument, GetPromptResult, PromptMessage,
TextContent
)
server = Server("code-review-server")
@server.list_prompts()
async def list_prompts() -> list[Prompt]:
return [
Prompt(
name="review_pull_request",
description=(
"Generate a structured code review for a GitHub pull request "
"following the team's review standards. Uses get_pr and get_pr_diff tools."
),
arguments=[
PromptArgument(
name="repo",
description="Repository in owner/name format",
required=True
),
PromptArgument(
name="pr_number",
description="Pull request number",
required=True
),
PromptArgument(
name="focus",
description="Focus area: security, performance, correctness, or all",
required=False
)
]
),
Prompt(
name="analyze_logs",
description=(
"Analyze application logs to identify errors, anomalies, and root causes. "
"Uses read_log_file resource or fetch_logs tool."
),
arguments=[
PromptArgument(
name="time_range",
description="Time range to analyze: '1h', '6h', '24h', or 'all'",
required=True
),
PromptArgument(
name="error_type",
description="Specific error type to focus on (optional)",
required=False
)
]
)
]
@server.get_prompt()
async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
args = arguments or {}
if name == "review_pull_request":
repo = args.get("repo", "{repo}")
pr_number = args.get("pr_number", "{pr_number}")
focus = args.get("focus", "all")
return GetPromptResult(
description=f"Code review for PR #{pr_number} in {repo}",
messages=[
PromptMessage(
role="user",
content=TextContent(
type="text",
text=f"""Please review PR #{pr_number} in {repo}.
**Steps:**
1. Use the get_pr tool to fetch PR details: title, description, files changed
2. Use the get_pr_diff tool to fetch the actual code diff
3. Review the changes according to the standards below
**Review Standards:**
- **Correctness**: Logic errors, off-by-one errors, null handling, edge cases
- **Security**: Input validation, SQL injection, XSS, authentication issues, secrets in code
- **Performance**: N+1 queries, unnecessary loops, missing indexes, memory leaks
- **Maintainability**: Code clarity, documentation, naming conventions, test coverage
**Focus for this review:** {focus}
**Output format (GitHub comment markdown):**
## Code Review - PR #{pr_number}
### Summary
[2-3 sentence summary of the changes]
### Issues Found
[Grouped by severity: Critical / Major / Minor]
For each issue:
- **File**: filename.py, line N
- **Issue**: What is wrong
- **Suggestion**: How to fix it
### Approved / Changes Requested
[Final verdict with brief justification]
Be specific. Reference file names and line numbers. Start with the most impactful issues."""
)
)
]
)
elif name == "analyze_logs":
time_range = args.get("time_range", "1h")
error_type = args.get("error_type", "")
error_focus = f"\n**Focus on error type:** {error_type}" if error_type else ""
return GetPromptResult(
description=f"Log analysis for the past {time_range}",
messages=[
PromptMessage(
role="user",
content=TextContent(
type="text",
text=f"""Analyze the application logs for the past {time_range}.{error_focus}
**Use the fetch_logs tool** to retrieve logs with time_range="{time_range}".
**Analysis structure:**
1. **Error Summary**: Count and categorize all errors by type
2. **Top Issues**: The 5 most frequent errors with example messages
3. **Anomalies**: Unusual patterns, spikes, or new error types not seen before
4. **Root Cause Hypotheses**: For the top 3 issues, suggest likely root causes
5. **Recommended Actions**: Specific, actionable next steps for each root cause
Present findings as a structured report suitable for sharing with the engineering team."""
)
)
]
)
raise ValueError(f"Unknown prompt: {name}")
A Complete Server with All Three Primitives
Here is a unified example showing how to compose all three primitives in a single server - a documentation and code analysis MCP server:
"""
documentation-server.py - Complete MCP server using all three primitives.
Tools: search_docs, create_doc_issue
Resources: team-style-guide, api-reference
Prompts: analyze_codebase, create_documentation
"""
import asyncio
import os
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
Tool, Resource, Prompt, PromptArgument,
TextContent, CallToolResult,
TextResourceContents, ResourceContents,
GetPromptResult, PromptMessage
)
import aiofiles
app = Server("documentation-server")
# ─────────────────────────────────────────────────────────────
# TOOLS
# ─────────────────────────────────────────────────────────────
@app.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="search_docs",
description=(
"Search the team's documentation for relevant content. "
"Returns matching sections with titles, summaries, and URLs. "
"Use this to find existing documentation before creating new docs."
),
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (natural language or keywords)"
},
"doc_type": {
"type": "string",
"enum": ["api", "guide", "tutorial", "all"],
"default": "all",
"description": "Filter by documentation type"
}
},
"required": ["query"]
}
),
Tool(
name="create_doc_issue",
description=(
"Create a GitHub issue to track missing or outdated documentation. "
"Only call this after confirming with the user. Returns the issue URL."
),
inputSchema={
"type": "object",
"properties": {
"title": {"type": "string", "description": "Issue title"},
"description": {"type": "string", "description": "What documentation is missing or needs updating"},
"priority": {"type": "string", "enum": ["high", "medium", "low"]}
},
"required": ["title", "description"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> CallToolResult:
if name == "search_docs":
# Simplified: in production, call Algolia, Elasticsearch, etc.
query = arguments["query"]
doc_type = arguments.get("doc_type", "all")
results = await _search_documentation(query, doc_type)
return CallToolResult(
content=[TextContent(type="text", text=results)],
isError=False
)
elif name == "create_doc_issue":
# Simplified: in production, call GitHub API
title = arguments["title"]
return CallToolResult(
content=[TextContent(
type="text",
text=f"Documentation issue created: '{title}'\nURL: https://github.com/org/docs/issues/new"
)],
isError=False
)
return CallToolResult(
content=[TextContent(type="text", text=f"Unknown tool: {name}")],
isError=True
)
# ─────────────────────────────────────────────────────────────
# RESOURCES
# ─────────────────────────────────────────────────────────────
STATIC_RESOURCES = {
"config://docs/style-guide": {
"name": "Documentation Style Guide",
"description": "Team standards for writing documentation: tone, structure, examples, formatting",
"mimeType": "text/markdown",
"content": """# Documentation Style Guide
## Tone
- Write in second person ("you will learn", not "the user will learn")
- Use active voice wherever possible
- Assume the reader is a competent engineer, not a beginner
## Structure
Every doc page should have:
1. A one-sentence description of what the reader will accomplish
2. Prerequisites (if any)
3. Main content with working code examples
4. Common errors and how to fix them
5. Next steps
## Code Examples
- All code examples must be runnable without modification
- Use real values, not placeholder values (use example.com for URLs, not YOUR_URL_HERE)
- Show the output of every code example
## Formatting
- Use level 2 headers (##) for main sections
- Use level 3 headers (###) for subsections
- No deeper than level 3
- Use code blocks for all code, commands, and file paths
"""
},
"config://docs/api-patterns": {
"name": "API Documentation Patterns",
"description": "Standard patterns for documenting REST API endpoints",
"mimeType": "text/markdown",
"content": """# API Documentation Patterns
## Endpoint Documentation Template
### GET /api/resource/{id}
**Description**: One sentence explaining what this endpoint returns.
**Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | string | Yes | Resource identifier |
**Response**:
```json
{
"id": "example-id",
"name": "Example Name",
"created_at": "2025-01-15T10:00:00Z"
}
Error codes: 404 Not Found, 403 Forbidden
Example:
curl -H "Authorization: Bearer TOKEN" https://api.example.com/api/resource/abc123
""" } }
@app.list_resources() async def list_resources() -> list[Resource]: return [ Resource( uri=uri, name=data["name"], description=data["description"], mimeType=data["mimeType"] ) for uri, data in STATIC_RESOURCES.items() ]
@app.read_resource() async def read_resource(uri: str) -> list[ResourceContents]: if uri in STATIC_RESOURCES: data = STATIC_RESOURCES[uri] return [TextResourceContents( uri=uri, mimeType=data["mimeType"], text=data["content"] )] raise ValueError(f"Unknown resource URI: {uri}")
─────────────────────────────────────────────────────────────
PROMPTS
─────────────────────────────────────────────────────────────
@app.list_prompts() async def list_prompts() -> list[Prompt]: return [ Prompt( name="create_documentation", description=( "Generate documentation for a code module following team style guide. " "Reads the style guide resource and the specified source file, " "then produces documentation in the standard format." ), arguments=[ PromptArgument( name="module_path", description="Path to the module to document", required=True ), PromptArgument( name="doc_type", description="Type of documentation: 'api', 'guide', or 'tutorial'", required=True ) ] ) ]
@app.get_prompt() async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult: args = arguments or {} if name == "create_documentation": module_path = args.get("module_path", "{module_path}") doc_type = args.get("doc_type", "api") return GetPromptResult( description=f"Documentation generation for {module_path}", messages=[ PromptMessage( role="user", content=TextContent( type="text", text=f"""Generate {doc_type} documentation for the module at {module_path}.
Steps:
- Read the style guide: resource URI 'config://docs/style-guide'
- Read the API patterns reference: resource URI 'config://docs/api-patterns'
- Use the search_docs tool to check if documentation already exists for this module
- If documentation exists, improve it; if not, create it from scratch
Follow the style guide exactly. All code examples must be runnable. Output the complete documentation as markdown, ready to be saved to the docs repository.""" ) ) ] ) raise ValueError(f"Unknown prompt: {name}")
─────────────────────────────────────────────────────────────
HELPER FUNCTIONS
─────────────────────────────────────────────────────────────
async def _search_documentation(query: str, doc_type: str) -> str: """Simplified doc search - replace with real search backend.""" return f"Search results for '{query}' (type: {doc_type}):\n\nNo matching documentation found. Consider creating a new doc."
─────────────────────────────────────────────────────────────
SERVER ENTRY POINT
─────────────────────────────────────────────────────────────
async def main(): async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options())
if name == "main": asyncio.run(main())
---
## Choosing the Right Primitive: Decision Guide
```mermaid
flowchart TD
Q1{"Does the AI need<br/>to take an action or<br/>retrieve dynamic data?"}:::blue
Q1 -->|Yes| T["Use a TOOL<br/>search, create, send,<br/>execute, query with params"]:::green
Q1 -->|No| Q2{"Is it data the model<br/>reads without parameters<br/>- a file, doc, config?"}:::blue
Q2 -->|Yes| R["Use a RESOURCE<br/>file, style guide,<br/>schema, reference data"]:::purple
Q2 -->|No| Q3{"Does the server<br/>know the best prompt<br/>for this use case?"}:::blue
Q3 -->|Yes| P["Use a PROMPT<br/>code review template,<br/>analysis format, report structure"]:::teal
Q3 -->|No| T2["Reconsider - most needs<br/>are covered by Tools.<br/>Add a Tool with good description."]:::orange
classDef blue fill:#dbeafe,color:#1e293b,stroke:#2563eb
classDef green fill:#dcfce7,color:#14532d,stroke:#16a34a
classDef purple fill:#ede9fe,color:#4c1d95,stroke:#7c3aed
classDef teal fill:#ccfbf1,color:#134e4a,stroke:#14b8a6
classDef orange fill:#ffedd5,color:#7c2d12,stroke:#ea580c
| Tools | Resources | Prompts | |
|---|---|---|---|
| Parameterized? | Yes | No | Yes (for rendering) |
| Has side effects? | Can | No | No |
| Dynamic output? | Yes | Sometimes | No (template) |
| Called by model? | Yes | Model reads | AI app retrieves |
| Best for | Actions, searches | Reference data | Standardized workflows |
Production Engineering Notes
Tool Description Quality Is Everything
The model reads tool descriptions to decide which tool to call and how to use it. A description that says "search GitHub" leads to incorrect tool use. A description that says "Search GitHub issues in a repository. Returns matching issues with title, number, state, labels, assignee, and URL. Use this before creating new issues to check for duplicates" leads to reliable tool use.
Write descriptions as if explaining to a competent engineer who has never seen your code. Include: what the tool does, what it returns, when to use it, when NOT to use it, and any important constraints.
Resources Are Not Files - They Are Data
Resources can serve any data that can be read: real files, database records, API snapshots, computed summaries, configuration. The URI scheme is a convention you define, not a protocol constraint. Design URI schemes that are semantically meaningful and self-documenting.
Version Your Prompts
If you expose prompts that teams rely on, treat them like an API: version them, document changes, and avoid breaking changes without warning. A prompt that changes format unexpectedly will break downstream processes that depend on it.
:::danger Returning Secrets in Tool Results Tool results are passed back to the language model and may appear in conversation history, logs, or be used in subsequent prompts. Never return secrets (API keys, passwords, tokens) in tool results. If a tool needs to use a secret internally, use it within the tool function and return only the result of the operation. :::
:::warning Resource Content Size Resources with very large content (multi-MB files, entire databases) will consume large amounts of context window space and slow down model inference. For large resources, consider: truncating to the most relevant portion, implementing pagination, or converting the resource to a parameterized tool that supports filtering. :::
Interview Q&A
Q1: What are the three MCP primitives and what is each used for?
Tools are callable functions - the model invokes them with parameters to take actions or retrieve dynamic data. They are for operations like searching, creating, sending, querying, or any operation that needs parameters or has side effects. Resources are readable data sources identified by URI - the model reads them to access static or semi-static content like documentation, configuration files, schemas, or API snapshots. They do not take parameters and do not have side effects. Prompts are reusable prompt templates with arguments that the server exposes - they encode best-practice instructions for using the server's tools and return rendered message content when called with arguments. They are used to standardize complex workflows and output formats across AI applications.
Q2: When should you use a Resource instead of a Tool?
Use a Resource when the data is relatively static, does not need parameters to identify it, and reading it does not cause side effects. The canonical examples are: documentation files, style guides, API schemas, configuration documents. Use a Tool when the data is dynamic, requires parameters (like a search query or filter criteria), or when retrieving it has side effects. The key test: if you would bookmark the URL to the data, it is a Resource. If you would call a function with parameters to get it, it is a Tool. Common mistake: putting static reference data (like a coding style guide) into a tool that gets called repeatedly instead of a resource that is listed once and readable on demand.
Q3: How should you design tool descriptions for reliable model behavior?
Write tool descriptions as if explaining to a competent engineer who has never seen the tool before. Include four things: (1) what the tool does and what it returns, with specifics about the return format, (2) when to use it - the trigger conditions that make this tool appropriate, (3) when NOT to use it - if there are common cases where a different tool is better, (4) important constraints or limitations. Avoid: vague one-word descriptions like "search GitHub," descriptions that match multiple tools (the model picks the wrong one), and descriptions that omit what the tool returns (the model cannot reason about how to use the result). The description is not documentation for humans - it is instructions for the model. Write it accordingly.
Q4: How do Prompts differ from just writing good tool descriptions?
Tool descriptions tell the model when and how to call a specific tool. Prompts tell the model how to accomplish a complete workflow - which tools to call, in what order, with what context, and how to structure the output. A tool description says "this tool searches GitHub issues." A prompt says "to review a pull request: first call get_pr to fetch details, then get_pr_diff for the code changes, then review using these specific standards and format your output as a GitHub comment in this structure." Prompts are especially valuable when the workflow is complex (multiple tools in sequence), when the output format must be consistent across uses (code review templates, report formats), or when the best practice for using the tools is non-obvious and the server developer wants to encode it once for all clients.
Q5: What are the key things to validate in a tool call handler before executing the underlying operation?
Five things. First: parameter presence - verify all required parameters are provided and non-null. Second: type validation - verify parameter types match the schema (string is a string, integer is within bounds). Third: authorization - verify the caller has permission to perform this operation in this context (especially for write operations). Fourth: input sanitization - for tools that interact with databases or shell commands, prevent injection attacks by parameterizing queries and avoiding direct string interpolation. Fifth: rate limiting - if the underlying API has rate limits, enforce them at the tool handler level to prevent the agent from exhausting the quota in a single run. Return descriptive, actionable error messages with isError: true when any validation fails - the model needs to understand what went wrong to adjust its approach.
