721 lines
20 KiB
Markdown
721 lines
20 KiB
Markdown
# MCP Protocol Features Deep Dive
|
|
|
|
This guide explores advanced MCP protocol features that go beyond basic tool and resource handling. Understanding these features helps you build more robust, user-friendly, and production-ready MCP servers.
|
|
|
|
> **Looking ahead:** the `2026-07-28` release candidate deprecates the Logging primitive (favoring `stderr` for stdio and OpenTelemetry for structured observability), removes the `initialize`/session model referenced in Server Lifecycle Events below, and moves the experimental Tasks feature into a dedicated Tasks extension with a new `tasks/get`/`tasks/update`/`tasks/cancel` lifecycle. See [What's Changing in MCP: The 2026-07-28 Release Candidate](../../01-CoreConcepts/mcp-2026-07-28-release-candidate.md).
|
|
|
|
## Features Covered
|
|
|
|
1. **Progress Notifications** - Report progress for long-running operations
|
|
2. **Request Cancellation** - Allow clients to cancel in-flight requests
|
|
3. **Resource Templates** - Dynamic resource URIs with parameters
|
|
4. **Server Lifecycle Events** - Proper initialization and shutdown
|
|
5. **Logging Control** - Server-side logging configuration
|
|
6. **Error Handling Patterns** - Consistent error responses
|
|
|
|
---
|
|
|
|
## 1. Progress Notifications
|
|
|
|
For operations that take time (data processing, file downloads, API calls), progress notifications keep users informed.
|
|
|
|
### How It Works
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client
|
|
participant Server
|
|
|
|
Client->>Server: tools/call (long operation)
|
|
Server-->>Client: notification: progress 10%
|
|
Server-->>Client: notification: progress 50%
|
|
Server-->>Client: notification: progress 90%
|
|
Server->>Client: result (complete)
|
|
```
|
|
|
|
### Python Implementation
|
|
|
|
```python
|
|
from mcp.server import Server, NotificationOptions
|
|
from mcp.types import ProgressNotification
|
|
import asyncio
|
|
|
|
app = Server("progress-server")
|
|
|
|
@app.tool()
|
|
async def process_large_file(file_path: str, ctx) -> str:
|
|
"""Process a large file with progress updates."""
|
|
|
|
# Get file size for progress calculation
|
|
file_size = os.path.getsize(file_path)
|
|
processed = 0
|
|
|
|
with open(file_path, 'rb') as f:
|
|
while chunk := f.read(8192):
|
|
# Process chunk
|
|
await process_chunk(chunk)
|
|
processed += len(chunk)
|
|
|
|
# Send progress notification
|
|
progress = (processed / file_size) * 100
|
|
await ctx.send_notification(
|
|
ProgressNotification(
|
|
progressToken=ctx.request_id,
|
|
progress=progress,
|
|
total=100,
|
|
message=f"Processing: {progress:.1f}%"
|
|
)
|
|
)
|
|
|
|
return f"Processed {file_size} bytes"
|
|
|
|
@app.tool()
|
|
async def batch_operation(items: list[str], ctx) -> str:
|
|
"""Process multiple items with progress."""
|
|
|
|
results = []
|
|
total = len(items)
|
|
|
|
for i, item in enumerate(items):
|
|
result = await process_item(item)
|
|
results.append(result)
|
|
|
|
# Report progress after each item
|
|
await ctx.send_notification(
|
|
ProgressNotification(
|
|
progressToken=ctx.request_id,
|
|
progress=i + 1,
|
|
total=total,
|
|
message=f"Processed {i + 1}/{total}: {item}"
|
|
)
|
|
)
|
|
|
|
return f"Completed {total} items"
|
|
```
|
|
|
|
### TypeScript Implementation
|
|
|
|
```typescript
|
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
|
server.setRequestHandler(CallToolSchema, async (request, extra) => {
|
|
const { name, arguments: args } = request.params;
|
|
|
|
if (name === "process_data") {
|
|
const items = args.items as string[];
|
|
const results = [];
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const result = await processItem(items[i]);
|
|
results.push(result);
|
|
|
|
// Send progress notification
|
|
await extra.sendNotification({
|
|
method: "notifications/progress",
|
|
params: {
|
|
progressToken: request.id,
|
|
progress: i + 1,
|
|
total: items.length,
|
|
message: `Processing item ${i + 1}/${items.length}`
|
|
}
|
|
});
|
|
}
|
|
|
|
return { content: [{ type: "text", text: JSON.stringify(results) }] };
|
|
}
|
|
});
|
|
```
|
|
|
|
### Client Handling (Python)
|
|
|
|
```python
|
|
async def handle_progress(notification):
|
|
"""Handle progress notifications from server."""
|
|
params = notification.params
|
|
print(f"Progress: {params.progress}/{params.total} - {params.message}")
|
|
|
|
# Register handler
|
|
session.on_notification("notifications/progress", handle_progress)
|
|
|
|
# Call tool (progress updates will arrive via handler)
|
|
result = await session.call_tool("process_large_file", {"file_path": "/data/large.csv"})
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Request Cancellation
|
|
|
|
Allow clients to cancel requests that are no longer needed or taking too long.
|
|
|
|
### Python Implementation
|
|
|
|
```python
|
|
from mcp.server import Server
|
|
from mcp.types import CancelledError
|
|
import asyncio
|
|
|
|
app = Server("cancellable-server")
|
|
|
|
@app.tool()
|
|
async def long_running_search(query: str, ctx) -> str:
|
|
"""Search that can be cancelled."""
|
|
|
|
results = []
|
|
|
|
try:
|
|
for page in range(100): # Search through many pages
|
|
# Check if cancellation was requested
|
|
if ctx.is_cancelled:
|
|
raise CancelledError("Search cancelled by user")
|
|
|
|
# Simulate page search
|
|
page_results = await search_page(query, page)
|
|
results.extend(page_results)
|
|
|
|
# Small delay allows cancellation checks
|
|
await asyncio.sleep(0.1)
|
|
|
|
except CancelledError:
|
|
# Return partial results
|
|
return f"Cancelled. Found {len(results)} results before cancellation."
|
|
|
|
return f"Found {len(results)} total results"
|
|
|
|
@app.tool()
|
|
async def download_file(url: str, ctx) -> str:
|
|
"""Download with cancellation support."""
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(url) as response:
|
|
total_size = int(response.headers.get('content-length', 0))
|
|
downloaded = 0
|
|
chunks = []
|
|
|
|
async for chunk in response.content.iter_chunked(8192):
|
|
if ctx.is_cancelled:
|
|
return f"Download cancelled at {downloaded}/{total_size} bytes"
|
|
|
|
chunks.append(chunk)
|
|
downloaded += len(chunk)
|
|
|
|
return f"Downloaded {downloaded} bytes"
|
|
```
|
|
|
|
### Implementing Cancellation Context
|
|
|
|
```python
|
|
class CancellableContext:
|
|
"""Context object that tracks cancellation state."""
|
|
|
|
def __init__(self, request_id: str):
|
|
self.request_id = request_id
|
|
self._cancelled = asyncio.Event()
|
|
self._cancel_reason = None
|
|
|
|
@property
|
|
def is_cancelled(self) -> bool:
|
|
return self._cancelled.is_set()
|
|
|
|
def cancel(self, reason: str = "Cancelled"):
|
|
self._cancel_reason = reason
|
|
self._cancelled.set()
|
|
|
|
async def check_cancelled(self):
|
|
"""Raise if cancelled, otherwise continue."""
|
|
if self.is_cancelled:
|
|
raise CancelledError(self._cancel_reason)
|
|
|
|
async def sleep_or_cancel(self, seconds: float):
|
|
"""Sleep that can be interrupted by cancellation."""
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._cancelled.wait(),
|
|
timeout=seconds
|
|
)
|
|
raise CancelledError(self._cancel_reason)
|
|
except asyncio.TimeoutError:
|
|
pass # Normal timeout, continue
|
|
```
|
|
|
|
### Client-Side Cancellation
|
|
|
|
```python
|
|
import asyncio
|
|
|
|
async def search_with_timeout(session, query, timeout=30):
|
|
"""Search with automatic cancellation on timeout."""
|
|
|
|
task = asyncio.create_task(
|
|
session.call_tool("long_running_search", {"query": query})
|
|
)
|
|
|
|
try:
|
|
result = await asyncio.wait_for(task, timeout=timeout)
|
|
return result
|
|
except asyncio.TimeoutError:
|
|
# Request cancellation
|
|
await session.send_notification({
|
|
"method": "notifications/cancelled",
|
|
"params": {"requestId": task.request_id, "reason": "Timeout"}
|
|
})
|
|
return "Search timed out"
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Resource Templates
|
|
|
|
Resource templates allow dynamic URI construction with parameters, useful for APIs and databases.
|
|
|
|
### Defining Templates
|
|
|
|
```python
|
|
from mcp.server import Server
|
|
from mcp.types import ResourceTemplate
|
|
|
|
app = Server("template-server")
|
|
|
|
@app.list_resource_templates()
|
|
async def list_templates() -> list[ResourceTemplate]:
|
|
"""Return available resource templates."""
|
|
return [
|
|
ResourceTemplate(
|
|
uriTemplate="db://users/{user_id}",
|
|
name="User Profile",
|
|
description="Fetch user profile by ID",
|
|
mimeType="application/json"
|
|
),
|
|
ResourceTemplate(
|
|
uriTemplate="api://weather/{city}/{date}",
|
|
name="Weather Data",
|
|
description="Historical weather for city and date",
|
|
mimeType="application/json"
|
|
),
|
|
ResourceTemplate(
|
|
uriTemplate="file://{path}",
|
|
name="File Content",
|
|
description="Read file at given path",
|
|
mimeType="text/plain"
|
|
)
|
|
]
|
|
|
|
@app.read_resource()
|
|
async def read_resource(uri: str) -> str:
|
|
"""Read resource, expanding template parameters."""
|
|
|
|
# Parse the URI to extract parameters
|
|
if uri.startswith("db://users/"):
|
|
user_id = uri.split("/")[-1]
|
|
return await fetch_user(user_id)
|
|
|
|
elif uri.startswith("api://weather/"):
|
|
parts = uri.replace("api://weather/", "").split("/")
|
|
city, date = parts[0], parts[1]
|
|
return await fetch_weather(city, date)
|
|
|
|
elif uri.startswith("file://"):
|
|
path = uri.replace("file://", "")
|
|
return await read_file(path)
|
|
|
|
raise ValueError(f"Unknown resource URI: {uri}")
|
|
```
|
|
|
|
### TypeScript Implementation
|
|
|
|
```typescript
|
|
server.setRequestHandler(ListResourceTemplatesSchema, async () => {
|
|
return {
|
|
resourceTemplates: [
|
|
{
|
|
uriTemplate: "github://repos/{owner}/{repo}/issues/{issue_number}",
|
|
name: "GitHub Issue",
|
|
description: "Fetch a specific GitHub issue",
|
|
mimeType: "application/json"
|
|
},
|
|
{
|
|
uriTemplate: "db://tables/{table}/rows/{id}",
|
|
name: "Database Row",
|
|
description: "Fetch a row from a database table",
|
|
mimeType: "application/json"
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
server.setRequestHandler(ReadResourceSchema, async (request) => {
|
|
const uri = request.params.uri;
|
|
|
|
// Parse GitHub issue URI
|
|
const githubMatch = uri.match(/^github:\/\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)$/);
|
|
if (githubMatch) {
|
|
const [_, owner, repo, issueNumber] = githubMatch;
|
|
const issue = await fetchGitHubIssue(owner, repo, parseInt(issueNumber));
|
|
return {
|
|
contents: [{
|
|
uri,
|
|
mimeType: "application/json",
|
|
text: JSON.stringify(issue, null, 2)
|
|
}]
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unknown resource URI: ${uri}`);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Server Lifecycle Events
|
|
|
|
Proper initialization and shutdown handling ensures clean resource management.
|
|
|
|
### Python Lifecycle Management
|
|
|
|
```python
|
|
from mcp.server import Server
|
|
from contextlib import asynccontextmanager
|
|
|
|
app = Server("lifecycle-server")
|
|
|
|
# Shared state
|
|
db_connection = None
|
|
cache = None
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(server: Server):
|
|
"""Manage server lifecycle."""
|
|
global db_connection, cache
|
|
|
|
# Startup
|
|
print("🚀 Server starting...")
|
|
db_connection = await create_database_connection()
|
|
cache = await create_cache_client()
|
|
print("✅ Resources initialized")
|
|
|
|
yield # Server runs here
|
|
|
|
# Shutdown
|
|
print("🛑 Server shutting down...")
|
|
await db_connection.close()
|
|
await cache.close()
|
|
print("✅ Resources cleaned up")
|
|
|
|
app = Server("lifecycle-server", lifespan=lifespan)
|
|
|
|
@app.tool()
|
|
async def query_database(sql: str) -> str:
|
|
"""Use the shared database connection."""
|
|
result = await db_connection.execute(sql)
|
|
return str(result)
|
|
```
|
|
|
|
### TypeScript Lifecycle
|
|
|
|
```typescript
|
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
|
class ManagedServer {
|
|
private server: Server;
|
|
private dbConnection: DatabaseConnection | null = null;
|
|
|
|
constructor() {
|
|
this.server = new Server({
|
|
name: "lifecycle-server",
|
|
version: "1.0.0"
|
|
});
|
|
|
|
this.setupHandlers();
|
|
}
|
|
|
|
async start() {
|
|
// Initialize resources
|
|
console.log("🚀 Server starting...");
|
|
this.dbConnection = await createDatabaseConnection();
|
|
console.log("✅ Database connected");
|
|
|
|
// Start server
|
|
await this.server.connect(transport);
|
|
}
|
|
|
|
async stop() {
|
|
// Cleanup resources
|
|
console.log("🛑 Server shutting down...");
|
|
if (this.dbConnection) {
|
|
await this.dbConnection.close();
|
|
}
|
|
await this.server.close();
|
|
console.log("✅ Cleanup complete");
|
|
}
|
|
|
|
private setupHandlers() {
|
|
this.server.setRequestHandler(CallToolSchema, async (request) => {
|
|
// Use this.dbConnection safely
|
|
// ...
|
|
});
|
|
}
|
|
}
|
|
|
|
// Usage with graceful shutdown
|
|
const server = new ManagedServer();
|
|
|
|
process.on('SIGINT', async () => {
|
|
await server.stop();
|
|
process.exit(0);
|
|
});
|
|
|
|
await server.start();
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Logging Control
|
|
|
|
MCP supports server-side logging levels that clients can control.
|
|
|
|
### Implementing Logging Levels
|
|
|
|
```python
|
|
from mcp.server import Server
|
|
from mcp.types import LoggingLevel
|
|
import logging
|
|
|
|
app = Server("logging-server")
|
|
|
|
# Map MCP levels to Python logging levels
|
|
LEVEL_MAP = {
|
|
LoggingLevel.DEBUG: logging.DEBUG,
|
|
LoggingLevel.INFO: logging.INFO,
|
|
LoggingLevel.WARNING: logging.WARNING,
|
|
LoggingLevel.ERROR: logging.ERROR,
|
|
}
|
|
|
|
logger = logging.getLogger("mcp-server")
|
|
|
|
@app.set_logging_level()
|
|
async def set_logging_level(level: LoggingLevel) -> None:
|
|
"""Handle client request to change logging level."""
|
|
python_level = LEVEL_MAP.get(level, logging.INFO)
|
|
logger.setLevel(python_level)
|
|
logger.info(f"Logging level set to {level}")
|
|
|
|
@app.tool()
|
|
async def debug_operation(data: str) -> str:
|
|
"""Tool with various logging levels."""
|
|
logger.debug(f"Processing data: {data}")
|
|
|
|
try:
|
|
result = process(data)
|
|
logger.info(f"Successfully processed: {result}")
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Processing failed: {e}")
|
|
raise
|
|
```
|
|
|
|
### Sending Log Messages to Client
|
|
|
|
```python
|
|
@app.tool()
|
|
async def complex_operation(input: str, ctx) -> str:
|
|
"""Operation that logs to client."""
|
|
|
|
# Send log notification to client
|
|
await ctx.send_log(
|
|
level="info",
|
|
message=f"Starting complex operation with input: {input}"
|
|
)
|
|
|
|
# Do work...
|
|
result = await do_work(input)
|
|
|
|
await ctx.send_log(
|
|
level="debug",
|
|
message=f"Operation complete, result size: {len(result)}"
|
|
)
|
|
|
|
return result
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Error Handling Patterns
|
|
|
|
Consistent error handling improves debugging and user experience.
|
|
|
|
### MCP Error Codes
|
|
|
|
```python
|
|
from mcp.types import McpError, ErrorCode
|
|
|
|
class ToolError(McpError):
|
|
"""Base class for tool errors."""
|
|
pass
|
|
|
|
class ValidationError(ToolError):
|
|
"""Invalid input parameters."""
|
|
def __init__(self, message: str):
|
|
super().__init__(ErrorCode.INVALID_PARAMS, message)
|
|
|
|
class NotFoundError(ToolError):
|
|
"""Requested resource not found."""
|
|
def __init__(self, resource: str):
|
|
super().__init__(ErrorCode.INVALID_REQUEST, f"Not found: {resource}")
|
|
|
|
class PermissionError(ToolError):
|
|
"""Access denied."""
|
|
def __init__(self, action: str):
|
|
super().__init__(ErrorCode.INVALID_REQUEST, f"Permission denied: {action}")
|
|
|
|
class InternalError(ToolError):
|
|
"""Internal server error."""
|
|
def __init__(self, message: str):
|
|
super().__init__(ErrorCode.INTERNAL_ERROR, message)
|
|
```
|
|
|
|
### Structured Error Responses
|
|
|
|
```python
|
|
@app.tool()
|
|
async def safe_operation(input: str) -> str:
|
|
"""Tool with comprehensive error handling."""
|
|
|
|
# Validate input
|
|
if not input:
|
|
raise ValidationError("Input cannot be empty")
|
|
|
|
if len(input) > 10000:
|
|
raise ValidationError(f"Input too large: {len(input)} chars (max 10000)")
|
|
|
|
try:
|
|
# Check permissions
|
|
if not await check_permission(input):
|
|
raise PermissionError(f"read {input}")
|
|
|
|
# Perform operation
|
|
result = await perform_operation(input)
|
|
|
|
if result is None:
|
|
raise NotFoundError(input)
|
|
|
|
return result
|
|
|
|
except ConnectionError as e:
|
|
raise InternalError(f"Database connection failed: {e}")
|
|
except TimeoutError as e:
|
|
raise InternalError(f"Operation timed out: {e}")
|
|
except Exception as e:
|
|
# Log unexpected errors
|
|
logger.exception(f"Unexpected error in safe_operation")
|
|
raise InternalError(f"Unexpected error: {type(e).__name__}")
|
|
```
|
|
|
|
### Error Handling in TypeScript
|
|
|
|
```typescript
|
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
|
|
function validateInput(data: unknown): asserts data is ValidInput {
|
|
if (typeof data !== "object" || data === null) {
|
|
throw new McpError(
|
|
ErrorCode.InvalidParams,
|
|
"Input must be an object"
|
|
);
|
|
}
|
|
// More validation...
|
|
}
|
|
|
|
server.setRequestHandler(CallToolSchema, async (request) => {
|
|
try {
|
|
validateInput(request.params.arguments);
|
|
|
|
const result = await performOperation(request.params.arguments);
|
|
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
};
|
|
|
|
} catch (error) {
|
|
if (error instanceof McpError) {
|
|
throw error; // Already an MCP error
|
|
}
|
|
|
|
// Convert other errors
|
|
if (error instanceof NotFoundError) {
|
|
throw new McpError(ErrorCode.InvalidRequest, error.message);
|
|
}
|
|
|
|
// Unknown error
|
|
console.error("Unexpected error:", error);
|
|
throw new McpError(
|
|
ErrorCode.InternalError,
|
|
"An unexpected error occurred"
|
|
);
|
|
}
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Experimental Features (MCP 2025-11-25)
|
|
|
|
These features are marked as experimental in the specification:
|
|
|
|
### Tasks (Long-Running Operations)
|
|
|
|
```python
|
|
# Tasks allow tracking long-running operations with state
|
|
@app.task()
|
|
async def training_task(model_id: str, data_path: str, ctx) -> str:
|
|
"""Long-running ML training task."""
|
|
|
|
# Report task started
|
|
await ctx.report_status("running", "Initializing training...")
|
|
|
|
# Training loop
|
|
for epoch in range(100):
|
|
await train_epoch(model_id, data_path, epoch)
|
|
await ctx.report_status(
|
|
"running",
|
|
f"Training epoch {epoch + 1}/100",
|
|
progress=epoch + 1,
|
|
total=100
|
|
)
|
|
|
|
await ctx.report_status("completed", "Training finished")
|
|
return f"Model {model_id} trained successfully"
|
|
```
|
|
|
|
### Tool Annotations
|
|
|
|
```python
|
|
# Annotations provide metadata about tool behavior
|
|
@app.tool(
|
|
annotations={
|
|
"destructive": False, # Does not modify data
|
|
"idempotent": True, # Safe to retry
|
|
"timeout_seconds": 30, # Expected max duration
|
|
"requires_approval": False # No user approval needed
|
|
}
|
|
)
|
|
async def safe_query(query: str) -> str:
|
|
"""A read-only database query tool."""
|
|
return await execute_read_query(query)
|
|
```
|
|
|
|
---
|
|
|
|
## What's Next
|
|
|
|
- [Module 8 - Best Practices](../../08-BestPractices/README.md)
|
|
- [5.14 - Context Engineering](../mcp-contextengineering/README.md)
|
|
- [MCP Specification Changelog](https://spec.modelcontextprotocol.io/)
|
|
|
|
---
|
|
|
|
## Additional Resources
|
|
|
|
- [MCP Specification 2025-11-25](https://spec.modelcontextprotocol.io/specification/2025-11-25/)
|
|
- [JSON-RPC 2.0 Error Codes](https://www.jsonrpc.org/specification#error_object)
|
|
- [Python SDK Examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples)
|
|
- [TypeScript SDK Examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples)
|