Lesson 9 of 55: MCP Sampling – Server-Initiated LLM Calls and Recursive AI

Historical note, revised 19 October 2026. Sampling, as taught below, is a server-initiated sampling/createMessage on the open connection. Specification 2026-07-28 deprecated Sampling, and it replaced that reverse request with a multi round-trip: the server returns resultType: "input_required" and the client retries with the model output. New servers should call the model provider directly. See the historical note.

Here is the mind-bending part of MCP: servers can ask the LLM for help. In the standard model, the flow is one-way – host calls LLM, LLM calls tool, tool runs on server, result goes back. Sampling reverses one arrow. It lets a server, while handling a request, ask the host’s LLM to generate text – and then use that generated text in its response. This is recursive AI, and it is what enables genuinely intelligent MCP servers that reason about their own actions.

MCP sampling flow diagram showing server requesting LLM inference from client creating recursive AI loop on dark background
Sampling: the server requests an LLM inference from the client, enabling server-side reasoning loops.

The Sampling Flow

Sampling works as follows: a server handling a tool call decides it needs to “think” before it can respond. It sends a sampling/createMessage request to the client. The client receives this, shows the pending sampling request to the user (or approves it automatically based on policy), then calls the actual LLM API, and returns the result to the server. The server uses the result to complete its work and returns the final tool response to the original caller.

The critical point: the server does not know which LLM the client is using. It just asks for “a language model response” and gets back generated text. This maintains provider-agnosticism even for server-side reasoning.

// Client configuration to enable sampling
const client = new Client(
  { name: 'my-host', version: '1.0.0' },
  {
    capabilities: {
      sampling: {},  // Must declare this to receive sampling requests from servers
    },
  }
);

// Client must handle incoming sampling requests
client.setRequestHandler(CreateMessageRequestSchema, async (request) => {
  const { messages, maxTokens, temperature } = request.params;

  // Here the host calls its actual LLM
  const openai = new OpenAI();
  const response = await openai.chat.completions.create({
    model: 'gpt-6-astra',
    messages: messages.map(m => ({
      role: m.role,
      content: typeof m.content === 'string' ? m.content : m.content.text,
    })),
    max_tokens: maxTokens || 1000,
    temperature: temperature || 0.7,
  });

  return {
    role: 'assistant',
    content: { type: 'text', text: response.choices[0].message.content },
    model: 'gpt-6-astra',
    stopReason: 'endTurn',
  };
});

Why this matters: without capabilities.sampling the server cannot request completions at all, and without a handler every sampling call fails the tool mid-flight. In a real project you would centralise LLM calls here so quotas, logging, and redaction policies stay in one place on the host.

MCP server using sampling to reason about its own tool execution with request loop diagram dark
A server using sampling to analyse data before returning a structured response.

Server-Side Sampling Usage

On the server side, you request sampling through the server’s sampling capability. Here is a server that uses sampling to classify user intent before deciding which database to query:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const server = new McpServer({ name: 'smart-search', version: '1.0.0' });

server.tool(
  'intelligent_search',
  'Searches across databases, routing the query based on intent',
  { query: z.string().describe('The search query') },
  async ({ query }, { server: serverInstance }) => {
    // Use sampling to classify the query intent
    const classification = await serverInstance.createMessage({
      messages: [{
        role: 'user',
        content: {
          type: 'text',
          text: `Classify this search query into one of: products, users, orders, docs.\nQuery: "${query}"\nRespond with only the category name.`,
        },
      }],
      maxTokens: 10,
    });

    const category = classification.content.text.trim().toLowerCase();

    // Route to the appropriate search function
    let results;
    switch (category) {
      case 'products': results = await searchProducts(query); break;
      case 'users': results = await searchUsers(query); break;
      case 'orders': results = await searchOrders(query); break;
      default: results = await searchDocs(query);
    }

    return { content: [{ type: 'text', text: JSON.stringify(results) }] };
  }
);

In a real project you would treat the classification step as a bounded, cheap call (low maxTokens, strict prompt) and keep routing logic easy to unit test. If the model returns an unexpected label, fall back to a safe default path instead of failing the whole tool.

“Sampling allows servers to request LLM completions through the client, enabling sophisticated agentic behaviors while maintaining security through human oversight. The client retains control over which model is used and what requests are permitted.” – MCP Documentation, Sampling

Sampling Parameters

The sampling/createMessage request supports model preferences and sampling parameters. These are preferences, not requirements – the client may choose to ignore them if they conflict with its policy or available models.

const response = await serverInstance.createMessage({
  messages: [{ role: 'user', content: { type: 'text', text: 'Summarise in one sentence.' } }],
  maxTokens: 100,
  temperature: 0.3,           // Lower = more deterministic
  modelPreferences: {
    hints: [{ name: 'claude-haiku-5-5' }], // Preferred model - client may ignore
    costPriority: 0.8,         // 0-1: prefer cheaper models
    speedPriority: 0.9,        // 0-1: prefer faster models
    intelligencePriority: 0.2, // 0-1: prefer smarter models
  },
  systemPrompt: 'You are a concise summariser.',
});

Those preferences are negotiation, not a guarantee: the host may pin a single approved model or ignore cost and speed hints for compliance. Use them to express intent, then document what your client actually honours so server authors know what to expect.

Failure Modes with Sampling

Case 1: Using Sampling for Every Decision

Sampling adds latency and cost. Using it for decisions that can be made with deterministic code (string matching, regex, a simple lookup) is waste. Reserve sampling for genuinely ambiguous situations where LLM understanding adds real value.

// WASTEFUL: Sampling for something a regex handles
const isEmail = await serverInstance.createMessage({
  messages: [{ role: 'user', content: { type: 'text', text: `Is "${input}" an email address? Yes or No.` } }],
  maxTokens: 5,
});

// BETTER: Just use a regex
const isEmail = /^[^@]+@[^@]+\.[^@]+$/.test(input);

Why this matters: every sampling round trip adds latency and billed tokens. In a real project you would profile hot tools and replace LLM branches with deterministic code wherever the spec is stable.

Case 2: Infinite Sampling Loops

If a server uses sampling and the LLM response triggers another tool call that uses sampling again, you can create infinite loops. Always set a maximum recursion depth and terminate if exceeded.

// Guard against recursion depth
async function toolHandler({ query }, context, depth = 0) {
  if (depth > 3) {
    return { isError: true, content: [{ type: 'text', text: 'Max reasoning depth exceeded.' }] };
  }
  const classification = await serverInstance.createMessage({ ... });
  if (needsMoreInfo(classification)) {
    return toolHandler({ query: refineQuery(query) }, context, depth + 1);
  }
  return finalResponse(classification);
}

Tool Calling in Sampling Requests

New in 2025-11-25

Starting with spec version 2025-11-25, servers can include tools and toolChoice parameters in a sampling/createMessage request. This lets the server constrain which tools the LLM may call during the sampling turn. Without this, the LLM during sampling would either have no tools at all or the full tool set – there was no way for the server to scope the tools available during a recursive inference.

// Server: sampling request with constrained tool set
const response = await serverInstance.createMessage({
  messages: [{
    role: 'user',
    content: {
      type: 'text',
      text: 'Look up the current status of order ORD-12345 and summarise it.',
    },
  }],
  maxTokens: 500,
  tools: [
    {
      name: 'get_order_status',
      description: 'Look up the current status of an order by ID',
      inputSchema: {
        type: 'object',
        properties: {
          orderId: { type: 'string', description: 'The order ID' },
        },
        required: ['orderId'],
      },
    },
  ],
  toolChoice: { type: 'auto' },  // 'auto' | 'none' | { type: 'tool', name: '...' }
});

The tools array defines the tool definitions available during this specific sampling turn. The toolChoice parameter controls how the LLM selects tools: "auto" lets the model decide, "none" disables tool use entirely, and { type: 'tool', name: 'get_order_status' } forces a specific tool. This is useful when a server needs the LLM to do a lookup-then-reason task: you provide only the lookup tool, the LLM calls it, gets the data, and writes a summary.

The client is responsible for actually executing the tool calls the LLM makes during sampling. The client returns the final assistant message to the server, including any tool results in the conversation. This keeps the server out of the tool execution loop during its own sampling request – the client manages the entire multi-turn tool-use conversation internally.

What to Check Right Now

  • Declare sampling on your client – if you want servers to be able to use sampling, your client must declare capabilities: { sampling: {} }. Without this, sampling requests from servers will be rejected.
  • Implement a sampling handler – if you build a host application, implement the CreateMessageRequestSchema handler. An unimplemented handler will cause all sampling requests to fail silently.
  • Show sampling requests to users – the spec emphasises human oversight. Production hosts should surface pending sampling requests to users and allow approval/rejection.
  • Cap sampling depth – any server that uses sampling recursively must have a maximum depth limit. Without it, one malformed query can run up unbounded costs.

nJoy 😉

Lesson 3 of 55: JSON-RPC 2.0, MCP Lifecycle, and Capability Negotiation

Historical note, revised 19 October 2026. This lesson was written against the 2025-11-25 handshake: initialize, then notifications/initialized, then the operation phase. Specification 2026-07-28 (published and documented 28 July 2026) removed that handshake. A request now carries protocolVersion and clientCapabilities in _meta, and server/discover replaces the old capability exchange. The sequence below is the historical lifecycle. The record of the break is the historical note on the course front page.

Protocols are not magic. Under every elegant abstraction is a set of bytes moving between processes, governed by rules that someone wrote down. MCP is no different. The moment you understand exactly what happens on the wire when a client connects to an MCP server – the handshake, the capability negotiation, the request-response cycle, the message format – the whole protocol becomes transparent. And transparent systems are debuggable systems.

JSON-RPC 2.0 message flow diagram showing initialize request response and tool calls on dark background
The MCP wire protocol: JSON-RPC 2.0 messages flowing through a stateful connection lifecycle.

JSON-RPC 2.0: The Wire Format

MCP uses JSON-RPC 2.0 as its message format. JSON-RPC is a simple remote procedure call protocol encoded as JSON. Every MCP message is one of four types: a Request, a Response, an Error Response, or a Notification (a one-way message with no response expected).

A JSON-RPC request looks like this:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "greet",
    "arguments": { "name": "Alice" }
  }
}

A successful response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      { "type": "text", "text": "Hello, Alice! Welcome to MCP." }
    ]
  }
}

An error response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "Invalid params",
    "data": { "detail": "Missing required argument: name" }
  }
}

A notification (no id, no response expected):

{
  "jsonrpc": "2.0",
  "method": "notifications/tools/list_changed"
}

The id field is how you match responses to requests in an async system. If you send ten requests and get ten responses back in any order, the id tells you which response belongs to which request. Notifications don’t have IDs because they are fire-and-forget.

This matters the moment you open a network trace or debug log. Every MCP problem you will ever diagnose comes down to reading these four message shapes and finding the mismatch – a missing id, an unexpected error code, or a notification that never arrived. Fluency in the wire format is the single most useful debugging skill in the protocol.

“The base protocol uses JSON-RPC 2.0 messages exchanged over a transport layer. Server and client capabilities are negotiated during an initialization phase.” – MCP Specification, Base Protocol

With the message format understood, the next question is ordering: when do these messages get sent, and in what sequence? The connection lifecycle defines exactly that – a strict three-phase flow that every MCP session follows from first contact to shutdown.

The Connection Lifecycle

Every MCP connection goes through a well-defined lifecycle. Understanding this lifecycle is essential for debugging connection problems and for building robust hosts and servers.

The lifecycle has three phases: initialisation, operation, and shutdown.

Phase 1: Initialisation

When a client connects to a server, the very first thing that happens is the initialisation handshake. This is not optional and not configurable – it is the protocol’s way of ensuring both sides agree on what they can do together before anything else happens.

The sequence:

  1. Client sends an initialize request, declaring its protocol version and capabilities.
  2. Server responds with its protocol version and capabilities.
  3. Client sends an initialized notification to confirm it received the response.
  4. Both sides are now in the operating phase and can exchange any supported messages.
// Step 1: Client sends initialize request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "clientInfo": { "name": "my-host", "version": "1.0.0" },
    "capabilities": {
      "sampling": {}
    }
  }
}

// Step 2: Server responds
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "serverInfo": { "name": "my-server", "version": "1.0.0", "description": "Product catalog and inventory management" },
    "capabilities": {
      "tools": {},
      "resources": { "subscribe": true },
      "logging": {}
    }
  }
}

