Advanced MCP Patterns
Most MCP tutorials cover the basics: define some tools, start a server, connect Claude Desktop. This tutorial goes further, covering patterns that experienced MCP server developers use to build production-grade, dynamic, and composable AI tool systems.
Prerequisites#
- Strong understanding of MCP servers and MCP clients
- Completed the build MCP server tutorial
- Familiarity with TypeScript async/await and the MCP SDK
- For multi-server patterns: understanding of MCP transport
Pattern 1: Dynamic Tool Registration#
Static tools are registered at server startup and never change. Dynamic tools are registered, modified, or removed based on runtime conditions — which external services are available, what configuration is loaded, what the user's permissions are.
Why Dynamic Tools?#
Consider a server that provides access to multiple APIs. Rather than hard-coding support for GitHub, Jira, and Linear, you want tools to appear only for services the user has configured API keys for. Or you want to expose different tools based on the authenticated user's role.
Implementation: Runtime Tool Updates#
The MCP SDK's low-level Server class gives you full control over tool lists. When tools change, send a tools/listChanged notification:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
class DynamicMcpServer {
private server: Server;
private registeredTools = new Map<string, {
schema: z.ZodObject<any>;
handler: (args: any) => Promise<any>;
}>();
constructor() {
this.server = new Server(
{ name: "dynamic-server", version: "1.0.0" },
{ capabilities: { tools: { listChanged: true } } }
);
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: this.buildToolList(),
}));
this.server.setRequestHandler(CallToolRequestSchema, async (req) => {
const tool = this.registeredTools.get(req.params.name);
if (!tool) {
throw new Error(`Unknown tool: ${req.params.name}`);
}
const args = tool.schema.parse(req.params.arguments);
return await tool.handler(args);
});
}
registerTool(name: string, description: string, schema: z.ZodObject<any>, handler: (args: any) => Promise<any>) {
this.registeredTools.set(name, { schema, handler });
// Notify connected clients that the tool list has changed
this.server.sendToolListChanged();
console.log(`Tool registered: ${name}`);
}
deregisterTool(name: string) {
this.registeredTools.delete(name);
this.server.sendToolListChanged();
console.log(`Tool deregistered: ${name}`);
}
private buildToolList(): ToolSchema[] {
return Array.from(this.registeredTools.entries()).map(([name, { schema }]) => ({
name,
description: `Dynamic tool: ${name}`,
inputSchema: zodToJsonSchema(schema),
}));
}
}
// Usage: register tools based on available integrations
const dynamicServer = new DynamicMcpServer();
async function loadIntegrations(config: Record<string, string>) {
if (config.GITHUB_TOKEN) {
dynamicServer.registerTool(
"list_github_prs",
"List open pull requests from GitHub",
z.object({ repo: z.string(), limit: z.number().default(10) }),
async ({ repo, limit }) => {
const prs = await fetchGitHubPRs(repo, config.GITHUB_TOKEN!, limit);
return { content: [{ type: "text", text: JSON.stringify(prs) }] };
}
);
}
if (config.LINEAR_API_KEY) {
dynamicServer.registerTool(
"list_linear_issues",
"List Linear issues assigned to current user",
z.object({ state: z.enum(["todo", "in_progress", "done"]).default("todo") }),
async ({ state }) => {
const issues = await fetchLinearIssues(config.LINEAR_API_KEY!, state);
return { content: [{ type: "text", text: JSON.stringify(issues) }] };
}
);
}
}
Plugin-Style Dynamic Registration#
For more complex scenarios, implement a plugin system where each plugin registers its own tools:
interface McpPlugin {
name: string;
tools: ToolDefinition[];
initialize(): Promise<void>;
}
class PluginRegistry {
private plugins = new Map<string, McpPlugin>();
private server: DynamicMcpServer;
async loadPlugin(plugin: McpPlugin) {
await plugin.initialize();
for (const toolDef of plugin.tools) {
this.server.registerTool(
`${plugin.name}__${toolDef.name}`,
toolDef.description,
toolDef.schema,
toolDef.handler
);
}
this.plugins.set(plugin.name, plugin);
}
async unloadPlugin(pluginName: string) {
const plugin = this.plugins.get(pluginName);
if (!plugin) return;
for (const toolDef of plugin.tools) {
this.server.deregisterTool(`${pluginName}__${toolDef.name}`);
}
this.plugins.delete(pluginName);
}
}
Pattern 2: Resource Subscriptions#
Resources are read-only data endpoints. Subscriptions allow clients to receive push notifications when resources change — no polling required.
Implementing a Resource with Subscriptions#
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer(
{ name: "realtime-server", version: "1.0.0" },
{
capabilities: {
resources: { subscribe: true, listChanged: true },
},
}
);
// Resource: current order queue
server.resource(
"orders://pending",
"Current pending orders",
async () => {
const orders = await db.query(
"SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 50"
);
return {
contents: [{
uri: "orders://pending",
text: JSON.stringify(orders.rows),
mimeType: "application/json",
}],
};
}
);
// Watch for database changes and notify subscribers
// Using PostgreSQL LISTEN/NOTIFY as an example
const pgClient = await connectToPostgres();
await pgClient.query("LISTEN order_changes");
pgClient.on("notification", (msg) => {
if (msg.channel === "order_changes") {
// Notify all subscribed clients that the resource has changed
server.sendResourceUpdated("orders://pending");
}
});
The client side subscribes and handles updates:
const client = new Client({ name: "my-agent", version: "1.0.0" }, { capabilities: {} });
// Subscribe to the resource
await client.subscribeResource({ uri: "orders://pending" });
// Handle update notifications
client.setNotificationHandler("notifications/resources/updated", async (notification) => {
if (notification.params.uri === "orders://pending") {
// Re-read the updated resource
const resource = await client.readResource({ uri: "orders://pending" });
console.log("Orders updated:", resource.contents[0].text);
// Trigger your agent loop with the new data
}
});
File-Based Resource Subscriptions#
For file system monitoring:
import { watch } from "chokidar";
const watcher = watch("/opt/config", { persistent: true });
watcher.on("change", (filepath) => {
const uri = `config://${path.basename(filepath)}`;
server.sendResourceUpdated(uri);
});
Pattern 3: Sampling Requests (Server-Initiated LLM Calls)#
Sampling is an advanced MCP feature where the server asks the client to run an LLM completion. This is useful when your tool needs AI-generated content as part of its execution.
When to Use Sampling#
- Document summarization tools that want to summarize before returning content
- Data extraction tools that use an LLM to parse unstructured text
- Analysis tools that generate insights from raw data using the same model that called the tool
Sampling Implementation#
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
CreateMessageRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "sampling-server", version: "1.0.0" },
{
capabilities: {
tools: {},
sampling: {},
},
}
);
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
if (req.params.name === "analyze_document") {
const { document_text } = req.params.arguments as { document_text: string };
// Request an LLM completion from the client
const samplingResult = await extra.sendRequest(
CreateMessageRequestSchema,
{
messages: [
{
role: "user",
content: {
type: "text",
text: `Extract the key facts from this document as a JSON array:\n\n${document_text}`,
},
},
],
maxTokens: 1024,
modelPreferences: {
// Prefer faster, cheaper models for this sub-task
speedPriority: 0.8,
costPriority: 0.7,
},
}
);
const extractedFacts = samplingResult.content.text;
return {
content: [{
type: "text",
text: extractedFacts,
}],
};
}
});
Important: Sampling requires explicit client support. Clients must declare the sampling capability in their initialization. Check extra.clientCapabilities?.sampling before attempting a sampling request.
Pattern 4: Multi-Server Orchestration#
Production AI agents typically need more than one MCP server. The two main orchestration patterns are client-level aggregation and server-level federation.
Client-Level Aggregation (Recommended)#
The agent client connects to all servers simultaneously and routes each tool call based on the tool name:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
interface ServerConfig {
name: string;
transport: "stdio" | "sse";
command?: string;
args?: string[];
url?: string;
apiKey?: string;
}
class MultiServerAgent {
private clients = new Map<string, Client>();
private toolToServer = new Map<string, string>(); // tool name → server name
async connectServer(config: ServerConfig) {
const transport = config.transport === "stdio"
? new StdioClientTransport({ command: config.command!, args: config.args })
: new SSEClientTransport(new URL(config.url!), {
requestInit: { headers: { Authorization: `Bearer ${config.apiKey}` } },
});
const client = new Client(
{ name: "multi-agent", version: "1.0.0" },
{ capabilities: { sampling: {} } }
);
await client.connect(transport);
this.clients.set(config.name, client);
// Discover and index all tools from this server
const tools = await client.listTools();
for (const tool of tools.tools) {
// Prefix tool names to avoid collisions across servers
const qualifiedName = `${config.name}__${tool.name}`;
this.toolToServer.set(qualifiedName, config.name);
}
return tools.tools;
}
async callTool(qualifiedToolName: string, args: Record<string, unknown>) {
const [serverName, toolName] = qualifiedToolName.split("__", 2);
const client = this.clients.get(serverName);
if (!client) {
throw new Error(`No client connected for server: ${serverName}`);
}
return await client.callTool({ name: toolName, arguments: args });
}
getAllTools() {
return Array.from(this.toolToServer.keys());
}
async disconnectAll() {
await Promise.all(
Array.from(this.clients.values()).map(client => client.close())
);
}
}
// Usage
const agent = new MultiServerAgent();
await agent.connectServer({
name: "filesystem",
transport: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"],
});
await agent.connectServer({
name: "github",
transport: "sse",
url: "https://github-mcp.example.com/sse",
apiKey: process.env.GITHUB_MCP_KEY,
});
console.log("Available tools:", agent.getAllTools());
// ["filesystem__read_file", "filesystem__write_file", "github__list_prs", ...]
Server-Level Federation (Proxy Pattern)#
For situations where clients can only connect to one server, build a proxy server that internally connects to multiple upstream servers:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
class FederatedMcpServer {
private proxyServer: McpServer;
private upstreamClients = new Map<string, Client>();
constructor() {
this.proxyServer = new McpServer({ name: "federated-gateway", version: "1.0.0" });
}
async addUpstream(name: string, client: Client) {
this.upstreamClients.set(name, client);
// Discover upstream tools and register proxy tools on this server
const tools = await client.listTools();
for (const tool of tools.tools) {
// Capture name and client for the closure
const upstreamToolName = tool.name;
const upstreamClient = client;
this.proxyServer.tool(
`${name}__${upstreamToolName}`,
`[via ${name}] ${tool.description}`,
{}, // Pass-through — we'll validate via the upstream call
async (args) => {
return await upstreamClient.callTool({
name: upstreamToolName,
arguments: args,
});
}
);
}
}
}
Pattern 5: Tool Composition and Chaining#
Build higher-level tools that internally call multiple lower-level tools:
server.tool(
"research_and_summarize",
"Search the web, read top results, and synthesize a summary",
{
query: z.string(),
num_sources: z.number().int().min(1).max(5).default(3),
},
async ({ query, num_sources }) => {
// Step 1: Search
const searchResults = await searchTool(query, num_sources);
// Step 2: Read each result (in parallel)
const contents = await Promise.all(
searchResults.map(url => fetchUrlContent(url))
);
// Step 3: Combine sources into a structured response
const combined = contents
.map((content, i) => `Source ${i + 1}: ${searchResults[i]}\n${content}`)
.join("\n\n---\n\n");
return {
content: [{
type: "text",
text: `Research results for: "${query}"\n\n${combined}`,
}],
};
}
);
Pattern 6: Stateful Server Sessions#
Some tools need to maintain state across multiple calls within a session — for example, a database transaction that spans multiple tool invocations:
// Session state storage (use Redis for multi-instance deployments)
const sessionState = new Map<string, Record<string, unknown>>();
server.tool(
"begin_transaction",
"Begin a database transaction",
{ session_id: z.string() },
async ({ session_id }) => {
const txn = await db.beginTransaction();
sessionState.set(session_id, { transaction: txn, startedAt: Date.now() });
return {
content: [{ type: "text", text: `Transaction started. Session: ${session_id}` }],
};
}
);
server.tool(
"commit_transaction",
"Commit the current database transaction",
{ session_id: z.string() },
async ({ session_id }) => {
const state = sessionState.get(session_id);
if (!state?.transaction) {
throw new Error("No active transaction for this session");
}
await (state.transaction as any).commit();
sessionState.delete(session_id);
return {
content: [{ type: "text", text: "Transaction committed successfully" }],
};
}
);
Next Steps#
- Test all these patterns using the test and debug MCP server tutorial
- Secure your advanced server with controls from the MCP server security tutorial
- Deploy your federated gateway using the deploy remote MCP server tutorial
- Explore how these patterns enable the agentic workflows that MCP is designed to power
Frequently Asked Questions#
How do I handle tool timeouts for long-running operations?
Set explicit timeouts in your tool handlers and return partial results or progress indicators when approaching the limit. For truly long-running operations (minutes, not seconds), consider implementing them as resource subscriptions: start the job, return a job ID, and push notifications to subscribed clients as the job progresses. The tool call returns immediately with the job ID, and the agent polls the resource or receives subscription notifications for updates.
Can I use WebSockets for real-time streaming within a tool response?
MCP tool responses are currently single-value — a tool call returns one result. For streaming output, use resource subscriptions to push incremental updates. There is ongoing work in the MCP specification on streaming tool responses, but it is not in the stable spec as of early 2026. For now, design long-running streaming tools to write to a resource that the client subscribes to.
How does multi-server orchestration interact with context window limits?
When you federate many servers, the AI model's context window can fill up with tool descriptions before you make a single tool call. Address this by: pruning tool lists to include only relevant tools based on the user's current task, implementing tool search (a meta-tool that returns matching tools for a query), or grouping related tools under composite tools with richer descriptions. The goal is to give the model a focused, relevant tool set rather than every available tool from every server.