24 KiB
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 | ↑ Best Practices | Deferred Loading →
Table of Contents
- What Is It? ← Start here
- Quick Start ← Get it working in 5 minutes
- Why Dynamic?
- Configuration ← For practitioners
- Server Implementation
- Client Implementation
- Capability Systems ← For architects
- MCP-Specific Setup
- 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
{
"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):
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["path/to/server.js"],
"dynamicDiscovery": true,
"discoveryInterval": 5000
}
}
}
For Spring AI:
@Bean
public McpClient mcpClient() {
return McpClient.builder()
.dynamicToolDiscovery(true)
.discoveryIntervalMs(5000)
.build();
}
Step 3: Test
Add new tool to manifest (without restart):
{
"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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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
{
"$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
{
"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:
-
Is
dynamicDiscoveryenabled in client config?{ "dynamicDiscovery": true } -
Is manifest endpoint returning updated timestamp?
curl http://localhost:3000/.well-known/mcp/manifest.json # Check "last_updated" field -
Is cache timeout too long?
{ "cacheTimeout": 60000 } // Try lower value -
Check client logs:
tail -f ~/.config/claude/logs/mcp.log
Problem: Too Many Requests
Symptoms: Server overwhelmed with manifest queries
Solutions:
-
Increase discovery interval:
{ "discoveryInterval": 30000 } // 30 seconds -
Implement rate limiting:
from slowapi import Limiter limiter = Limiter(key_func=get_remote_address) @app.get("/tools/list") @limiter.limit("10/minute") async def list_tools(): ... -
Use ETags for conditional requests:
@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:
-
Add cache-busting timestamp:
{ "last_updated": "2025-10-20T10:30:00Z", "cache_key": "v1-20251020-103000" } -
Implement version-based invalidation:
@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: Design pattern for revealing information gradually
- Dynamic manifests: Technical implementation for discovering capabilities
Example flow:
- User opens app (Progressive disclosure: show basic UI)
- Client queries server (Dynamic manifest: get available tools)
- Server returns capabilities based on user tier (Progressive disclosure: only relevant features)
- UI updates to show available features (Progressive disclosure: tiered feature set)
Dynamic Manifests → Deferred Loading
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
Navigation: ← Progressive Disclosure | ↑ Best Practices | Deferred Loading →
Last Updated: 2025-10-20