// Step 3: Client confirms with a notification
{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}
MCP connection lifecycle sequence diagram - initialize operate shutdown phases on dark background
The three-phase connection lifecycle: initialise, operate, shutdown. Everything else happens in the middle phase.

Phase 2: Operation

After the handshake, the connection is fully operational. Either side can send requests, responses, or notifications, subject to the capabilities they negotiated. The client can call tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get. The server can send sampling/createMessage requests (if the client declared sampling capability) or notifications about state changes.

Phase 3: Shutdown

Either side can close the connection. With stdio transport, this happens when the process exits. With HTTP/SSE transport, it happens when the connection is closed. The MCP SDK handles shutdown gracefully when you call server.close() or client.close().

The lifecycle ensures orderly communication, but the handshake also decides what each side is allowed to do. The next section unpacks capability negotiation – the mechanism that controls which features are available during the operation phase.

Capability Negotiation in Detail

Capability negotiation determines what each side is allowed to do in the operation phase. If the client does not declare sampling capability, the server cannot send sampling/createMessage requests – the client simply will not handle them. If the server does not declare resources capability, the client calling resources/list will receive a method-not-found error.

This is a safety mechanism. It prevents servers from sending requests that clients cannot handle, and prevents clients from calling methods the server has not implemented. You can inspect negotiated capabilities after connecting:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const client = new Client(
  { name: 'inspector', version: '1.0.0' },
  { capabilities: { sampling: {} } }
);

const transport = new StdioClientTransport({
  command: 'node',
  args: ['./my-server.js'],
});

await client.connect(transport);

// Read back what the server declared it supports
const serverCaps = client.getServerCapabilities();
console.log('Server capabilities:', JSON.stringify(serverCaps, null, 2));

// Read back what the server reported about itself
const serverInfo = client.getServerVersion();
console.log('Server info:', serverInfo);

In a production system, you would check capabilities immediately after connecting to decide what features to offer the user. If the server does not declare resources support, your host should grey out or hide the resource browser rather than letting the user trigger a method-not-found error.

New in 2025-11-25 – The Implementation interface (used for both serverInfo and clientInfo) now includes an optional description field. This provides a human-readable summary of what the server or client does, aligning with MCP registry and discovery formats. Include it when your server might appear in a registry or service mesh where operators need to identify it at a glance.

Common Protocol Failures

Case 1: Sending Requests Before Initialisation Completes

If you attempt to call a tool or list resources before the initialized notification has been exchanged, the server will reject the request with a protocol error. The MCP SDK handles this transparently when you use client.connect(transport) – the connect method waits for the full handshake before resolving. But if you implement a custom transport or bypass the SDK, this is the first thing that will bite you.

// WRONG: Calling tools before connect resolves
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });
client.connect(transport); // Don't await this
const tools = await client.listTools(); // Races with initialisation - will fail

// CORRECT: Always await connect
await client.connect(transport);
const tools = await client.listTools(); // Safe: handshake is complete

Case 2: Mismatched Protocol Versions

MCP has versioned specifications. If client and server declare different protocol versions and neither can support the other, the connection fails at initialisation. The current stable version is 2024-11-05; the draft spec has incremental additions. When upgrading SDK versions, check the protocol version the new SDK defaults to and ensure your deployed servers are compatible.

// Always pin to a specific protocol version in production:
const client = new Client(
  { name: 'my-host', version: '1.0.0' },
  {
    capabilities: {},
    // The SDK picks the protocol version based on what server supports
    // Check SDK changelog when upgrading
  }
);

Case 3: Understanding Notification Semantics

Notifications do not have an id field and do not receive a response from the other side. However, sending a notification is still an I/O operation – bytes must be written to the transport. In the MCP SDK, Protocol.notification() is async and returns a Promise that resolves once the message has been flushed to the transport. Awaiting it is correct and recommended – it ensures the write completes before you proceed. Skipping await risks a race condition where the next message is sent before the notification has been flushed.

// CORRECT: await ensures the notification bytes are flushed to the transport
await client.sendNotification({ method: 'notifications/initialized' });

// ALSO CORRECT but risky in fast sequences: the write might not finish
// before your next message if the transport buffers
client.sendNotification({ method: 'notifications/initialized' });

The key distinction: awaiting a notification does not wait for a response from the server (there is none). It waits for the local transport to finish writing. This is the same as await stream.write() in Node.js – you are awaiting the I/O, not a reply.

“Servers MUST NOT send requests to clients before receiving the initialized notification from the client. Clients MUST NOT send requests other than pings before receiving the initialize response from the server.” – MCP Specification, Lifecycle

Each of these failure modes is easy to hit during development and hard to diagnose without seeing the raw messages. The common thread is timing and ordering – sending things too early, expecting responses to notifications, or assuming capabilities that were never negotiated. When something breaks, the JSON-RPC trace is always the first place to look.

What to Check Right Now

  • Inspect live traffic with the MCP Inspector – run npx @modelcontextprotocol/inspector node your-server.js. The inspector shows every JSON-RPC message, making the protocol visible in real time.
  • Enable SDK logging – set DEBUG=mcp:* in your environment when developing. The SDK logs all protocol messages, which is invaluable for debugging lifecycle issues.
  • Validate your capability declarations – if a server feature is not working, check that the server declared the capability and the client did not need to declare a matching client capability first.
  • Use JSON-RPC error codes correctly – MCP defines standard error codes (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error). Application-level errors use codes in the range -32000 to -32099.

nJoy 😉

Lesson 14 of 55: Streamable HTTP and SSE – Building Remote MCP Servers

Historical note, revised 19 October 2026. This lesson describes Streamable HTTP as it stood in 2025-11-25: a session id header, a long-lived GET stream, and Last-Event-ID so a dropped SSE stream could resume. Specification 2026-07-28 removed protocol-level sessions and that resume mechanism. A POST carries Mcp-Method and Mcp-Name, and any instance may handle it. If the response stream breaks, the client sends a new request with a new id. See the historical note.

stdio works beautifully for local tools, but the moment you want to share an MCP server across a network – serving multiple clients, deploying to a container, integrating with a third-party host over the internet – you need HTTP transport. The MCP specification defines Streamable HTTP as the canonical remote transport: HTTP POST for client-to-server requests, Server-Sent Events (SSE) for server-to-client streaming. This lesson covers the protocol mechanics, the implementation pattern, and the hard-won lessons about making streaming work reliably in production.

Streamable HTTP transport diagram showing HTTP POST requests and SSE stream responses between client and server dark
Streamable HTTP: POST for requests, SSE for streaming responses – the MCP remote transport standard.

The Streamable HTTP Protocol

The Streamable HTTP transport uses a single HTTP endpoint (typically /mcp) and the following request flow:

  • Client to server: HTTP POST with Content-Type: application/json containing the JSON-RPC message(s). The client must include a session ID header (mcp-session-id) after the connection is established.
  • Server to client (immediate response): HTTP 200 with Content-Type: application/json containing the JSON-RPC response. For a single request-response pair with no streaming.
  • Server to client (streaming): HTTP 200 with Content-Type: text/event-stream (SSE). The server keeps the connection open and pushes events as they arrive. This is used for long-running tools, progress notifications, and sampling requests.
  • Server to client (async): HTTP GET to the MCP endpoint opens an SSE stream for the server to push unsolicited notifications (resource updates, tool list changes, etc.).
// Client side: use the Streamable HTTP transport
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const client = new Client(
  { name: 'my-http-host', version: '1.0.0' },
  { capabilities: {} }
);

const transport = new StreamableHTTPClientTransport(
  new URL('https://my-mcp-server.example.com/mcp')
);

await client.connect(transport);

const tools = await client.listTools();
console.log('Available tools:', tools.tools.map(t => t.name));

Unlike stdio, this transport lets your MCP server exist independently of any single host process. The server can be deployed as a standalone service, shared across a team, or exposed to third-party clients over the internet. That independence is what makes HTTP the right choice for any non-local use case.

“The HTTP with SSE transport uses Server-Sent Events for server-to-client streaming while using HTTP POST for client-to-server communication. This allows servers to stream results and send notifications to clients.” – MCP Documentation, Transports

Building a Streamable HTTP Server

The MCP SDK provides a StreamableHTTPServerTransport that handles all the protocol mechanics. You attach it to any HTTP server framework – Express, Hono, Fastify, or Node’s built-in http module.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import express from 'express';

const app = express();
app.use(express.json());

const server = new McpServer({ name: 'remote-server', version: '1.0.0' });

server.tool(
  'get_weather',
  'Gets current weather for a city',
  { city: z.string().describe('City name') },
  async ({ city }) => {
    const data = await fetchWeather(city);
    return {
      content: [{ type: 'text', text: `${city}: ${data.temp}°C, ${data.condition}` }],
    };
  }
);

// Session management: one transport per client session
const sessions = new Map();

app.post('/mcp', async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];

  let transport;
  if (sessionId && sessions.has(sessionId)) {
    transport = sessions.get(sessionId);
  } else {
    // New session
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => crypto.randomUUID(),
      onsessioninitialized: (id) => sessions.set(id, transport),
    });
    await server.connect(transport);
  }

  await transport.handleRequest(req, res);
});

app.get('/mcp', async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];
  const transport = sessions.get(sessionId);
  if (!transport) return res.status(404).send('Session not found');
  await transport.handleRequest(req, res);
});

app.delete('/mcp', async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];
  sessions.delete(sessionId);
  res.status(200).send('Session terminated');
});

app.listen(3000, () => console.error('MCP HTTP server running on :3000'));
MCP HTTP session management diagram showing session ID header session map multiple clients connecting dark
HTTP session management: each client gets a session ID, mapped to its own transport instance.

The server setup above handles basic request-response patterns. But the real power of HTTP transport is streaming: the ability to push progress updates, intermediate results, and server-initiated notifications to the client while a long-running operation is still in flight.

SSE Streaming in Practice

When a tool produces results progressively (e.g. a long-running data processing job), the server can stream intermediate progress via SSE notifications before sending the final result:

server.tool(
  'process_large_dataset',
  'Processes a large dataset with progress streaming',
  { dataset_id: z.string(), chunk_size: z.number().default(1000) },
  async ({ dataset_id, chunk_size }, { server: serverInstance }) => {
    const dataset = await loadDataset(dataset_id);
    const totalRows = dataset.length;
    let processed = 0;

    for (let i = 0; i < dataset.length; i += chunk_size) {
      const chunk = dataset.slice(i, i + chunk_size);
      await processChunk(chunk);
      processed += chunk.length;

      // Stream progress via SSE notification
      serverInstance.server.notification({
        method: 'notifications/progress',
        params: {
          progressToken: dataset_id,
          progress: processed,
          total: totalRows,
        },
      });
    }

    return {
      content: [{
        type: 'text',
        text: `Processed ${processed} rows from dataset ${dataset_id}`,
      }],
    };
  }
);

In a production system, you would combine progress streaming with timeout handling and cancellation support. If a client disconnects mid-stream, the server should detect the closed connection and abort the in-progress work to avoid wasting resources on results nobody will receive.

Failure Modes with Streamable HTTP

Case 1: No Session Management - One Transport for All Clients

Creating a single global transport instance and sharing it across all HTTP requests corrupts all sessions. Each client connection needs its own transport instance.

// WRONG: Single global transport - all sessions corrupt each other
const globalTransport = new StreamableHTTPServerTransport({ ... });
await server.connect(globalTransport);

app.post('/mcp', async (req, res) => {
  await globalTransport.handleRequest(req, res); // All clients share state - WRONG
});

// CORRECT: Per-session transport instances (as shown above)

This is one of the most common bugs when building HTTP-based MCP servers. The corruption is subtle: two clients may receive each other's responses, or a notification intended for one session leaks into another. It often only surfaces under concurrent load, making it hard to reproduce locally.

Case 2: SSE Connection Not Kept Alive

SSE connections must be kept open by the server for the duration of the session. Intermediate proxies (nginx, load balancers, CDNs) may buffer responses or close idle connections. Set appropriate headers and configure proxy timeouts.

// When using Express with SSE, set headers to prevent buffering
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no'); // Nginx: disable buffering
res.setHeader('Connection', 'keep-alive');

// For nginx: proxy_read_timeout 3600s; proxy_buffering off;

