1001 lines
24 KiB
Markdown
1001 lines
24 KiB
Markdown
# Dynamic Manifests
|
|
|
|
> **Definition**: A runtime capability discovery system that allows clients to query available tools, resources, and features dynamically without requiring restarts or static configuration.
|
|
|
|
**Navigation**: [← Progressive Disclosure](./progressive-disclosure.md) | [↑ Best Practices](../README.md) | [Deferred Loading →](./deferred-loading.md)
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [What Is It?](#what-is-it) ← Start here
|
|
- [Quick Start](#quick-start) ← Get it working in 5 minutes
|
|
- [Why Dynamic?](#why-dynamic)
|
|
- [Configuration](#configuration) ← For practitioners
|
|
- [Server Implementation](#server-implementation)
|
|
- [Client Implementation](#client-implementation)
|
|
- [Capability Systems](#capability-systems) ← For architects
|
|
- [MCP-Specific Setup](#mcp-specific-setup)
|
|
- [Troubleshooting](#troubleshooting)
|
|
|
|
---
|
|
|
|
## What Is It?
|
|
|
|
Traditional systems load capabilities at startup. Dynamic manifests query them at runtime.
|
|
|
|
### Visual Comparison
|
|
|
|
```
|
|
STATIC MANIFEST (Old Way)
|
|
─────────────────────────────────────
|
|
Startup:
|
|
Load manifest.json → Parse all tools → Cache forever
|
|
Tools: [A, B, C, D, E]
|
|
|
|
Hour 1: User needs Tool A
|
|
✓ Use cached Tool A
|
|
|
|
Hour 2: Server adds Tool F
|
|
✗ Client doesn't know about Tool F
|
|
✗ Requires restart to see Tool F
|
|
|
|
Hour 3: Server removes Tool D
|
|
✗ Client still tries to use Tool D
|
|
✗ Error: Tool not found
|
|
|
|
|
|
DYNAMIC MANIFEST (New Way)
|
|
─────────────────────────────────────
|
|
Startup:
|
|
Connect to server → Register for updates
|
|
Initial query: Tools = [A, B, C, D, E]
|
|
|
|
Hour 1: User needs Tool A
|
|
Query server → Tools available: [A, B, C, D, E]
|
|
✓ Use Tool A
|
|
|
|
Hour 2: Server adds Tool F
|
|
Server notifies → Client updates
|
|
Query server → Tools available: [A, B, C, D, E, F]
|
|
✓ Tool F immediately available
|
|
|
|
Hour 3: Server removes Tool D
|
|
Server notifies → Client updates
|
|
Query server → Tools available: [A, B, C, E, F]
|
|
✓ Client never tries to use Tool D
|
|
```
|
|
|
|
### Key Difference
|
|
|
|
| Aspect | Static | Dynamic |
|
|
|--------|--------|---------|
|
|
| **When capabilities are discovered** | Startup only | Runtime, continuously |
|
|
| **Adding new features** | Requires restart | Available immediately |
|
|
| **Removing features** | Clients break until restart | Graceful degradation |
|
|
| **Conditional availability** | Not possible | Context-aware |
|
|
| **Memory usage** | All features loaded | On-demand loading |
|
|
|
|
---
|
|
|
|
## Quick Start
|
|
|
|
Get dynamic manifests working in 5 minutes.
|
|
|
|
### Step 1: Server Configuration
|
|
|
|
Create manifest file at standard location:
|
|
|
|
**.well-known/mcp/manifest.json**
|
|
```json
|
|
{
|
|
"id": "my-mcp-server",
|
|
"name": "My MCP Server",
|
|
"version": "1.0.0",
|
|
"last_updated": "2025-10-20T10:00:00Z",
|
|
"capabilities": {
|
|
"tools": true,
|
|
"resources": true,
|
|
"prompts": true,
|
|
"dynamic_discovery": true
|
|
},
|
|
"tools": [
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Get current weather for a location",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"location": {
|
|
"type": "string",
|
|
"description": "City name"
|
|
}
|
|
},
|
|
"required": ["location"]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Step 2: Client Configuration
|
|
|
|
**For Claude Desktop** (claude_desktop_config.json):
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"my-server": {
|
|
"command": "node",
|
|
"args": ["path/to/server.js"],
|
|
"dynamicDiscovery": true,
|
|
"discoveryInterval": 5000
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**For Spring AI**:
|
|
```java
|
|
@Bean
|
|
public McpClient mcpClient() {
|
|
return McpClient.builder()
|
|
.dynamicToolDiscovery(true)
|
|
.discoveryIntervalMs(5000)
|
|
.build();
|
|
}
|
|
```
|
|
|
|
### Step 3: Test
|
|
|
|
Add new tool to manifest (without restart):
|
|
```json
|
|
{
|
|
"tools": [
|
|
{
|
|
"name": "get_weather",
|
|
...
|
|
},
|
|
{
|
|
"name": "get_forecast",
|
|
"description": "Get 5-day forecast",
|
|
"parameters": { ... }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Client should see new tool within 5 seconds!
|
|
|
|
---
|
|
|
|
## Why Dynamic?
|
|
|
|
### Problem 1: Static Configuration is Brittle
|
|
|
|
```
|
|
Dev adds new feature → Commit server code
|
|
↓
|
|
Deploy server → New feature available
|
|
↓
|
|
❌ But: Clients don't know about it
|
|
↓
|
|
Users restart apps → Feature finally available
|
|
```
|
|
|
|
This creates delays and poor UX.
|
|
|
|
### Problem 2: Context Awareness
|
|
|
|
Static manifests can't adapt:
|
|
|
|
```
|
|
User A (beginner): Static manifest shows all 50 tools ❌
|
|
User B (expert): Static manifest shows all 50 tools ❌
|
|
User C (admin): Static manifest shows all 50 tools ❌
|
|
|
|
Everyone sees everything, regardless of skill/permissions.
|
|
```
|
|
|
|
Dynamic manifests adapt:
|
|
|
|
```
|
|
User A (beginner): Query → 5 basic tools ✓
|
|
User B (expert): Query → 25 tools (basic + advanced) ✓
|
|
User C (admin): Query → 50 tools (all features) ✓
|
|
|
|
Each user sees appropriate capabilities.
|
|
```
|
|
|
|
### Problem 3: Performance
|
|
|
|
Static manifests load everything:
|
|
|
|
```
|
|
Startup: Load 50 tools → Initialize all dependencies
|
|
Memory: 500MB
|
|
Time: 10 seconds
|
|
```
|
|
|
|
Dynamic manifests load on-demand:
|
|
|
|
```
|
|
Startup: Connect to server
|
|
Memory: 50MB
|
|
Time: 1 second
|
|
|
|
Later: User requests Tool X → Load Tool X dependencies
|
|
```
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
### Server-Side: Expose Discovery Endpoints
|
|
|
|
Your MCP server must implement:
|
|
|
|
```
|
|
GET /.well-known/mcp/manifest.json → Full manifest
|
|
GET /tools/list → Available tools
|
|
GET /resources/list → Available resources
|
|
GET /prompts/list → Available prompts
|
|
```
|
|
|
|
**Example Express.js Server**:
|
|
```javascript
|
|
const express = require('express');
|
|
const app = express();
|
|
|
|
// Manifest endpoint
|
|
app.get('/.well-known/mcp/manifest.json', (req, res) => {
|
|
res.json({
|
|
id: 'my-server',
|
|
version: '1.0.0',
|
|
last_updated: new Date().toISOString(),
|
|
capabilities: {
|
|
tools: true,
|
|
dynamic_discovery: true
|
|
},
|
|
tools: getAvailableTools(req.user) // Context-aware!
|
|
});
|
|
});
|
|
|
|
// Tools list endpoint
|
|
app.get('/tools/list', (req, res) => {
|
|
const tools = getAvailableTools(req.user);
|
|
res.json({ tools });
|
|
});
|
|
|
|
function getAvailableTools(user) {
|
|
const tools = [
|
|
{ name: 'basic_tool', description: '...' }
|
|
];
|
|
|
|
if (user.isAdmin) {
|
|
tools.push({ name: 'admin_tool', description: '...' });
|
|
}
|
|
|
|
return tools;
|
|
}
|
|
|
|
app.listen(3000);
|
|
```
|
|
|
|
### Client-Side: Enable Dynamic Discovery
|
|
|
|
**Key Configuration Options**:
|
|
|
|
| Option | Description | Default | Recommended |
|
|
|--------|-------------|---------|-------------|
|
|
| `dynamicDiscovery` | Enable runtime queries | `false` | `true` |
|
|
| `discoveryInterval` | How often to check (ms) | `10000` | `5000` |
|
|
| `cacheManifest` | Cache between queries | `true` | `true` |
|
|
| `cacheTimeout` | Cache duration (ms) | `300000` | `60000` |
|
|
|
|
**Example: Python Client**:
|
|
```python
|
|
from mcp import MCPClient
|
|
|
|
client = MCPClient(
|
|
server_url="http://localhost:3000",
|
|
dynamic_discovery=True,
|
|
discovery_interval=5000, # Check every 5 seconds
|
|
)
|
|
|
|
# Don't cache tools - always query fresh
|
|
async def execute_tool(tool_name, params):
|
|
# This queries server each time
|
|
tools = await client.get_available_tools()
|
|
tool = tools.get(tool_name)
|
|
|
|
if not tool:
|
|
raise Exception(f"Tool {tool_name} not available")
|
|
|
|
return await tool.execute(params)
|
|
```
|
|
|
|
### Optimization: Smart Caching
|
|
|
|
Don't query on every request - use smart caching:
|
|
|
|
```python
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
class SmartManifestCache:
|
|
def __init__(self, client, ttl_seconds=60):
|
|
self.client = client
|
|
self.ttl = timedelta(seconds=ttl_seconds)
|
|
self.cached_manifest = None
|
|
self.cached_hash = None
|
|
self.last_fetch = None
|
|
|
|
async def get_tools(self):
|
|
now = datetime.now()
|
|
|
|
# Check if cache is still valid
|
|
if (self.cached_manifest and self.last_fetch and
|
|
now - self.last_fetch < self.ttl):
|
|
return self.cached_manifest
|
|
|
|
# Fetch new manifest
|
|
manifest = await self.client.fetch_manifest()
|
|
manifest_hash = hashlib.md5(
|
|
json.dumps(manifest, sort_keys=True).encode()
|
|
).hexdigest()
|
|
|
|
# Only update if manifest changed
|
|
if manifest_hash != self.cached_hash:
|
|
print("Manifest changed! New tools available.")
|
|
self.cached_manifest = manifest
|
|
self.cached_hash = manifest_hash
|
|
|
|
self.last_fetch = now
|
|
return self.cached_manifest
|
|
```
|
|
|
|
---
|
|
|
|
## Server Implementation
|
|
|
|
### Pattern 1: Basic Dynamic Server
|
|
|
|
Minimal implementation with runtime queries:
|
|
|
|
```python
|
|
from fastapi import FastAPI
|
|
from typing import List, Dict
|
|
|
|
app = FastAPI()
|
|
|
|
# Dynamic tool registry
|
|
tool_registry = {}
|
|
|
|
def register_tool(name: str, config: Dict):
|
|
"""Register a tool at runtime"""
|
|
tool_registry[name] = config
|
|
|
|
def unregister_tool(name: str):
|
|
"""Remove a tool at runtime"""
|
|
if name in tool_registry:
|
|
del tool_registry[name]
|
|
|
|
@app.get("/.well-known/mcp/manifest.json")
|
|
async def get_manifest():
|
|
return {
|
|
"id": "dynamic-server",
|
|
"version": "1.0.0",
|
|
"last_updated": datetime.now().isoformat(),
|
|
"capabilities": {
|
|
"tools": True,
|
|
"dynamic_discovery": True
|
|
},
|
|
"tools": list(tool_registry.values())
|
|
}
|
|
|
|
@app.get("/tools/list")
|
|
async def list_tools():
|
|
return {"tools": list(tool_registry.values())}
|
|
|
|
# Add/remove tools dynamically
|
|
@app.post("/admin/register_tool")
|
|
async def admin_register_tool(tool_config: Dict):
|
|
register_tool(tool_config["name"], tool_config)
|
|
return {"status": "registered"}
|
|
|
|
# Initial tools
|
|
register_tool("get_weather", {
|
|
"name": "get_weather",
|
|
"description": "Get weather for a location",
|
|
"parameters": {...}
|
|
})
|
|
```
|
|
|
|
### Pattern 2: Context-Aware Capabilities
|
|
|
|
Adapt capabilities based on user/context:
|
|
|
|
```python
|
|
from fastapi import FastAPI, Request
|
|
from typing import Optional
|
|
|
|
@app.get("/tools/list")
|
|
async def list_tools(request: Request):
|
|
user = get_user_from_request(request)
|
|
context = detect_context(request)
|
|
|
|
tools = []
|
|
|
|
# Basic tools for everyone
|
|
tools.extend(get_basic_tools())
|
|
|
|
# Skill-based tools
|
|
if user.skill_level >= "intermediate":
|
|
tools.extend(get_advanced_tools())
|
|
|
|
# Permission-based tools
|
|
if user.has_permission("admin"):
|
|
tools.extend(get_admin_tools())
|
|
|
|
# Context-based tools
|
|
if context.project_type == "data_science":
|
|
tools.extend(get_data_tools())
|
|
|
|
if context.language == "python":
|
|
tools.extend(get_python_tools())
|
|
|
|
return {"tools": tools}
|
|
|
|
def detect_context(request: Request) -> Context:
|
|
"""Detect context from request headers/params"""
|
|
return Context(
|
|
project_type=request.headers.get("X-Project-Type"),
|
|
language=request.headers.get("X-Language"),
|
|
working_dir=request.headers.get("X-Working-Dir")
|
|
)
|
|
```
|
|
|
|
### Pattern 3: Change Notifications
|
|
|
|
Notify clients when capabilities change:
|
|
|
|
```python
|
|
from fastapi import FastAPI, WebSocket
|
|
from typing import Set
|
|
import asyncio
|
|
|
|
app = FastAPI()
|
|
active_connections: Set[WebSocket] = set()
|
|
|
|
@app.websocket("/ws")
|
|
async def websocket_endpoint(websocket: WebSocket):
|
|
await websocket.accept()
|
|
active_connections.add(websocket)
|
|
try:
|
|
while True:
|
|
await websocket.receive_text()
|
|
except:
|
|
active_connections.remove(websocket)
|
|
|
|
async def notify_tools_changed():
|
|
"""Notify all connected clients that tools changed"""
|
|
message = {
|
|
"event": "tools_changed",
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
disconnected = set()
|
|
for connection in active_connections:
|
|
try:
|
|
await connection.send_json(message)
|
|
except:
|
|
disconnected.add(connection)
|
|
|
|
# Clean up disconnected clients
|
|
active_connections.difference_update(disconnected)
|
|
|
|
# When tools change
|
|
@app.post("/admin/register_tool")
|
|
async def admin_register_tool(tool_config: Dict):
|
|
register_tool(tool_config["name"], tool_config)
|
|
await notify_tools_changed() # ← Notify clients!
|
|
return {"status": "registered"}
|
|
```
|
|
|
|
---
|
|
|
|
## Client Implementation
|
|
|
|
### Pattern 1: Polling
|
|
|
|
Periodically check for changes:
|
|
|
|
```python
|
|
import asyncio
|
|
from typing import Optional
|
|
|
|
class PollingMCPClient:
|
|
def __init__(self, server_url: str, poll_interval: int = 5):
|
|
self.server_url = server_url
|
|
self.poll_interval = poll_interval
|
|
self.cached_tools = None
|
|
self._polling_task = None
|
|
|
|
async def start(self):
|
|
"""Start polling for manifest changes"""
|
|
self._polling_task = asyncio.create_task(self._poll_loop())
|
|
|
|
async def stop(self):
|
|
"""Stop polling"""
|
|
if self._polling_task:
|
|
self._polling_task.cancel()
|
|
|
|
async def _poll_loop(self):
|
|
"""Poll server for changes"""
|
|
while True:
|
|
try:
|
|
tools = await self._fetch_tools()
|
|
if tools != self.cached_tools:
|
|
print("Tools changed!")
|
|
self.cached_tools = tools
|
|
await self._on_tools_changed(tools)
|
|
except Exception as e:
|
|
print(f"Error polling: {e}")
|
|
|
|
await asyncio.sleep(self.poll_interval)
|
|
|
|
async def _fetch_tools(self):
|
|
"""Fetch tools from server"""
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(f"{self.server_url}/tools/list") as resp:
|
|
data = await resp.json()
|
|
return data["tools"]
|
|
|
|
async def _on_tools_changed(self, tools):
|
|
"""Handle tool changes"""
|
|
# Update UI, clear caches, etc.
|
|
pass
|
|
```
|
|
|
|
### Pattern 2: WebSocket Subscriptions
|
|
|
|
Real-time updates via WebSocket:
|
|
|
|
```python
|
|
import asyncio
|
|
import websockets
|
|
import json
|
|
|
|
class WebSocketMCPClient:
|
|
def __init__(self, server_url: str):
|
|
self.server_url = server_url
|
|
self.ws = None
|
|
self.cached_tools = None
|
|
|
|
async def connect(self):
|
|
"""Connect to server WebSocket"""
|
|
ws_url = self.server_url.replace("http", "ws") + "/ws"
|
|
self.ws = await websockets.connect(ws_url)
|
|
|
|
# Start listening for events
|
|
asyncio.create_task(self._listen())
|
|
|
|
# Fetch initial tools
|
|
await self.refresh_tools()
|
|
|
|
async def _listen(self):
|
|
"""Listen for server events"""
|
|
async for message in self.ws:
|
|
event = json.loads(message)
|
|
|
|
if event["event"] == "tools_changed":
|
|
print("Server notified: tools changed")
|
|
await self.refresh_tools()
|
|
|
|
async def refresh_tools(self):
|
|
"""Fetch latest tools from server"""
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(f"{self.server_url}/tools/list") as resp:
|
|
data = await resp.json()
|
|
self.cached_tools = data["tools"]
|
|
print(f"Refreshed {len(self.cached_tools)} tools")
|
|
```
|
|
|
|
### Pattern 3: Lazy Query
|
|
|
|
Don't cache - always query fresh:
|
|
|
|
```python
|
|
class LazyMCPClient:
|
|
"""Client that always queries server for latest capabilities"""
|
|
|
|
def __init__(self, server_url: str):
|
|
self.server_url = server_url
|
|
|
|
async def get_tool(self, tool_name: str):
|
|
"""Get a specific tool (always fresh from server)"""
|
|
tools = await self._fetch_tools()
|
|
return next((t for t in tools if t["name"] == tool_name), None)
|
|
|
|
async def execute_tool(self, tool_name: str, params: dict):
|
|
"""Execute a tool (checks availability first)"""
|
|
tool = await self.get_tool(tool_name)
|
|
|
|
if not tool:
|
|
raise Exception(f"Tool {tool_name} not available")
|
|
|
|
# Execute tool...
|
|
return await self._execute(tool, params)
|
|
|
|
async def _fetch_tools(self):
|
|
"""Always fetch fresh from server"""
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(f"{self.server_url}/tools/list") as resp:
|
|
data = await resp.json()
|
|
return data["tools"]
|
|
```
|
|
|
|
---
|
|
|
|
## Capability Systems
|
|
|
|
### Tiered Capabilities
|
|
|
|
Implement progressive disclosure through capability tiers:
|
|
|
|
```python
|
|
class CapabilityTier(Enum):
|
|
BASIC = 1 # Everyone gets these
|
|
INTERMEDIATE = 2 # Requires skill level
|
|
ADVANCED = 3 # Requires permission
|
|
EXPERT = 4 # Requires admin
|
|
|
|
class CapabilityManager:
|
|
def __init__(self):
|
|
self.capabilities = {
|
|
CapabilityTier.BASIC: [
|
|
"search", "file_read", "file_write"
|
|
],
|
|
CapabilityTier.INTERMEDIATE: [
|
|
"bulk_operations", "advanced_search"
|
|
],
|
|
CapabilityTier.ADVANCED: [
|
|
"system_config", "deployment"
|
|
],
|
|
CapabilityTier.EXPERT: [
|
|
"debug_mode", "raw_sql"
|
|
]
|
|
}
|
|
|
|
def get_capabilities_for_user(self, user):
|
|
"""Return capabilities based on user tier"""
|
|
capabilities = []
|
|
|
|
# Basic: everyone
|
|
capabilities.extend(self.capabilities[CapabilityTier.BASIC])
|
|
|
|
# Intermediate: skill-based
|
|
if user.skill_level >= "intermediate":
|
|
capabilities.extend(self.capabilities[CapabilityTier.INTERMEDIATE])
|
|
|
|
# Advanced: permission-based
|
|
if user.has_permission("advanced"):
|
|
capabilities.extend(self.capabilities[CapabilityTier.ADVANCED])
|
|
|
|
# Expert: admin only
|
|
if user.is_admin:
|
|
capabilities.extend(self.capabilities[CapabilityTier.EXPERT])
|
|
|
|
return capabilities
|
|
```
|
|
|
|
### Conditional Capabilities
|
|
|
|
Enable features based on runtime conditions:
|
|
|
|
```python
|
|
class ConditionalCapabilities:
|
|
def get_capabilities(self, context):
|
|
caps = self.get_base_capabilities()
|
|
|
|
# Environment-based
|
|
if context.environment == "production":
|
|
caps.remove("debug_tools")
|
|
else:
|
|
caps.append("mock_data_generator")
|
|
|
|
# Project-based
|
|
if context.has_file("package.json"):
|
|
caps.extend(["npm_tools", "node_debugger"])
|
|
|
|
if context.has_file("requirements.txt"):
|
|
caps.extend(["pip_tools", "python_debugger"])
|
|
|
|
# Time-based
|
|
if context.is_business_hours():
|
|
caps.append("live_support_chat")
|
|
|
|
# Resource-based
|
|
if context.disk_space > 10_000_000_000: # 10GB
|
|
caps.append("video_processing")
|
|
|
|
return caps
|
|
```
|
|
|
|
### Gradual Feature Rollout
|
|
|
|
Use dynamic manifests for A/B testing and gradual rollouts:
|
|
|
|
```python
|
|
class FeatureRollout:
|
|
def __init__(self):
|
|
self.features = {
|
|
"new_search_algorithm": {
|
|
"rollout_percentage": 10, # 10% of users
|
|
"enabled_for": set()
|
|
},
|
|
"beta_ui": {
|
|
"rollout_percentage": 5,
|
|
"enabled_for": set()
|
|
}
|
|
}
|
|
|
|
def should_enable_feature(self, feature_name, user_id):
|
|
"""Determine if feature should be enabled for user"""
|
|
feature = self.features.get(feature_name)
|
|
if not feature:
|
|
return False
|
|
|
|
# Already enabled?
|
|
if user_id in feature["enabled_for"]:
|
|
return True
|
|
|
|
# Roll dice
|
|
import random
|
|
if random.random() * 100 < feature["rollout_percentage"]:
|
|
feature["enabled_for"].add(user_id)
|
|
return True
|
|
|
|
return False
|
|
|
|
def get_capabilities_for_user(self, user_id):
|
|
caps = ["basic_tool_1", "basic_tool_2"]
|
|
|
|
if self.should_enable_feature("new_search_algorithm", user_id):
|
|
caps.append("new_search_tool")
|
|
|
|
if self.should_enable_feature("beta_ui", user_id):
|
|
caps.append("beta_ui_tools")
|
|
|
|
return caps
|
|
```
|
|
|
|
---
|
|
|
|
## MCP-Specific Setup
|
|
|
|
### Standard MCP Manifest Location
|
|
|
|
```
|
|
project_root/
|
|
├── .well-known/
|
|
│ └── mcp/
|
|
│ └── manifest.json ← Must be here
|
|
```
|
|
|
|
### MCP Manifest Schema
|
|
|
|
```json
|
|
{
|
|
"$schema": "https://spec.modelcontextprotocol.io/manifest/v1",
|
|
"id": "unique-server-id",
|
|
"name": "Human Readable Name",
|
|
"version": "1.0.0",
|
|
"description": "What this server does",
|
|
"last_updated": "2025-10-20T10:00:00Z",
|
|
|
|
"capabilities": {
|
|
"tools": true,
|
|
"resources": true,
|
|
"prompts": true,
|
|
"sampling": false,
|
|
"dynamic_discovery": true
|
|
},
|
|
|
|
"tools": [
|
|
{
|
|
"name": "tool_name",
|
|
"description": "What the tool does",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"param1": {
|
|
"type": "string",
|
|
"description": "Parameter description"
|
|
}
|
|
},
|
|
"required": ["param1"]
|
|
}
|
|
}
|
|
],
|
|
|
|
"resources": [
|
|
{
|
|
"uri": "resource://path/to/resource",
|
|
"name": "Resource Name",
|
|
"description": "What the resource provides",
|
|
"mimeType": "application/json"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Claude Desktop Configuration
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"my-dynamic-server": {
|
|
"command": "node",
|
|
"args": ["path/to/server.js"],
|
|
|
|
// Enable dynamic discovery
|
|
"dynamicDiscovery": true,
|
|
|
|
// How often to check for changes (ms)
|
|
"discoveryInterval": 5000,
|
|
|
|
// Optional: Environment variables
|
|
"env": {
|
|
"API_KEY": "your-key-here"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Known Limitations
|
|
|
|
⚠️ **Claude Code Issue #4110**: Dynamic resources may not be discovered properly in Claude Code. Workarounds:
|
|
- Use static resources
|
|
- Use tools instead of resources
|
|
- Poll for changes manually
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Problem: Changes Not Appearing
|
|
|
|
**Symptoms**: Server updated, client doesn't see changes
|
|
|
|
**Check**:
|
|
1. Is `dynamicDiscovery` enabled in client config?
|
|
```json
|
|
{ "dynamicDiscovery": true }
|
|
```
|
|
|
|
2. Is manifest endpoint returning updated timestamp?
|
|
```bash
|
|
curl http://localhost:3000/.well-known/mcp/manifest.json
|
|
# Check "last_updated" field
|
|
```
|
|
|
|
3. Is cache timeout too long?
|
|
```json
|
|
{ "cacheTimeout": 60000 } // Try lower value
|
|
```
|
|
|
|
4. Check client logs:
|
|
```bash
|
|
tail -f ~/.config/claude/logs/mcp.log
|
|
```
|
|
|
|
### Problem: Too Many Requests
|
|
|
|
**Symptoms**: Server overwhelmed with manifest queries
|
|
|
|
**Solutions**:
|
|
1. Increase discovery interval:
|
|
```json
|
|
{ "discoveryInterval": 30000 } // 30 seconds
|
|
```
|
|
|
|
2. Implement rate limiting:
|
|
```python
|
|
from slowapi import Limiter
|
|
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
@app.get("/tools/list")
|
|
@limiter.limit("10/minute")
|
|
async def list_tools():
|
|
...
|
|
```
|
|
|
|
3. Use ETags for conditional requests:
|
|
```python
|
|
@app.get("/tools/list")
|
|
async def list_tools(request: Request):
|
|
tools = get_tools()
|
|
tools_hash = hashlib.md5(json.dumps(tools).encode()).hexdigest()
|
|
|
|
if request.headers.get("If-None-Match") == tools_hash:
|
|
return Response(status_code=304) # Not Modified
|
|
|
|
return Response(
|
|
content=json.dumps(tools),
|
|
headers={"ETag": tools_hash}
|
|
)
|
|
```
|
|
|
|
### Problem: Stale Cache
|
|
|
|
**Symptoms**: Client sees old capabilities after server update
|
|
|
|
**Solutions**:
|
|
1. Add cache-busting timestamp:
|
|
```json
|
|
{
|
|
"last_updated": "2025-10-20T10:30:00Z",
|
|
"cache_key": "v1-20251020-103000"
|
|
}
|
|
```
|
|
|
|
2. Implement version-based invalidation:
|
|
```python
|
|
@app.get("/tools/list")
|
|
async def list_tools(client_version: Optional[str] = None):
|
|
current_version = "v1.2.3"
|
|
|
|
if client_version != current_version:
|
|
# Force full refresh
|
|
return {
|
|
"version": current_version,
|
|
"invalidate_cache": True,
|
|
"tools": get_tools()
|
|
}
|
|
|
|
return {"tools": get_tools()}
|
|
```
|
|
|
|
---
|
|
|
|
## Related Concepts
|
|
|
|
### Dynamic Manifests ↔ [Progressive Disclosure](./progressive-disclosure.md)
|
|
|
|
- Progressive disclosure: **Design pattern** for revealing information gradually
|
|
- Dynamic manifests: **Technical implementation** for discovering capabilities
|
|
|
|
Example flow:
|
|
1. User opens app (Progressive disclosure: show basic UI)
|
|
2. Client queries server (Dynamic manifest: get available tools)
|
|
3. Server returns capabilities based on user tier (Progressive disclosure: only relevant features)
|
|
4. UI updates to show available features (Progressive disclosure: tiered feature set)
|
|
|
|
### Dynamic Manifests → [Deferred Loading](./deferred-loading.md)
|
|
|
|
Dynamic manifests tell you **what's available**.
|
|
Deferred loading determines **when to load it**.
|
|
|
|
```
|
|
Dynamic Manifest Query:
|
|
Server: "Tool X is available"
|
|
|
|
Deferred Loading Decision:
|
|
Client: "Don't load Tool X yet, wait until user calls it"
|
|
|
|
User Invokes Tool X:
|
|
Client: "Now load Tool X's dependencies"
|
|
```
|
|
|
|
See: [Deferred Loading: Lazy Initialization](./deferred-loading.md#lazy-initialization)
|
|
|
|
---
|
|
|
|
**Navigation**: [← Progressive Disclosure](./progressive-disclosure.md) | [↑ Best Practices](../README.md) | [Deferred Loading →](./deferred-loading.md)
|
|
|
|
**Last Updated**: 2025-10-20
|