Together, these two failure modes highlight the fundamental challenge of HTTP transport: you are responsible for connection lifecycle management that stdio handles automatically. Session isolation and keep-alive behavior are things you must actively get right, not things that work by default.

SSE Polling and Server-Initiated Disconnection

New in 2025-11-25

Earlier versions of Streamable HTTP assumed the server would keep SSE connections open indefinitely. The 2025-11-25 spec clarifies that servers may disconnect SSE streams at will, enabling a polling model for environments where long-lived connections are impractical (load balancers with short timeouts, serverless functions, horizontally scaled clusters).

The key change: clients can always resume a stream by sending a GET request to the MCP endpoint, regardless of whether the original stream was created by a POST or a GET. The server includes event IDs that encode stream identity, allowing the client to reconnect and pick up where it left off.

// Client: reconnect after server disconnects the SSE stream
async function connectWithReconnect(mcpUrl, lastEventId = null) {
  const headers = { Accept: 'text/event-stream' };
  if (lastEventId) {
    headers['Last-Event-ID'] = lastEventId;  // Resume from where we left off
  }

  const response = await fetch(mcpUrl, { method: 'GET', headers });
  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  let currentEventId = lastEventId;

  while (true) {
    const { done, value } = await reader.read();
    if (done) {
      // Server closed the stream - reconnect after a brief delay
      console.log('Stream ended, reconnecting...');
      await new Promise(r => setTimeout(r, 1000));
      return connectWithReconnect(mcpUrl, currentEventId);
    }

    const text = decoder.decode(value);
    // Parse SSE events, track event IDs for resumption
    for (const line of text.split('\n')) {
      if (line.startsWith('id:')) currentEventId = line.slice(3).trim();
      if (line.startsWith('data:')) handleMessage(JSON.parse(line.slice(5)));
    }
  }
}

Event IDs should encode stream identity so that the server can distinguish reconnection attempts from new connections. The spec does not prescribe a format, but a common pattern is to include both a session identifier and a sequence number in the event ID.

Origin Validation

Clarified in 2025-11-25

Servers MUST respond with HTTP 403 Forbidden for requests with invalid Origin headers in the Streamable HTTP transport. This prevents cross-origin attacks where a malicious web page attempts to connect to a local MCP server running on localhost. Always validate the Origin header against an allowlist before processing any request.

What to Check Right Now

  • Test with curl - send a raw HTTP POST to your server: curl -X POST http://localhost:3000/mcp -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"initialize",...}'
  • Verify SSE with the browser - open your /mcp GET endpoint in a browser with DevTools open. The Network tab should show the SSE stream with events appearing in real time.
  • Configure nginx for SSE - in any production deployment, add proxy_buffering off and proxy_read_timeout 3600s to your nginx location block for the MCP endpoint.
  • Implement session cleanup - sessions that are never explicitly terminated will accumulate. Add a TTL or a periodic cleanup job to the sessions Map.

nJoy 😉

Lesson 42 of 55: Scaling MCP – Load Balancing, Rate Limiting, and Caching

Historical note, revised 19 October 2026. The scaling design below assumes MCP sessions are sticky: pin the client, or keep session state in Redis, because Mcp-Session-Id tied the next call to the first process. Specification 2026-07-28 removed that header and the session. Cross-call state, if you still need it, is an explicit handle the server mints and the client passes back as an ordinary argument. Rate limits and cache still apply. Session affinity does not. See the historical note.

A single MCP server instance handling one user works fine. The same server handling 500 concurrent users during peak hours is a different problem entirely. This lesson covers the four levers for scaling MCP infrastructure: horizontal scaling with session affinity, rate limiting that protects both your server and upstream LLM APIs, response caching for expensive tool calls, and load balancing configurations that handle MCP’s stateful session requirements correctly.

MCP scaling architecture diagram load balancer multiple server instances Redis session cache rate limiter dark
Horizontal MCP scaling requires: session affinity, shared session store, and a rate limiter at the gateway.

Horizontal Scaling with Shared Session State

MCP Streamable HTTP sessions are stateful. If a client’s POST goes to server A but the next SSE connection goes to server B, the session state is lost. Two solutions:

Option 1: Sticky sessions (simpler) – Configure your load balancer to route all requests from the same client to the same server instance. Works but creates uneven load distribution.

Option 2: Shared session store (recommended) – Store session state in Redis and allow any server instance to handle any request.

import { createClient } from 'redis';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamable-http.js';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

// In-memory session store backed by Redis
const sessions = new Map();  // Local cache for active requests

app.post('/mcp', async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];

  // Try local cache first, then Redis
  let transport = sessions.get(sessionId);
  if (!transport && sessionId) {
    const stored = await redis.get(`mcp:session:${sessionId}`);
    if (stored) {
      // Restore session - in practice, transport state is complex to serialize
      // For true multi-instance support, use sticky sessions at the LB level
      console.error(`Session ${sessionId} not found locally - sticky sessions recommended`);
    }
  }

  if (!transport) {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => crypto.randomUUID(),
      onsessioninitialized: async (sid) => {
        sessions.set(sid, transport);
        // Mark session as active in Redis (for health tracking)
        await redis.setEx(`mcp:session:${sid}:active`, 3600, '1');
      },
    });
    const server = buildMcpServer();
    await server.connect(transport);
  }

  await transport.handleRequest(req, res, req.body);
});

In practice, shared session state is the difference between a prototype and a production system. Without it, a load balancer restart or a single instance crash silently kills every session pinned to that node, and your users see cryptic “session not found” errors with no recovery path.

Rate Limiting at the Gateway

Scaling the server only to leave it unprotected is a recipe for outages. Rate limiting sits at the gateway layer and prevents a single misbehaving client, or a sudden LLM retry loop, from consuming all available capacity. The following setup uses Redis so the limits are enforced consistently across all server instances.

import { RateLimiterRedis } from 'rate-limiter-flexible';

// Per-user rate limit: 60 requests per minute
const rateLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'mcp-rl',
  points: 60,        // Number of requests
  duration: 60,      // Per 60 seconds
  blockDuration: 60, // Block for 60 seconds after limit hit
});

// Per-IP rate limit for unauthenticated paths
const ipRateLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'mcp-ip-rl',
  points: 100,
  duration: 60,
});

async function rateLimit(req, res, next) {
  const key = req.auth?.sub ?? req.ip;
  try {
    await rateLimiter.consume(key);
    next();
  } catch (rl) {
    res.setHeader('Retry-After', Math.ceil(rl.msBeforeNext / 1000));
    res.setHeader('X-RateLimit-Limit', 60);
    res.setHeader('X-RateLimit-Remaining', 0);
    res.status(429).json({ error: 'too_many_requests', retryAfter: Math.ceil(rl.msBeforeNext / 1000) });
  }
}

app.use('/mcp', rateLimit);
Rate limiting Redis sliding window per user request counter 429 response with Retry-After header dark diagram
Redis-backed sliding window rate limiter: 60 req/min per user, returns 429 with Retry-After on breach.

Tool Result Caching

Many MCP tool calls hit the same data repeatedly. An LLM agent researching a product might call get_product three times in one conversation with identical arguments. Caching those results in Redis avoids redundant database queries and cuts latency for the most common patterns. The key decision is choosing TTLs that match how frequently the underlying data actually changes.

// Cache expensive or read-heavy tool call results in Redis
class ToolResultCache {
  #redis;
  #ttls;

  constructor(redis, ttls = {}) {
    this.#redis = redis;
    this.#ttls = {
      // Default TTLs per tool (seconds)
      get_product: 300,      // 5 min - product data changes rarely
      search_products: 60,   // 1 min - search results change more
      get_inventory: 10,     // 10 sec - inventory changes frequently
      get_user: 600,         // 10 min - user profile rarely changes
      ...ttls,
    };
  }

  key(toolName, args) {
    return `mcp:tool:${toolName}:${JSON.stringify(args)}`;
  }

  async get(toolName, args) {
    const cached = await this.#redis.get(this.key(toolName, args));
    return cached ? JSON.parse(cached) : null;
  }

  async set(toolName, args, result) {
    const ttl = this.#ttls[toolName];
    if (!ttl) return;  // Don't cache if no TTL defined
    await this.#redis.setEx(this.key(toolName, args), ttl, JSON.stringify(result));
  }

  async invalidate(pattern) {
    const keys = await this.#redis.keys(`mcp:tool:${pattern}:*`);
    if (keys.length) await this.#redis.del(keys);
  }
}

const toolCache = new ToolResultCache(redis);

// Wrap MCP callTool with caching
async function callToolWithCache(mcp, name, args) {
  const cached = await toolCache.get(name, args);
  if (cached) {
    return cached;
  }
  const result = await mcp.callTool({ name, arguments: args });
  await toolCache.set(name, args, result);
  return result;
}

Nginx Load Balancer Config with Sticky Sessions

With application-level concerns handled, the last piece is the load balancer itself. MCP’s SSE streaming requires long-lived connections, which means default Nginx timeouts and buffering settings will break things immediately. Watch out for proxy_buffering in particular: if left on, Nginx will hold SSE events in memory instead of forwarding them, and the client will appear to hang.

upstream mcp_servers {
    ip_hash;  # Sticky sessions by client IP
    server mcp-server-1:3000;
    server mcp-server-2:3000;
    server mcp-server-3:3000;
    keepalive 64;
}

server {
    listen 443 ssl;
    server_name mcp.yourcompany.com;

    # SSE requires long-lived connections - increase timeouts
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_connect_timeout 10s;

    # Required for SSE streaming
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;

    location /mcp {
        proxy_pass http://mcp_servers;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location /health {
        proxy_pass http://mcp_servers;
        access_log off;
    }
}

A common real-world failure is caching a tool result that was specific to one user’s permissions and then serving it to another user. Always include the user or session identity in your cache key when the tool’s output depends on who is calling it. The key() method above uses only tool name and arguments, which is correct for public data but dangerous for anything access-controlled.

Scaling Decision Guide

  • Under 10 concurrent users: Single instance, no load balancer needed
  • 10-100 concurrent users: 2-3 instances with sticky sessions, Redis for rate limiting
  • 100-1000 concurrent users: 5-10 instances, Redis session store, tool result caching, dedicated rate limiting layer
  • 1000+ concurrent users: Kubernetes with horizontal pod autoscaling, Redis Cluster, API Gateway (Kong, APISIX) for rate limiting and auth

nJoy 😉

Lesson 44 of 55: MCP Observability – Logs, Prometheus Metrics, OpenTelemetry Traces

Historical note, revised 19 October 2026. The logging in this lesson is application logging: structured lines, metrics, traces. That stays. The protocol Logging feature (logging/setLevel, notifications/message as a standing channel) was deprecated on 28 July 2026, and logging/setLevel was removed. A log level is now per request, in _meta as io.modelcontextprotocol/logLevel. A server must not emit notifications/message for a request that did not ask. Prefer stderr on stdio, and OpenTelemetry on HTTP. See the historical note.

You cannot fix what you cannot measure. MCP applications introduce new failure surfaces: tool latency, LLM token costs per request, session counts, tool call error rates, and the latency contribution of each component in a multi-step agent run. This lesson builds the observability stack for an MCP server: structured logging with correlation IDs, Prometheus metrics, and OpenTelemetry distributed tracing that shows the full span from user request to final LLM response.

MCP observability stack diagram showing logs metrics traces flowing to dashboards Prometheus Grafana OpenTelemetry dark
Three pillars of MCP observability: structured logs, Prometheus metrics, and OpenTelemetry traces.

Structured Logging with Correlation IDs

// Every log line includes a correlation ID that spans the full request lifecycle
import crypto from 'node:crypto';

class Logger {
  #context;

  constructor(context = {}) {
    this.#context = context;
  }

  child(context) {
    return new Logger({ ...this.#context, ...context });
  }

  #log(level, message, extra = {}) {
    process.stdout.write(JSON.stringify({
      timestamp: new Date().toISOString(),
      level,
      message,
      ...this.#context,
      ...extra,
    }) + '\n');
  }

  info(msg, extra) { this.#log('info', msg, extra); }
  warn(msg, extra) { this.#log('warn', msg, extra); }
  error(msg, extra) { this.#log('error', msg, extra); }
}

const rootLogger = new Logger({ service: 'mcp-server', version: '1.0.0' });

// Per-request logger with correlation ID
app.use((req, res, next) => {
  const requestId = req.headers['x-request-id'] ?? crypto.randomUUID();
  req.log = rootLogger.child({ requestId, path: req.path, method: req.method });
  res.setHeader('x-request-id', requestId);
  req.log.info('Request received');
  next();
});

Plain console logs are hard to grep at volume and impossible to stitch across services. JSON lines with a stable requestId let you jump from one error line to the full story in log aggregators and prove which user or agent session triggered a spike in tool errors.

Prometheus Metrics

npm install prom-client
import { Registry, Counter, Histogram, Gauge } from 'prom-client';

const registry = new Registry();

// Tool call metrics
const toolCallTotal = new Counter({
  name: 'mcp_tool_calls_total',
  help: 'Total number of MCP tool calls',
  labelNames: ['tool_name', 'status'],
  registers: [registry],
});

const toolCallDuration = new Histogram({
  name: 'mcp_tool_call_duration_seconds',
  help: 'Duration of MCP tool calls in seconds',
  labelNames: ['tool_name'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30],
  registers: [registry],
});

const activeSessions = new Gauge({
  name: 'mcp_active_sessions',
  help: 'Number of active MCP sessions',
  registers: [registry],
});

const llmTokensTotal = new Counter({
  name: 'mcp_llm_tokens_total',
  help: 'Total LLM tokens consumed',
  labelNames: ['provider', 'model', 'type'],
  registers: [registry],
});

// Expose /metrics endpoint for Prometheus scraping
app.get('/metrics', async (req, res) => {
  res.setHeader('Content-Type', registry.contentType);
  res.end(await registry.metrics());
});

// Instrument tool calls
function instrumentedToolCall(name, handler) {
  return async (args, context) => {
    const end = toolCallDuration.startTimer({ tool_name: name });
    try {
      const result = await handler(args, context);
      const status = result?.isError ? 'error' : 'success';
      toolCallTotal.inc({ tool_name: name, status });
      return result;
    } catch (err) {
      toolCallTotal.inc({ tool_name: name, status: 'exception' });
      throw err;
    } finally {
      end();
    }
  };
}

Counters and histograms turn noisy tool traffic into SLO-friendly views: which tools fail, how slow p95 is, and whether sessions or token burn are climbing. In a real project, you would wrap each registered tool with instrumentedToolCall (or equivalent) so every path records latency and outcome without hand-maintained log parsing.

Prometheus metrics dashboard for MCP server showing tool call rate duration active sessions token costs dark
Key MCP metrics to track: tool call rate per tool, p95 latency, active sessions, and token costs per provider.

OpenTelemetry Distributed Tracing

npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node \
            @opentelemetry/exporter-trace-otlp-http
// tracing.js - Load this BEFORE any other imports
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
  serviceName: 'mcp-server',
  serviceVersion: process.env.npm_package_version,
});

sdk.start();
process.on('SIGTERM', () => sdk.shutdown());

Auto-instrumentation captures HTTP and many libraries with little code, but the SDK must start before other modules load or spans will be incomplete. Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (Grafana Agent, Datadog Agent, vendor OTLP ingress) so traces leave the pod with the same labels you use for metrics.

// Add custom spans for MCP tool calls
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('mcp-server');

function tracedToolCall(name, handler) {
  return async (args, context) => {
    return tracer.startActiveSpan(`mcp.tool.${name}`, async (span) => {
      span.setAttributes({
        'mcp.tool.name': name,
        'mcp.session.id': context.sessionId ?? 'unknown',
        'mcp.arg.keys': JSON.stringify(Object.keys(args)),
      });

      try {
        const result = await handler(args, context);
        span.setStatus({ code: result?.isError ? SpanStatusCode.ERROR : SpanStatusCode.OK });
        return result;
      } catch (err) {
        span.recordException(err);
        span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
        throw err;
      } finally {
        span.end();
      }
    });
  };
}

Custom spans make the MCP layer visible inside a trace: you see tool name, session, and failure mode next to downstream HTTP or DB spans. That closes the loop when metrics show latency but logs do not say which hop grew; the following queries turn the same series into boards and on-call dashboards.

Grafana Dashboard Queries

# Top 5 slowest tools (p95 latency)
histogram_quantile(0.95, sum(rate(mcp_tool_call_duration_seconds_bucket[5m])) by (le, tool_name))

# Tool error rate
sum(rate(mcp_tool_calls_total{status="error"}[5m])) by (tool_name)
/
sum(rate(mcp_tool_calls_total[5m])) by (tool_name)

# Token cost per hour (estimated)
sum(increase(mcp_llm_tokens_total{type="input"}[1h])) by (model) * 0.0000025

# Active sessions over time
mcp_active_sessions

OpenTelemetry Trace Context in MCP

New in Draft – This convention is documented in the Draft spec and may be finalised in a future revision.

The Draft specification documents a standard convention for propagating OpenTelemetry trace context through MCP messages. Any request or notification can include traceparent, tracestate, and baggage keys in the _meta field, following the W3C Trace Context format. This lets traces flow seamlessly from an MCP client through the server and into downstream services (databases, APIs, queues) without custom propagation code.

// Client: include OTel trace context in an MCP request
const response = await client.request({
  method: 'tools/call',
  params: {
    name: 'query_database',
    arguments: { sql: 'SELECT * FROM orders LIMIT 10' },
    _meta: {
      traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
      tracestate: 'vendor=mycompany',
      baggage: 'userId=alice,tenant=acme',
    },
  },
});

// Server: extract trace context from _meta and create a child span
function handleToolCall(params) {
  const { traceparent, tracestate, baggage } = params._meta ?? {};

  // Use the W3C propagator to create a context from the incoming headers
  const parentContext = propagation.extract(ROOT_CONTEXT, {
    traceparent,
    tracestate,
    baggage,
  });

  return tracer.startActiveSpan('mcp.tools/call', { attributes: {
    'mcp.tool.name': params.name,
  }}, parentContext, async (span) => {
    try {
      const result = await executeToolHandler(params);
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } finally {
      span.end();
    }
  });
}

With this convention, a single trace can show the full journey: user action in the host, MCP client request, server tool execution, database query, and back. This is essential for debugging latency in production MCP deployments where the bottleneck could be anywhere in the chain.

Key Alerts to Configure

  • Tool error rate > 5% for 5 minutes: A specific tool may be failing due to a backend outage or schema change
  • p95 tool latency > 10 seconds for 5 minutes: A tool is consistently slow – investigate the backend
  • Active sessions > 1000: Approaching capacity – scale up or investigate for session leaks
  • LLM token rate > 2x baseline: Possible runaway agent loop – investigate with trace data

nJoy 😉

Lesson 11 of 55: MCP Roots – Filesystem and URI Boundaries for Safe Agents

Historical note, revised 19 October 2026. Roots, as taught below, are a client capability the server fetches with roots/list, plus a notifications/roots/list_changed message when the workspace changes. Specification 2026-07-28 deprecated Roots and removed that notification. During the deprecation window the feature still works, via a multi round-trip rather than a reverse request. New servers should take paths as tool arguments or resource URIs. See the historical note.

A server that can read any file anywhere on the filesystem is a security disaster waiting to happen. Roots are MCP’s answer to the containment problem: a mechanism for clients to tell servers exactly which filesystem paths and URIs they are permitted to access. It is not just a security feature – it is also a scoping feature. Roots let the host say “this AI assistant is allowed to work with the files in this project directory”, giving the server a clear operational boundary without restricting it to a fixed list of resources.

MCP roots diagram showing client sending root URIs to server defining allowed filesystem boundaries dark
Roots: the host tells the server which paths it is allowed to work with, enforcing operational boundaries.

What Roots Are

A root is a URI that defines a boundary of the client’s environment that the server may access. A root most commonly represents a directory on the filesystem (file:///Users/alice/my-project), but it can also be any URI scheme meaningful to the server (https://api.mycompany.com/v1, git://my-org/my-repo). The server should limit its operations to within the URIs provided as roots.

Roots flow from client to server: the client announces its roots when the server requests them via the roots/list method. The client can also notify the server when roots change via the roots/list_changed notification.

// Client: declare roots capability
const client = new Client(
  { name: 'my-ide', version: '1.0.0' },
  {
    capabilities: {
      roots: {
        listChanged: true,  // Client will notify when roots change
      },
    },
  }
);

// Client: respond to roots/list requests from the server
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';

client.setRequestHandler(ListRootsRequestSchema, async () => ({
  roots: [
    {
      uri: 'file:///Users/alice/my-project',
      name: 'My Project',
    },
    {
      uri: 'file:///Users/alice/shared-libs',
      name: 'Shared Libraries',
    },
  ],
}));

// Notify server when workspace changes (e.g. user opens a different project)
await client.sendNotification({
  method: 'notifications/roots/list_changed',
});
MCP roots boundary diagram showing project directory as allowed zone and system files as restricted zone dark
Roots create operational zones: the server is guided to stay within declared URIs and avoid everything else.

Server-Side Roots Usage

On the server side, you request the current roots at startup or whenever you need to know the operational scope. Use roots to validate that requested resource URIs fall within allowed boundaries before accessing them.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

const server = new McpServer({ name: 'fs-server', version: '1.0.0' });

// Helper: check if a path is within any declared root
async function isWithinRoots(targetPath, serverInstance) {
  const { roots } = await serverInstance.listRoots();
  const fileRoots = roots
    .filter(r => r.uri.startsWith('file://'))
    .map(r => fileURLToPath(r.uri));

  const normalised = path.resolve(targetPath);
  return fileRoots.some(root => normalised.startsWith(path.resolve(root)));
}

server.tool(
  'read_file',
  'Reads a file from within the allowed workspace roots',
  { file_path: z.string().describe('Path to the file to read') },
  async ({ file_path }, { server: serverInstance }) => {
    // Check the path is within declared roots before reading
    const allowed = await isWithinRoots(file_path, serverInstance);
    if (!allowed) {
      return {
        isError: true,
        content: [{
          type: 'text',
          text: `Access denied: ${file_path} is outside the allowed workspace roots.`,
        }],
      };
    }

    const content = await fs.readFile(file_path, 'utf8');
    return { content: [{ type: 'text', text: content }] };
  }
);

“Roots represent URI boundaries that define the scope of client access. Servers SHOULD use roots as guidance for what resources and operations to offer, respecting the boundaries set by the client.” – MCP Specification, Roots

Roots for Non-Filesystem URIs

Roots are not limited to file paths. Any URI scheme can be a root, which allows hosts to scope server access to particular API endpoints, repository namespaces, or any other URI-addressed resource space.

// API roots example
client.setRequestHandler(ListRootsRequestSchema, async () => ({
  roots: [
    { uri: 'https://api.mycompany.com/v1/projects/42', name: 'Project 42 API' },
    { uri: 'https://api.mycompany.com/v1/users/me', name: 'My User API' },
  ],
}));

// The server checks that any API call it makes is within these URIs:
async function isApiAllowed(endpoint, serverInstance) {
  const { roots } = await serverInstance.listRoots();
  return roots.some(r => endpoint.startsWith(r.uri));
}

Failure Modes with Roots

Case 1: Server Ignoring Roots Entirely

Roots are advisory in the current spec – the protocol does not force enforcement on the server. This means a badly implemented server can simply ignore the roots and access anything it wants. In a security-conscious deployment, the host should use OS-level sandboxing (chroot, Docker volumes, seccomp filters) to enforce the boundaries that roots only hint at.

// RISKY: Server trusts roots only, no OS enforcement
// A malicious or buggy server can bypass this
const allowed = await isWithinRoots(userProvidedPath, serverInstance);
if (allowed) await fs.readFile(userProvidedPath); // Only guarded by protocol hint

// SAFER: Add OS-level enforcement too
// Run the server process in a Docker container with volume mounts limited to the root dirs:
// docker run --volume /Users/alice/my-project:/workspace:ro my-mcp-server

Case 2: Not Handling roots/list_changed

If the user changes the active workspace (opens a different project, switches repositories), the client sends roots/list_changed. If the server caches the roots from startup and ignores this notification, it will use stale root information for all subsequent operations.

// Handle roots change notifications
server.setNotificationHandler(
  { method: 'notifications/roots/list_changed' },
  async () => {
    // Invalidate cached roots and re-fetch
    cachedRoots = null;
    console.error('[server] Roots changed - refreshed scope');
  }
);

What to Check Right Now

  • Declare roots capability on your clients – if you build a host that has a concept of a workspace or project, declare roots and implement the handler. This is what makes your server integration “workspace-aware”.
  • Validate paths against roots in every file-touching tool – add the isWithinRoots check to every tool that reads or writes files. Do this before any fs.readFile or fs.writeFile call.
  • Test path traversal attempts – try passing ../../../etc/passwd to a file-reading tool and verify the roots check catches it.
  • Combine roots with OS isolation – in production, run server processes in containers with volume mounts restricted to the declared roots. Advisory protocol constraints are not a substitute for OS-level isolation.

nJoy 😉

MCP Protocol Course: 55 Lessons From Zero to Enterprise (Model Context Protocol + Node.js)

Revised 19 October 2026. This course contains 55 lessons on MCP architecture, protocol mechanics, Node.js, security, deployment, and agent integration. The original examples target MCP 2025-11-25 and the v1 TypeScript SDK. This revision documents the breaking changes in MCP 2026-07-28, published and documented on 28 July 2026. Until a lesson is marked 2026-07-28 verified, treat its code as a legacy example, not a current-protocol recipe.

Every few years, something happens in computing that quietly reshapes everything around it. The UNIX pipe. HTTP. REST. The transformer architecture. And now, in 2026, the Model Context Protocol. If you build software and you haven’t internalised MCP yet, this is your moment. This course will fix that – thoroughly.

MCP Protocol course - dark architectural diagram of hosts, clients and servers
The MCP ecosystem: hosts, clients, and servers unified under a single open protocol.

What This Course Is

This is a long practical course on the Model Context Protocol – the open standard, published by Anthropic and now maintained by a broad coalition, that lets AI models talk to tools, data sources, and services in a structured, secure, and interoperable way. Think of it as HTTP for AI context: before HTTP, every web server spoke its own dialect; after HTTP, the whole web could talk to each other. MCP does the same thing for the agentic AI layer.

The course runs 55 lessons across 12 Parts, from zero to enterprise. Part I gives you the mental model and the first working server in under an hour. Part XII has you building a full production MCP platform with a registry, an API gateway, and multi-agent orchestration. Everything in between is ordered by dependency – no lesson assumes knowledge that hasn’t been covered yet.

The specification describes three jobs for an application: share context with a language model, expose tools, and build workflows out of those pieces. The current text is in the 2026-07-28 specification.

All code is in plain Node.js 22 ESM – no TypeScript, no compilation step, no tsconfig to wrestle with. You run node server.js and it works. The point is to teach MCP, not the type system. Where types genuinely help (complex tool schema shapes), JSDoc hints appear inline. Everywhere else, the code is clean signal.

Historical note: what changed on 28 July 2026

This course was first published on 19 March 2026. Two lessons followed on 22 March 2026. Those pages were written against specification 2025-11-25. On 28 July 2026 the maintainers published specification 2026-07-28 and documented it the same day, in the release note and the changelog. This note, added on 19 October 2026, is the record of that break. Read it before the lessons that still walk through the older connection.

The July revision did not bolt a feature onto the side. It changed what a connection is. Through 2025-11-25, a client opened a session, sent initialize, read the server’s capabilities, then sent notifications/initialized before any real work. Streamable HTTP kept that session in the Mcp-Session-Id header, so a load balancer had to pin the client to one process. From 2026-07-28 that handshake is gone. Every request carries its protocol version and client capabilities in _meta. Any instance can answer it. server/discover is there when a client wants identity and supported versions before the first real call. Servers must implement it. Clients may skip it.

  • Sessions and the handshake, removed. No initialize, no notifications/initialized, no Mcp-Session-Id. A version the server does not support comes back as UnsupportedProtocolVersionError. Results carry resultType. A result from an older server that omits the field is treated as complete.
  • Multi round-trip requests, added. The server used to ask the client something mid-call, on the open stream: elicitation/create, sampling/createMessage, roots/list. That pattern is no longer supported. The server now returns resultType: "input_required" with inputRequests. The client gathers the answer and retries the original call, echoing requestState untouched. The retry is a new request, with a new id.
  • Tasks left the core. The experimental Tasks API in 2025-11-25 (tasks/list, tasks/result, per-call opt-in) moved to the official extension io.modelcontextprotocol/tasks. The extension polls with tasks/get, takes further input with tasks/update, and cancels with tasks/cancel. A server may return a task handle without the client asking for one on that call.
  • Roots, Sampling, and Logging are deprecated. They still function during the deprecation window. New implementations should not adopt them. Pass directories as tool arguments or resource URIs instead of Roots. Call the model provider yourself instead of Sampling. Write logs to stderr, or to OpenTelemetry, instead of the protocol Logging feature. ping, logging/setLevel, and notifications/roots/list_changed are removed. The older HTTP+SSE transport, replaced in March 2025, is classified as Deprecated. Streamable HTTP is the remote transport.
  • Extensions are now a formal track. Client and server capabilities carry an extensions field. Three to know: Tasks, MCP Apps (interactive UI rendered inside the host), and Skills over MCP (structured agent instructions discovered through the protocol).

Where a lesson still shows initialize, a session id, tasks/result, or a server-initiated request on an open stream, that is the 2025-11-25 behaviour this course originally taught. The replacement is the list above. Lifecycle, Streamable HTTP, scaling, Tasks, Sampling, Roots, and protocol Logging each point back to this note.

Who This Is For

The course was designed for two audiences who need the same rigour but come at it differently:

  • University students – third or fourth year CS, AI, or software engineering. You know how to write async JavaScript. You’ve used an LLM API. You want to understand the architecture that makes production agentic systems work, not just the vibes.
  • Professional engineers and architects – you’re building AI-powered products or evaluating MCP for your organisation. You need the protocol internals, the security model, the enterprise deployment patterns, and a clear comparison of how OpenAI, Anthropic Claude, and Google Gemini each implement the standard differently.

If you’re a beginner to programming, start with the Node.js fundamentals first. If you’re already shipping LLM features to production, you can start from Part IV (provider integrations) and backfill the protocol theory as needed.

MCP course structure - 12 parts from foundations to capstone projects
Twelve parts. Fifty-five lessons. Ordered strictly by dependency.

The Technology Stack

Every lesson uses the same stack throughout, so you never lose time context-switching:

  • Runtime: Node.js 22+ with native ESM ("type": "module")
  • MCP SDK: @modelcontextprotocol/sdk v1 stable (v2 features noted as they ship)
  • Schema validation: zod v4 for tool input schemas
  • HTTP transport: @modelcontextprotocol/express or Hono adapter
  • OpenAI: openai latest – tool calling with GPT-6 Astra, GPT-6.1 Sol, and GPT-6 Luna
  • Anthropic: @anthropic-ai/sdk latest – Claude Sonnet 5.5 and Claude Opus 5.5
  • Gemini: @google/generative-ai latest – Gemini 3.8 Flash and Gemini 4 Argon
  • Native Node.js extras: --env-file for secrets, node:test for tests

No framework lock-in beyond the MCP SDK itself. All HTTP adapter code works with plain Node.js http if you prefer – the adapter packages are convenience wrappers, not requirements.

Course Curriculum

Fifty-five lessons across twelve parts. Every lesson below is published.

Part I: Foundations

  1. What is MCP? The Protocol that Unified AI Tool Integration
  2. Hosts, Clients, and Servers: The Three-Role Model
  3. Under the Hood: JSON-RPC 2.0, Lifecycle, and Capability Negotiation
  4. Node.js Dev Environment: SDK, Zod, ESM, and Tooling
  5. Your First MCP Server and Client in Node.js

Part II: Core Server Primitives

  1. Tools: Defining, Validating, and Executing LLM-Callable Functions
  2. Resources: Exposing Static and Dynamic Data to AI Models
  3. Prompts: Reusable Templates and Workflow Fragments
  4. Sampling: Server-Initiated LLM Calls and Recursive AI
  5. Elicitation: Asking the User for Input from Inside a Server
  6. Roots: Filesystem and URI Boundaries
  7. Server Instructions: Guiding Agents With the Server Manual

Part III: Transports

  1. stdio Transport: The Local Standard and When to Use It
  2. Streamable HTTP and SSE: Building Remote MCP Servers
  3. HTTP Adapters: Express, Hono, and the Node Middleware
  4. Transport Security: TLS, CORS, and Host Header Validation

Part IV: OpenAI Integration

  1. OpenAI + MCP: Tool Calling with GPT-6 Astra, Sol, and Luna
  2. Streaming Completions and Structured Outputs with MCP Tools
  3. Responses API and the Agents SDK + MCP
  4. Building a Production OpenAI-Powered MCP Client

Part V: Anthropic Claude Integration

  1. Claude Opus 5.5 and Sonnet 5.5 + MCP: Native Tool Calling
  2. Extended Thinking Mode with MCP Tools
  3. Claude Code and Agent Skills: MCP in the Era of Autonomous Coding
  4. Patterns and Anti-Patterns: Claude + MCP in Production

Part VI: Google Gemini Integration

  1. Gemini 4 Argon and 3.8 Flash + MCP: Function Calling at Scale
  2. Multi-modal MCP: Images, PDFs, and Audio with Gemini
  3. Google AI Studio, Vertex AI, and MCP Servers
  4. Building a Production Gemini-Powered MCP Client

Part VII: Cross-Provider Patterns

  1. OpenAI vs Claude vs Gemini: MCP Tool Calling Compared
  2. Provider Abstraction: A Node.js Library for Multi-Provider MCP Clients
  3. Choosing the Right Model: A Decision Framework for MCP Applications

Part VIII: Security and Trust

  1. OAuth 2.0 with MCP: Authentication for Remote Servers
  2. Authorization, Scope Consent, and Incremental Permissions
  3. Tool Safety: Input Validation, Sandboxing, and Execution Limits
  4. Secrets Management: Vault, Environment Variables, and Rotation
  5. Audit Logging, Compliance, and Data Privacy

Part IX: Multi-Agent Systems

  1. Agent-to-Agent (A2A) Protocol: MCP in Multi-Agent Architectures
  2. MCP + LangChain and LangGraph: Orchestration Patterns in Node.js
  3. Reliable Agent Pipelines: State, Memory, and Checkpoints
  4. Failure Modes: Loops, Hallucinations, and Cascades in MCP Agents

Part X: Enterprise Patterns

  1. Production Deployment: Docker, Health Checks, and Graceful Shutdown
  2. Scaling MCP: Load Balancing, Rate Limiting, and Caching
  3. Token Optimization and Tool Search for MCP at Scale
  4. Observability: Logging, Metrics, and Distributed Tracing
  5. CI/CD for MCP Servers: Testing, Versioning, and Zero-Downtime Deploy
  6. MCP Registry, Discovery, and Service Mesh Patterns

Part XI: Advanced Protocol Features

  1. Tasks API: Long-Running Async Operations
  2. Cancellation, Progress, and Backpressure in MCP Streams
  3. Protocol Versioning, Backwards Compatibility, and Migration
  4. Writing Custom Transports and Protocol Extensions

Part XII: Capstone Projects

  1. Project 1: PostgreSQL Query Agent with OpenAI + MCP
  2. Project 2: File System Agent with Claude + MCP
  3. Project 3: A Multi-Provider API Integration Hub
  4. Project 4: Enterprise AI Assistant with Auth, RBAC, and Audit Logging
  5. Capstone: Building a Full MCP Platform – Registry, Gateway, and Agents
Node.js MCP stack - SDK, Zod, OpenAI, Claude, Gemini on dark background
The complete stack: Node.js 22 ESM, the MCP SDK, Zod schemas, and all three major LLM providers.

How the Lessons Are Written

Each lesson is designed to be self-contained and longer than comfortable. The goal is that a reader who sits down with the article and a terminal open will finish knowing how to do the thing, not just knowing that the thing exists. That means:

  • Named failure cases – every lesson covers what goes wrong, specifically, with the exact code that triggers it and the exact fix. Learning from bad examples sticks better than learning from good ones.
  • Official source quotes – every lesson cites the MCP specification, SDK documentation, or relevant RFC directly. The wording is exact, not paraphrased. The link goes to the actual source document.
  • Code – examples are labelled by protocol version and status. A block is not a current recipe unless the lesson says 2026-07-28 verified. Pseudocode is marked as pseudocode.
  • Balance – where a technique has valid alternatives, the lesson says so. A reader should leave knowing when to use the thing taught, and when not to.

The specification uses the BCP 14 keywords (MUST, SHOULD, MAY, and the rest) only when they appear in capitals. The conventions are on the 2026-07-28 specification.

The course is sourced from over 77 videos across six major MCP playlists from channels including theailanguage, Microsoft Developer, and CampusX – then substantially expanded with code, official spec references, and architectural analysis that the videos don’t cover. The videos are the floor, not the ceiling.

What to Check Right Now

  • Verify Node.js 22+ – run node --version. If you’re below 22, install via nodejs.org or nvm install 22.
  • Install yt-dlp (optional, for running the research tooling) – brew install yt-dlp or pip install yt-dlp.
  • Get API keys before Part IV – OpenAI, Anthropic, and Google AI Studio keys. Store them in .env files, never in code.
  • Bookmark the MCP spec – modelcontextprotocol.io/specification/2026-07-28. You will refer to it constantly.
  • Start with Lesson 1 – even if you’ve used LLM tool calling before, the framing in the first three lessons will change how you think about it.
Enterprise MCP platform architecture - registry, gateway, agents, dark minimal
Where you’ll be by Part XII: a production MCP platform with registry, gateway, and full multi-agent orchestration.

nJoy 😉

Lesson 10 of 55: MCP Elicitation – Ask the User for Structured Input From Inside a Server

Historical note, revised 19 October 2026. The elicitation flow below sends elicitation/create from the server to the client in the middle of a tool call. That server-initiated request is no longer supported. From specification 2026-07-28 the server returns input_required, the user answers in the host, and the client retries the original tools/call with inputResponses. The elicitationId field and the completion notification from November 2025 were removed with it. See the historical note.

Some tools cannot complete without asking the user a question. “Which account should I debit?” “What is the date range for this report?” “Are you sure you want to delete all 200 records?” These are not questions an LLM should guess at. They require explicit, structured input from the human in the loop. Elicitation is MCP’s mechanism for this – it lets a server, while handling a request, ask the user for information through the client’s UI, wait for the answer, and then continue. It is a synchronous human-in-the-loop pattern baked into the protocol.

MCP elicitation flow showing server pausing tool execution to request user input through client UI dark diagram
Elicitation: the server pauses tool execution, asks the user a structured question, and resumes with the answer.

How Elicitation Works

When a server needs user input, it sends an elicitation/create request to the client. The request includes a message (explaining what information is needed) and a JSON schema describing the expected response format. The client presents this to the user in whatever UI is appropriate – a dialog box, a prompt, a form – and returns the user’s structured response. The server receives the answer and continues processing.

Elicitation is a client capability – the server can only use it if the client declared elicitation support during initialisation. If the client does not support elicitation, the server must handle the lack of user input gracefully (skip the step, use defaults, or return an error with a clear explanation).

// Client: declare elicitation support
const client = new Client(
  { name: 'my-host', version: '1.0.0' },
  {
    capabilities: {
      elicitation: {
        form: {},   // In-band structured data collection
        url: {},    // Out-of-band URL navigation (New in 2025-11-25)
      },
    },
  }
);

// Client: implement the elicitation handler
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';

client.setRequestHandler(ElicitRequestSchema, async (request) => {
  const { message, requestedSchema } = request.params;

  // Show UI to user - implementation is host-specific
  const userResponse = await showElicitationDialog(message, requestedSchema);

  if (userResponse === null) {
    // User dismissed/cancelled
    return { action: 'cancel' };
  }

  return {
    action: 'accept',
    content: userResponse,  // Must match requestedSchema
  };
});

Why this matters: the handler is the trust boundary between protocol messages and your UI. In a real project you would validate userResponse against requestedSchema before returning accept, and you would time out slow users so the server does not hang forever.

MCP elicitation schema showing JSON schema for user response with string number boolean enum field types
Elicitation schemas: flat JSON schemas that define the exact structure of the user’s expected answer.

The client pieces only declare and route elicitation; the server still decides when to pause a tool and what shape of answer it needs. The next example shows a full tool handler that elicits structured confirmation before a side effect.

Server-Side Elicitation Usage

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const server = new McpServer({ name: 'payment-server', version: '1.0.0' });

server.tool(
  'process_payment',
  'Processes a payment transaction with user confirmation',
  {
    amount: z.number().positive().describe('Amount in USD'),
    recipient: z.string().describe('Recipient name or email'),
  },
  async ({ amount, recipient }, { server: serverInstance }) => {
    // Elicit confirmation before processing
    const confirmation = await serverInstance.elicitInput({
      message: `You are about to send $${amount.toFixed(2)} to ${recipient}. Please confirm the payment account:`,
      requestedSchema: {
        type: 'object',
        properties: {
          account_id: {
            type: 'string',
            description: 'Your account ID to debit (format: ACC-XXXXXXXX)',
          },
          confirmed: {
            type: 'boolean',
            description: 'Confirm you want to proceed with this payment',
          },
        },
        required: ['account_id', 'confirmed'],
      },
    });

    if (confirmation.action === 'cancel') {
      return { content: [{ type: 'text', text: 'Payment cancelled by user.' }] };
    }

    if (!confirmation.content.confirmed) {
      return { content: [{ type: 'text', text: 'Payment declined - user did not confirm.' }] };
    }

    const result = await processPayment({
      amount,
      recipient,
      accountId: confirmation.content.account_id,
    });

    return { content: [{ type: 'text', text: `Payment ${result.transactionId} processed successfully.` }] };
  }
);

Why this pattern matters: money-moving and irreversible actions are where silent model guesses hurt the most. In a real project you would keep the elicitation copy short, show the same numbers the server will use, and log the outcome for audit without storing secrets in the model context.

“Elicitation allows servers to request additional information from users during tool execution. This enables interactive workflows where user input is needed to complete tasks, while maintaining a clear separation between the AI model and the human oversight layer.” – MCP Specification, Elicitation

Elicitation Schema Constraints

The schema for an elicitation request is deliberately restricted compared to full JSON Schema. This is intentional – the schema must be renderable by any client UI, which means it cannot be arbitrarily complex. The spec defines a “flat” schema: a single object with primitive properties (string, number, boolean, or enum). No nested objects, no arrays, no $ref references.

// VALID elicitation schema - flat, primitive properties only
{
  type: 'object',
  properties: {
    name: { type: 'string', description: 'Your full name' },
    age: { type: 'number', description: 'Your age in years' },
    agree_to_terms: { type: 'boolean', description: 'Do you agree to the terms?' },
    plan: { type: 'string', enum: ['basic', 'pro', 'enterprise'], description: 'Choose a plan' },
  },
  required: ['name', 'agree_to_terms'],
}

// INVALID: Nested objects not allowed in elicitation schemas
{
  type: 'object',
  properties: {
    address: {
      type: 'object',  // This will fail - nested objects not permitted
      properties: { street: { type: 'string' } },
    },
  },
}

If you need a richer form, split it into sequential elicitations or collect details before the tool runs. The flat schema trade-off keeps portable clients implementable; planning your UX around that limit avoids spec violations at runtime.

Failure Modes with Elicitation

Case 1: Not Handling cancel Action

Users can always cancel an elicitation. If your tool handler does not check for the cancel action, it will try to proceed with undefined data and crash or produce garbage output.

// BAD: No cancel check
const response = await serverInstance.elicitInput({ ... });
await processData(response.content.value); // Will throw if action was 'cancel'

// GOOD: Always check the action
const response = await serverInstance.elicitInput({ ... });
if (response.action !== 'accept') {
  return { content: [{ type: 'text', text: 'Action cancelled.' }] };
}
await processData(response.content.value);

Case 2: Using Elicitation When a Tool Parameter Would Suffice

Elicitation is for input the server cannot know at design time – confirmation of a specific action, a password, runtime context. If the information can be a tool argument, make it a tool argument. Elicitation adds a round-trip to the user and breaks the automated flow.

// WRONG: Using elicitation for something that should be an arg
server.tool('delete_record', '...', { id: z.string() }, async ({ id }, ctx) => {
  const confirm = await ctx.server.elicitInput({ message: 'Confirm deletion?', ... });
  // Should this really need an interactive prompt? Or is --confirm a better pattern?
});

// BETTER: Use tool annotations + let the host handle confirmation
server.tool(
  'delete_record',
  'Permanently deletes a record',
  { id: z.string(), confirm: z.boolean().describe('Set true to confirm permanent deletion') },
  { annotations: { destructiveHint: true } },
  async ({ id, confirm }) => {
    if (!confirm) return { isError: true, content: [{ type: 'text', text: 'Set confirm=true to proceed.' }] };
    await db.delete(id);
    return { content: [{ type: 'text', text: `Deleted ${id}` }] };
  }
);

URL Mode Elicitation

New in 2025-11-25

Form mode elicitation passes user data through the MCP client – the client renders the form, collects the response, and relays it back to the server. This is fine for non-sensitive data like preferences, date ranges, or confirmation flags. But some workflows need the user to enter credentials, sign a document, or complete an OAuth flow in a secure context where the MCP client should never see the raw data. URL mode solves this by directing the user to an external URL for the sensitive interaction.

When a server sends a URL mode elicitation, the client opens the target URL (in a browser or embedded webview), and the user interacts directly with the server’s web page. The sensitive data (password, API key, payment info) stays between the user and the server’s web endpoint. The MCP client only learns whether the user completed or cancelled the flow.

// Server: elicit via URL mode for credential entry
const result = await serverInstance.elicitInput({
  mode: 'url',
  message: 'Please sign in to your GitHub account to grant repository access.',
  url: 'https://auth.example.com/github/connect?session=abc123',
});

if (result.action === 'cancel') {
  return { content: [{ type: 'text', text: 'GitHub authentication cancelled.' }] };
}

// result.action === 'accept' means the user completed the flow
// The server's web endpoint already has the credential from the callback
const repos = await fetchGitHubRepos(sessionId);
return { content: [{ type: 'text', text: `Connected. Found ${repos.length} repositories.` }] };

The specification requires that servers MUST NOT use form mode for sensitive information. If your tool needs a password, API key, or payment detail, use URL mode. The client MUST display the target domain and get user consent before navigating. This is a trust boundary: the user decides whether to visit the URL, and the MCP client never touches the sensitive data.

Clients declare URL mode support in their capabilities: { elicitation: { form: {}, url: {} } }. A client that only supports form mode omits the url key. For backwards compatibility, an empty elicitation: {} object is treated as form-only. Servers MUST check the client’s declared capabilities before sending a URL mode request.

Titled and Multi-Select Enums

New in 2025-11-25

Earlier versions of elicitation only supported bare enums: enum: ['basic', 'pro', 'enterprise']. The 2025-11-25 spec adds titled enums (human-readable labels for each option) and multi-select enums (the user can pick more than one value). These make elicitation forms more usable without adding nested schema complexity.

// Titled enum: each option has a display label
const response = await serverInstance.elicitInput({
  message: 'Select your deployment region:',
  requestedSchema: {
    type: 'object',
    properties: {
      region: {
        type: 'string',
        enum: ['us-east-1', 'eu-west-1', 'ap-southeast-1'],
        enumNames: ['US East (Virginia)', 'EU West (Ireland)', 'Asia Pacific (Singapore)'],
        description: 'AWS region for deployment',
      },
    },
    required: ['region'],
  },
});

// Multi-select enum: user picks one or more values
const tagsResponse = await serverInstance.elicitInput({
  message: 'Tag this record (select all that apply):',
  requestedSchema: {
    type: 'object',
    properties: {
      tags: {
        type: 'array',
        items: {
          type: 'string',
          enum: ['urgent', 'billing', 'technical', 'feature-request'],
          enumNames: ['Urgent', 'Billing Issue', 'Technical Support', 'Feature Request'],
        },
        description: 'One or more tags for this support ticket',
      },
    },
    required: ['tags'],
  },
});

Titled enums prevent the user from seeing raw enum values like us-east-1 when the client renders the form – instead they see “US East (Virginia)”. Multi-select enums use an array wrapper around the enum type. Both features keep the schema flat and portable across client UIs.

Default Values in Elicitation Schemas

New in 2025-11-25

All primitive types in elicitation schemas now support a default value. The client pre-fills the form field with the default, reducing friction for the common case while still letting the user override when needed.

const response = await serverInstance.elicitInput({
  message: 'Configure the export:',
  requestedSchema: {
    type: 'object',
    properties: {
      format: {
        type: 'string',
        enum: ['csv', 'json', 'parquet'],
        default: 'csv',
        description: 'Export file format',
      },
      limit: {
        type: 'number',
        default: 1000,
        description: 'Maximum rows to export',
      },
      include_headers: {
        type: 'boolean',
        default: true,
        description: 'Include column headers in the output',
      },
    },
    required: ['format'],
  },
});

Defaults reduce the number of fields the user has to actively fill in. For high-frequency workflows (daily exports, recurring queries), a well-chosen default turns a multi-field form into a single-click confirmation.

What to Check Right Now

  • Map your interactive flows – identify any workflow in your application that requires user input mid-execution. These are elicitation candidates.
  • Keep schemas flat – validate your elicitation schemas against the spec constraints: flat object, primitive values only, no nested objects or arrays.
  • Always handle cancel and decline – every elicitation can result in cancel (user dismissed) or decline (user responded negatively). Handle all three outcomes.
  • Check client support first – before calling elicitInput, check client.getClientCapabilities()?.elicitation. If the client does not support it, fall back to tool-argument-based confirmation.
  • Use URL mode for sensitive data – passwords, API keys, and payment details MUST go through URL mode, never form mode. Check that the client declares url in its elicitation capabilities before sending a URL mode request.
  • Add defaults to common fields – pre-fill format, limit, and toggle fields so the user can confirm with one click in routine workflows.

nJoy 😉

Lesson 47 of 55: MCP Tasks API – Long-Running Async Operations and Progress

Historical note, revised 19 October 2026. The Tasks API below is the experimental core feature from 2025-11-25, including tasks/list and tasks/result. On 28 July 2026 those methods left the core. Tasks is now the extension io.modelcontextprotocol/tasks: poll with tasks/get, send further input with tasks/update, cancel with tasks/cancel. The server may return a task handle without a per-call opt-in. See the historical note.

New in 2025-11-25 (experimental) – The Tasks API replaced older DIY polling patterns with a protocol-level state machine. The entire feature is experimental and may evolve in future spec versions.

Most MCP tool calls complete in under a second: query a database, call an API, read a file. But some operations take minutes or hours: training a model, processing a large dataset, running a batch export, triggering a CI/CD pipeline. For these, a synchronous request-response model breaks down. The 2025-11-25 specification introduced the Tasks API – a protocol-level mechanism for durable, async request tracking. Instead of inventing your own “start_task + poll get_task_status” pattern (which every server implemented differently), the Tasks API provides a standard state machine, standard polling endpoints (tasks/get, tasks/list, tasks/cancel, tasks/result), and per-tool opt-in via execution.taskSupport.

MCP Tasks API async operation diagram task submitted accepted polling progress events completion dark
Tasks API: augment a tools/call request with a task, poll via tasks/get, retrieve the result via tasks/result when done.

When to Use Tasks vs Regular Tools

  • Use regular tools for operations that complete in under 30 seconds. Keep them synchronous – the LLM waits for the result before proceeding.
  • Use task-augmented tools for operations that take longer than 30 seconds, produce intermediate results the user or LLM can act on, or may fail partway through and need resumability.

Before the Tasks API, every server had to invent its own polling scheme (two tools, custom status fields, ad-hoc cancellation). The protocol-level approach standardises the state machine and the polling endpoints, so every client handles async the same way regardless of which server it talks to.

Task State Machine

Every task starts in the working state and follows a strict lifecycle. The three terminal states (completed, failed, cancelled) are irreversible – once a task reaches one of them, it cannot transition to any other state.

//  Task Status State Machine
//
//  [created] --> working --+--> completed (terminal)
//                 |    ^   +--> failed    (terminal)
//                 |    |   +--> cancelled (terminal)
//                 v    |
//           input_required
//
//  working --> input_required: server needs client input to proceed
//  input_required --> working: client provided the requested input
//  Any non-terminal --> cancelled: via tasks/cancel

The input_required state is for cases where the task cannot proceed without additional input from the client – for example, the server needs an MFA code or the user must approve an intermediate step. When the client sees input_required, it should call tasks/result to receive the pending request (an elicitation or sampling request), handle it, and allow the task to transition back to working.

Capability Negotiation

Both servers and clients declare task support during initialisation. The capabilities structure is organised by request type – a server declares which of its incoming request types (like tools/call) support task augmentation, and a client declares which of its incoming request types (like sampling/createMessage and elicitation/create) support it.

// Server capabilities: tasks supported for tools/call
{
  capabilities: {
    tasks: {
      list: {},                      // supports tasks/list
      cancel: {},                    // supports tasks/cancel
      requests: {
        tools: { call: {} },         // tools/call can be task-augmented
      },
    },
  },
}

// Client capabilities: tasks supported for sampling and elicitation
{
  capabilities: {
    tasks: {
      list: {},
      cancel: {},
      requests: {
        sampling: { createMessage: {} },    // sampling can be task-augmented
        elicitation: { create: {} },        // elicitation can be task-augmented
      },
    },
  },
}

If a server does not include tasks.requests.tools.call, clients MUST NOT attempt task augmentation on that server’s tools, regardless of per-tool settings.

Tool-Level Task Support

Individual tools declare their task support via execution.taskSupport in the tools/list response. This is a fine-grained layer on top of the server-level capability.

// In the tools/list response, each tool can declare task support
{
  name: 'generate_report',
  title: 'Generate Report',
  description: 'Generates a PDF report from analytics data. May take several minutes.',
  inputSchema: { /* ... */ },
  execution: {
    taskSupport: 'optional',   // 'forbidden' (default) | 'optional' | 'required'
  },
}
  • "forbidden" (default): the tool cannot be invoked as a task. If a client tries, the server returns error -32601.
  • "optional": the client may invoke the tool normally (synchronous) or as a task (async). Both work.
  • "required": the client MUST invoke the tool as a task. Synchronous invocation returns error -32601.

Creating a Task-Augmented Request

To invoke a tool as a task, the client includes a task field in the tools/call params. The server accepts the request immediately and returns a CreateTaskResult containing the task metadata – not the actual tool result.

// Client: send a task-augmented tools/call
const response = await client.request({
  method: 'tools/call',
  params: {
    name: 'generate_report',
    arguments: { reportType: 'quarterly', period: '2025-Q3' },
    task: {
      ttl: 300000,   // requested lifetime: 5 minutes
    },
  },
});

// Response is a CreateTaskResult, not the tool result
// {
//   task: {
//     taskId: '786512e2-9e0d-44bd-8f29-789f320fe840',
//     status: 'working',
//     statusMessage: 'Report generation started.',
//     createdAt: '2025-11-25T10:30:00Z',
//     lastUpdatedAt: '2025-11-25T10:30:00Z',
//     ttl: 300000,
//     pollInterval: 5000,
//   }
// }

const { taskId, pollInterval } = response.task;

The ttl (time-to-live in milliseconds) tells the server how long the client wants the task and its results to be retained. The server may override the requested TTL. After the TTL expires, the server may delete the task and its results regardless of status.

Polling With tasks/get

Clients poll for task status using tasks/get. The server returns the current task state including status, statusMessage, and a pollInterval suggestion. Clients SHOULD respect the pollInterval to avoid overwhelming the server.

// Client: poll until terminal status
async function pollTask(client, taskId, initialInterval = 5000) {
  let interval = initialInterval;

  while (true) {
    await new Promise(r => setTimeout(r, interval));

    const status = await client.request({
      method: 'tasks/get',
      params: { taskId },
    });

    console.log(`Task ${taskId}: ${status.status} - ${status.statusMessage ?? ''}`);

    if (['completed', 'failed', 'cancelled'].includes(status.status)) {
      return status;
    }

    if (status.status === 'input_required') {
      // The server needs input - call tasks/result to get the pending request
      return status;
    }

    // Respect the server's suggested poll interval
    if (status.pollInterval) {
      interval = status.pollInterval;
    }
  }
}
Task status polling pattern client calling tasks/get multiple times watching progress working to completed dark
Polling pattern: send task-augmented tools/call, poll with tasks/get respecting pollInterval, retrieve result with tasks/result.

Retrieving Task Results

Once a task reaches a terminal status, the actual tool result is retrieved via tasks/result. This is distinct from tasks/get (which returns task metadata). The result has the same shape as a normal CallToolResult.

// Client: retrieve the actual tool result
const taskStatus = await pollTask(client, taskId);

if (taskStatus.status === 'completed') {
  const result = await client.request({
    method: 'tasks/result',
    params: { taskId },
  });

  // result is a CallToolResult: { content: [...], isError: false }
  console.log('Report ready:', result.content[0].text);
}

if (taskStatus.status === 'failed') {
  const result = await client.request({
    method: 'tasks/result',
    params: { taskId },
  });
  // result may contain an error description
  console.error('Task failed:', result.content?.[0]?.text);
}

If tasks/result is called while the task is still working, the server MUST block until the task reaches a terminal status and then return the result. This makes tasks/result a long-poll alternative to repeated tasks/get calls. However, clients SHOULD still poll with tasks/get in parallel if they want to display progress updates.

Listing and Cancelling Tasks

// List all tasks (paginated)
const listing = await client.request({
  method: 'tasks/list',
  params: { cursor: undefined },  // or a cursor from a previous response
});
// listing.tasks: array of Task objects
// listing.nextCursor: pagination token (if more tasks exist)

// Cancel a running task
try {
  const cancelled = await client.request({
    method: 'tasks/cancel',
    params: { taskId },
  });
  console.log(`Cancelled: ${cancelled.status}`);  // 'cancelled'
} catch (err) {
  // Error -32602 if the task is already in a terminal state
  console.error('Cannot cancel:', err.message);
}

Cancellation transitions the task to the cancelled terminal state. The server SHOULD attempt to stop the underlying work, but the task MUST be marked cancelled even if the underlying computation continues to run (best-effort cancellation). Clients SHOULD NOT rely on cancelled tasks being retained – retrieve any needed data before cancelling.

Status Notifications

Servers MAY send notifications/tasks/status when a task’s status changes. These are a convenience – clients MUST NOT rely on them for correctness, because notifications are optional and may be dropped. Always poll with tasks/get as the source of truth.

// Server: optionally notify the client of status changes
server.notification({
  method: 'notifications/tasks/status',
  params: {
    taskId: '786512e2-...',
    status: 'completed',
    statusMessage: 'Report generation finished.',
    createdAt: '2025-11-25T10:30:00Z',
    lastUpdatedAt: '2025-11-25T10:35:00Z',
    ttl: 300000,
    pollInterval: 5000,
  },
});

Client-Side Tasks: Sampling and Elicitation

Tasks are not server-only. Servers can also send task-augmented requests to the client for sampling/createMessage and elicitation/create. This is useful when a server initiates a sampling request that might take a long time (the client is calling an LLM), or an elicitation that requires the user to complete an out-of-band flow.

The pattern mirrors the server side: the server sends the request with a task field, the client accepts immediately with a CreateTaskResult, and the server polls the client’s tasks/get and tasks/result endpoints. The client declares which request types support this in its capabilities under tasks.requests.sampling.createMessage and tasks.requests.elicitation.create.

Task Metadata: Related Tasks

All requests, notifications, and responses related to a task MUST include io.modelcontextprotocol/related-task in their _meta field. This links sub-operations (like an elicitation triggered during a task-augmented tool call) back to the parent task.

// Elicitation triggered during a task-augmented tool call
// The _meta links it to the parent task
{
  method: 'elicitation/create',
  params: {
    message: 'Enter the MFA code to continue the deployment.',
    requestedSchema: { /* ... */ },
    _meta: {
      'io.modelcontextprotocol/related-task': {
        taskId: '786512e2-9e0d-44bd-8f29-789f320fe840',
      },
    },
  },
}

Server-Side Implementation Pattern

The SDK does not yet have high-level helpers for the Tasks API (it is experimental). In practice, you implement it by managing a task store, intercepting tool calls that include a task field, and exposing the tasks/* methods. Production systems should use Redis or a database so task state survives server restarts.

import crypto from 'node:crypto';

const taskStore = new Map();

// When a tools/call includes params.task, create a task entry and return immediately
function createTask(ttl = 60000) {
  const task = {
    taskId: crypto.randomUUID(),
    status: 'working',
    statusMessage: null,
    createdAt: new Date().toISOString(),
    lastUpdatedAt: new Date().toISOString(),
    ttl,
    pollInterval: 5000,
    _result: null,    // stored when complete
    _error: null,     // stored on failure
  };
  taskStore.set(task.taskId, task);
  return task;
}

function updateTask(taskId, updates) {
  const task = taskStore.get(taskId);
  if (!task) return;
  Object.assign(task, updates, { lastUpdatedAt: new Date().toISOString() });
}

// Clean up expired tasks
setInterval(() => {
  const now = Date.now();
  for (const [id, task] of taskStore) {
    const created = new Date(task.createdAt).getTime();
    if (task.ttl !== null && now - created > task.ttl) {
      taskStore.delete(id);
    }
  }
}, 60_000);

What to Check Right Now

  • Audit your slow tools – any tool that regularly takes over 30 seconds is a candidate for execution.taskSupport: 'optional'.
  • Check server capabilities – if you add task support, declare tasks.requests.tools.call in your server capabilities.
  • Respect pollInterval – never hard-code a polling frequency. Always use the server’s suggested pollInterval from the tasks/get response.
  • Handle all terminal states – completed, failed, and cancelled all need distinct handling in your polling loop.
  • Remember this is experimental – the Tasks API was introduced in 2025-11-25 and may change. Pin your implementation to the spec version and watch for updates.

nJoy 😉

Lesson 43 of 55: Token Optimization and Tool Search for MCP at Scale

Every MCP session starts the same way: the client calls tools/list, gets back every tool schema your server exposes, and sends the entire payload to the LLM as part of the system context. For a server with 10 tools and concise descriptions, that is a few thousand tokens – barely noticeable. For a real enterprise setup with 5 MCP servers exposing 50-100 tools total, you are burning 50,000-80,000 tokens before the user has typed a single word. That is 40% of a 200K context window, gone to tool definitions alone. This lesson covers how to measure, reduce, and eventually eliminate that tax.

MCP token budget breakdown showing tool schemas consuming 40 percent of context window dark visualization
The hidden token tax: tool schemas can consume 40%+ of context before any work begins.

Measuring the Problem

Before optimizing, measure. The MCP Inspector shows you the raw tools/list response. To estimate the token cost, count the JSON payload size: roughly 1 token per 4 characters of JSON.

// measure-tool-tokens.js
// Connect to an MCP server and measure the token cost of its tool schemas
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const transport = new StdioClientTransport({
  command: 'node',
  args: ['./your-server.js'],
});
const client = new Client({ name: 'token-measurer', version: '1.0.0' });
await client.connect(transport);

const { tools } = await client.listTools();
const payload = JSON.stringify(tools);
const estimatedTokens = Math.ceil(payload.length / 4);

console.log(`Tools: ${tools.length}`);
console.log(`Payload size: ${(payload.length / 1024).toFixed(1)} KB`);
console.log(`Estimated tokens: ${estimatedTokens.toLocaleString()}`);
console.log(`% of 200K context: ${((estimatedTokens / 200_000) * 100).toFixed(1)}%`);

// Per-tool breakdown
for (const tool of tools) {
  const toolJson = JSON.stringify(tool);
  console.log(`  ${tool.name}: ${Math.ceil(toolJson.length / 4)} tokens`);
}

await client.close();

Real-world numbers from production MCP servers:

MCP Server Tools Tokens % of 200K
MySQL Server 106 54,600 27.3%
GitHub + Slack + Sentry + Grafana + Splunk ~120 ~77,200 38.6%
Typical 10-tool custom server 10 ~3,000 1.5%

The problem is not a single server – it is the aggregate. Five servers with 20 tools each, at 300 tokens per tool, is 30,000 tokens. Add the system prompt, conversation history, and the reserved output buffer, and you have very little room left for actual work.

Server-Side: Description Economy

The single highest-impact optimization is writing shorter tool descriptions and leaner schemas. Here is the token cost per pattern:

Schema Pattern Tokens/Tool Recommendation
Verbose description (200+ words) ~300 Trim to 1-2 sentences
Nested object params (3+ levels) ~180 Flatten to scalar params
Enum with 20+ values ~120 Use string + validate server-side
Concise description (1-2 sentences) ~40 Target this range
// BEFORE: 280+ tokens per tool
server.tool(
  'search_orders',
  'Search for customer orders in the database. This tool allows you to find orders ' +
  'by various criteria including customer email, order status, date range, and product ' +
  'category. It returns a paginated list of matching orders with full details including ' +
  'line items, shipping status, payment method, and customer information. Use this tool ' +
  'when the user asks about their orders, wants to check order status, or needs to find ' +
  'a specific purchase. Results are sorted by date descending by default.',
  {
    filters: z.object({
      customer: z.object({
        email: z.string().email().optional(),
        id: z.string().optional(),
      }).optional(),
      status: z.enum(['pending', 'processing', 'shipped', 'delivered', 'returned', 'cancelled', 'refunded']).optional(),
      date_range: z.object({
        start: z.string().optional(),
        end: z.string().optional(),
      }).optional(),
    }),
    pagination: z.object({
      page: z.number().int().min(1).default(1),
      per_page: z.number().int().min(1).max(100).default(20),
    }).optional(),
  },
  handler
);

// AFTER: ~45 tokens per tool (same functionality)
server.tool(
  'search_orders',
  'Find orders by email, status, or date. Returns order ID, status, total, date.',
  {
    email: z.string().optional().describe('Customer email'),
    status: z.string().optional().describe('Order status filter'),
    after: z.string().optional().describe('ISO date lower bound'),
    before: z.string().optional().describe('ISO date upper bound'),
    limit: z.number().int().max(100).default(20).describe('Max results'),
  },
  handler
);

Key changes: flattened nested objects to scalar params, replaced the verbose description with one sentence, removed the long enum (validate server-side instead), dropped the pagination wrapper object. Same functionality, 85% fewer tokens.

Server-Side: Tool Consolidation

If you have multiple similar tools, consolidate them into one with a mode or provider parameter:

// BEFORE: 4 tools, ~2,800 tokens total
// search_tavily, search_brave, search_kagi, search_exa

// AFTER: 1 tool, ~700 tokens
server.tool(
  'web_search',
  'Search the web. Returns title, URL, snippet for each result.',
  {
    query: z.string().describe('Search query'),
    provider: z.enum(['tavily', 'brave', 'kagi', 'exa']).default('tavily')
      .describe('Search provider'),
    limit: z.number().int().max(20).default(5).describe('Max results'),
  },
  async ({ query, provider, limit }) => {
    const results = await searchProviders[provider].search(query, limit);
    return { content: [{ type: 'text', text: JSON.stringify(results) }] };
  }
);

A real-world case study: consolidating 20 tools into 8 reduced token cost from 14,214 to 5,663 – a 60% reduction with identical functionality.

Anthropic Tool Search: 85% Token Reduction

For servers with many tools, Anthropic offers a client-side solution: tool search with defer_loading. Instead of sending all tool schemas to the LLM upfront, you mark tools as deferred. The LLM sees only a search interface and your server’s instructions. When it needs a tool, it searches the catalog, and only the matching schemas are loaded into context.

This is an Anthropic API feature, not part of the MCP specification itself. It works with the Anthropic Messages API and Claude Code:

// Using the Anthropic Messages API with tool search
// The MCP client gathers tools from multiple servers, then marks them as deferred

import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

// Collect tools from MCP servers
const allTools = await mcpClient.listAllTools(); // your multi-server aggregation

// Mark large/infrequent tools as deferred
const toolDefinitions = allTools.map(tool => ({
  name: tool.name,
  description: tool.description,
  input_schema: tool.inputSchema,
  // Tools marked defer_loading are NOT sent to the LLM initially
  // They are loaded on-demand via tool search
  defer_loading: allTools.length > 30 ? true : false,
}));

// Include the tool search tool (Anthropic provides two variants)
toolDefinitions.push({
  type: 'tool_search_tool_bm25_20251119', // BM25 natural language search
});

const response = await anthropic.messages.create({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 4096,
  tools: toolDefinitions,
  messages: [{ role: 'user', content: 'Check the latest PR reviews on the main repo' }],
});

// Claude will:
// 1. See only the tool_search tool + non-deferred tools
// 2. Search for relevant tools using natural language
// 3. Get back 3-5 tool_reference blocks with full schemas
// 4. Call the discovered tools normally

The token impact is dramatic:

Mode Tokens Context Preserved
All tools loaded ~77,200 122,800 / 200K
Tool search (defer_loading) ~8,700 191,300 / 200K

Two search variants exist:

  • tool_search_tool_regex_20251119 – Claude constructs regex patterns to search tool names and descriptions. Fast, precise.
  • tool_search_tool_bm25_20251119 – Claude uses natural language queries to search. Better for fuzzy matching.

Critical interaction with server instructions: when tool search is active, the LLM does not see individual tool schemas upfront. It sees only your server’s instructions field and the search tool. This makes instructions the primary signal for tool discovery – if your instructions don’t mention a capability, the model may never search for the tools that implement it.

Protocol-Level: Lazy Tool Hydration (Proposed)

The MCP community has proposed a protocol-level solution: lazy tool hydration (Issue #1978). The idea:

  1. Add a minimal flag to tools/list that returns only names, categories, and one-line summaries (~5K tokens for 106 tools instead of ~55K).
  2. Add a new tools/get_schema method that fetches the full schema for a specific tool on demand (~400 tokens per tool).
  3. Clients send the minimal list to the LLM. When the LLM wants to use a tool, the client fetches its full schema and adds it to context.

Estimated savings: 91% token reduction for the initial tool payload (54,604 tokens to 4,899 tokens for 106 tools). This is a proposal, not yet part of the specification, but it signals the direction the protocol is heading.

A related proposal, SEP-1576, adds $ref deduplication for shared parameter types across tools. If ten tools all take a customer_id parameter with the same schema, the definition is included once and referenced by all ten.

Host-Side: Claude Code Context Budget

Claude Code provides a /context command that shows the exact token breakdown per component. A typical over-provisioned setup looks like this:

System prompt:     3,100 tokens  ( 1.5%)
System tools:     12,400 tokens  ( 6.2%)
MCP tools:        82,000 tokens  (41.0%)  <-- THE PROBLEM
Conversation:     45,000 tokens  (22.5%)
Reserved output:  45,000 tokens  (22.5%)
Free space:       12,500 tokens  ( 6.3%)  <-- NOTHING LEFT

After applying the optimizations from this lesson (lean descriptions, tool consolidation, defer_loading):

System prompt:     3,100 tokens  ( 1.5%)
System tools:     12,400 tokens  ( 6.2%)
MCP tools:         5,700 tokens  ( 2.8%)  <-- FIXED
Conversation:     45,000 tokens  (22.5%)
Reserved output:  45,000 tokens  (22.5%)
Free space:       88,800 tokens  (44.4%)  <-- ROOM TO WORK

Claude Code v2.1.7+ automatically triggers MCP Tool Search when tool descriptions exceed 10% of context. If you’ve done the server-side optimization, this threshold is rarely hit. If you haven’t, Claude Code compensates by searching on demand – but at the cost of an extra round trip per tool discovery.

Optimization Checklist

Apply these in order of impact:

  1. Trim descriptions to 1-2 sentences – the biggest single win. Tool names carry semantic weight; the description just needs to disambiguate.
  2. Flatten nested object params – email instead of customer.email. Nested objects add structural JSON tokens.
  3. Consolidate similar tools – replace N tools with 1 tool + a mode parameter when the schemas are similar.
  4. Set sensible defaults – z.number().default(20) is better than documenting what happens when the field is omitted.
  5. Validate server-side, not in the schema – replace long enums (20+ values) with a string and validate in the handler.
  6. Use defer_loading for large tool sets (Anthropic API) – if you have >30 tools across your servers.
  7. Write strong server instructions – when tool search is active, instructions are the primary discovery signal.
  8. Add TTL caching to read-only tools – reduces repeated calls, which reduces total tokens spent on tool results across the conversation.
  9. Filter API responses before returning – return only the fields the LLM needs, not the full API response. Every field in a tool result is a token.

What to Check Right Now

  • Run the measurement script above on your MCP server. If any single tool exceeds 200 tokens, it needs trimming.
  • Check your Claude Code context budget – run /context in Claude Code and see how much of your context window is consumed by MCP tools.
  • Identify consolidation candidates – any group of 3+ tools with similar schemas is a consolidation opportunity.
  • Write server instructions if you haven’t – they are free (one-time cost) and they improve tool discovery accuracy for all clients.

nJoy 😉