Skip to content

Instantly share code, notes, and snippets.

@terrhorn
Created March 11, 2026 15:08
Show Gist options
  • Select an option

  • Save terrhorn/71e28f32d0f6b4bdcae35ecf9227a665 to your computer and use it in GitHub Desktop.

Select an option

Save terrhorn/71e28f32d0f6b4bdcae35ecf9227a665 to your computer and use it in GitHub Desktop.
MCP Tool Call Flow: Specification → SDK → Server

MCP Tool Call Flow: Specification → SDK → Server

How a tool call flows through the Model Context Protocol, traced across the specification, TypeScript SDK, and reference server implementations.


Phase 1: Capability Negotiation (Initialization)

During initialize, the server declares it supports tools:

{ "capabilities": { "tools": { "listChanged": true } } }

The client notes this capability and knows it can call tools/list and tools/call. The listChanged flag indicates whether the server will send notifications/tools/list_changed when its tool set changes at runtime.

Spec reference: docs/specification/draft/server/tools.mdx


Phase 2: Discovery (tools/list)

Client ──► Server:  { method: "tools/list" }
Server ──► Client:  { tools: [{ name, description, inputSchema, outputSchema?, annotations? }] }

Each Tool object includes:

Field Required Purpose
name Programmatic identifier (1–128 chars, case-sensitive)
title Human-readable display name
description What the tool does (for LLM and human consumption)
inputSchema JSON Schema defining expected arguments
outputSchema JSON Schema for structured results
annotations Behavioral hints (see below)
execution Task execution support configuration

Tool Annotations

Annotations are hints only — clients must not make security decisions based on untrusted server annotations.

interface ToolAnnotations {
  readOnlyHint?: boolean;      // Doesn't modify environment
  destructiveHint?: boolean;   // May perform destructive updates
  idempotentHint?: boolean;    // Safe to call repeatedly
  openWorldHint?: boolean;     // Interacts with external entities
}

Pagination

tools/list supports cursor-based pagination via PaginatedRequest / PaginatedResult for servers with large tool sets.

Schema reference: schema/draft/schema.tsListToolsRequest (line ~1507), ListToolsResult (line ~1519), Tool (line ~1730)


Phase 3: Invocation (tools/call)

This is the core flow. The sequence below traces a tool call from the LLM's decision through the full protocol stack.

Sequence Diagram

sequenceDiagram
    participant LLM
    participant Client
    participant Server

    LLM->>Client: Select tool to use
    Client->>Server: tools/call { name, arguments }

    Note over Server: Protocol layer: _onrequest()
    Note over Server: Lookup handler in _requestHandlers map
    Note over Server: Validate request against CallToolRequestSchema
    Note over Server: Find registered tool by name
    Note over Server: Validate arguments against inputSchema
    Note over Server: Execute tool handler callback
    Note over Server: Validate result against outputSchema

    Server-->>Client: CallToolResult { content, structuredContent?, isError? }

    Note over Client: Validate structuredContent against outputSchema
    Client->>LLM: Process result
Loading

Wire Format

Request:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": {
      "location": "New York"
    }
  }
}

Successful response:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy"
      }
    ],
    "isError": false
  }
}

Content Types in Results

CallToolResult.content is an array of content blocks. Supported types:

  • TextContent — plain text results
  • ImageContent — base64-encoded images
  • AudioContent — base64-encoded audio
  • ResourceLink — links to external resources
  • EmbeddedResource — full resource with URI and content

Schema reference: schema/draft/schema.tsCallToolRequest (line ~1619), CallToolResult (line ~1549)


Phase 4: Dynamic Updates

Servers can notify clients when their tool set changes at runtime:

Server ──) Client:  { method: "notifications/tools/list_changed" }
Client ──► Server:  { method: "tools/list" }  (re-fetches)

This enables servers that dynamically add/remove tools (e.g., based on user permissions or loaded plugins).

Schema reference: schema/draft/schema.tsToolListChangedNotification (line ~1632)


Two-Level Error Model

MCP distinguishes between protocol errors and tool execution errors:

Level When Example
Protocol error (JSON-RPC) Unknown tool, malformed request { error: { code: -32602, message: "Unknown tool" } }
Tool execution error (in result) Tool ran but failed { content: [{ type: "text", text: "..." }], isError: true }

Why two levels? Tool execution errors are returned as results (not JSON-RPC errors) so LLMs can see the failure message and self-correct with adjusted parameters. Protocol errors indicate structural problems the LLM is unlikely to fix.

Example tool execution error:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Invalid departure date: must be in the future."
      }
    ],
    "isError": true
  }
}

TypeScript SDK Implementation Layers

Source: modelcontextprotocol/typescript-sdk

Architecture

┌─────────────────────────────────────────────────────┐
│  McpServer  (high-level API)                        │
│  packages/server/src/server/mcp.ts                  │
│  • registerTool() — user-facing registration        │
│  • Input/output validation pipeline                 │
│  • Tool lookup by name                              │
├─────────────────────────────────────────────────────┤
│  Server  (protocol implementation)                  │
│  packages/server/src/server/server.ts               │
│  • setRequestHandler() override for tools/call      │
│  • Schema validation wrapper                        │
│  • Task vs non-task result handling                 │
├─────────────────────────────────────────────────────┤
│  Protocol  (base class)                             │
│  packages/core/src/shared/protocol.ts               │
│  • JSON-RPC dispatch (_onrequest)                   │
│  • Handler registry (_requestHandlers map)          │
│  • Capability assertion                             │
├─────────────────────────────────────────────────────┤
│  Transport  (stdio, SSE, StreamableHTTP)            │
│  packages/core/src/shared/transport.ts              │
│  • Wire-level message sending/receiving             │
└─────────────────────────────────────────────────────┘

Server-Side: Registering a Tool

McpServer.registerTool() accepts a name, config (with Zod schemas), and a callback:

registerTool(
  name: string,
  config: {
    title?: string;
    description?: string;
    inputSchema?: ZodSchema;
    outputSchema?: ZodSchema;
    annotations?: ToolAnnotations;
  },
  cb: ToolCallback
): RegisteredTool

Under the hood, _initializeToolHandlers() registers two protocol handlers:

  • tools/list — iterates registered tools, converts Zod schemas to JSON Schema
  • tools/call — looks up tool by name, validates input, executes callback, validates output

Server-Side: Request Dispatch Pipeline

When a tools/call message arrives:

  1. Protocol._onrequest() — looks up handler in _requestHandlers map
  2. Server.setRequestHandler() wrapper — validates request against CallToolRequestSchema, validates result against CallToolResultSchema (or CreateTaskResultSchema for task-based execution)
  3. McpServer handler — finds tool by name, validates input with validateToolInput(), calls executeToolHandler(), validates output with validateToolOutput()
  4. Errors caught → wrapped as { content: [...], isError: true }

Client-Side: Calling a Tool

Client.callTool() sends the request and validates the response:

async callTool(params: CallToolRequest['params'], options?: RequestOptions) {
    const result = await this._requestWithSchema(
        { method: 'tools/call', params },
        CallToolResultSchema,
        options
    );

    // Validate structuredContent against tool's outputSchema if present
    const validator = this.getToolOutputValidator(params.name);
    if (validator && !result.isError) {
        const validationResult = validator(result.structuredContent);
        if (!validationResult.valid) throw new ProtocolError(...);
    }
    return result;
}

Client-Side Middleware

The SDK provides composable fetch middleware for HTTP transports:

  • OAuth middleware (withOAuth) — attaches bearer tokens, handles 401 retry
  • Logging middleware (withLogging) — request/response timing
  • Custom middleware — any (next: FetchLike) => FetchLike function
const transport = new StreamableHTTPClientTransport({
    url: 'https://api.example.com',
    fetchMiddleware: applyMiddlewares(withOAuth(provider), withLogging())
});

Source: packages/client/src/client/middleware.ts


Reference Server Patterns

Source: modelcontextprotocol/servers

TypeScript Pattern: server.registerTool()

The filesystem server registers tools with Zod schemas and annotations:

const ReadTextFileArgsSchema = z.object({
  path: z.string(),
  tail: z.number().optional(),
  head: z.number().optional()
});

server.registerTool(
  "read_text_file",
  {
    title: "Read Text File",
    description: "Read the complete contents of a file from the file system.",
    inputSchema: { path: z.string(), tail: z.number().optional(), head: z.number().optional() },
    outputSchema: { content: z.string() },
    annotations: { readOnlyHint: true }
  },
  async (args) => {
    const validPath = await validatePath(args.path);
    const content = await readFileContent(validPath);
    return {
      content: [{ type: "text" as const, text: content }],
      structuredContent: { content }
    };
  }
);

Python Pattern: Decorator-Based Registration

The fetch server uses @server.list_tools() and @server.call_tool() decorators with Pydantic for validation:

class Fetch(BaseModel):
    url: AnyUrl = Field(description="URL to fetch")
    max_length: int = Field(default=5000)
    start_index: int = Field(default=0)
    raw: bool = Field(default=False)

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [Tool(name="fetch", description="...", inputSchema=Fetch.model_json_schema())]

@server.call_tool()
async def call_tool(name, arguments: dict) -> list[TextContent]:
    args = Fetch(**arguments)  # Pydantic validation
    content, prefix = await fetch_url(str(args.url))
    return [TextContent(type="text", text=f"{prefix}\n{content}")]

Common Handler Structure

Regardless of language, all reference servers follow the same pattern:

  1. Validate inputs — parse and validate against schema
  2. Normalize/authorize — check paths, permissions, robots.txt, etc.
  3. Execute — perform the actual operation
  4. Format output — return CallToolResult with content blocks

Advanced: Sampling with Tools (Agentic Context)

Tools also participate in the Sampling API, where servers request LLM completions through the client. The server can include tool definitions in a sampling/createMessage request, enabling multi-turn tool use loops:

sequenceDiagram
    participant Server
    participant Client
    participant User
    participant LLM

    Server->>Client: sampling/createMessage (messages + tools)
    Client->>User: Present request for approval
    User-->>Client: Approve
    Client->>LLM: Forward request with tools
    LLM-->>Client: Response with tool_use (stopReason: "toolUse")
    Client->>User: Present tool calls for review
    User-->>Client: Approve
    Client-->>Server: Return tool_use response

    Note over Server: Execute tool(s) locally

    Server->>Client: sampling/createMessage (history + tool_results + tools)
    Client->>LLM: Forward with tool results
    LLM-->>Client: Final text response (stopReason: "endTurn")
    Client-->>Server: Return final response
Loading

Key difference: In this flow, the server executes tools locally and manages the multi-turn loop. The client maintains human oversight and LLM access control.

Spec reference: docs/specification/draft/client/sampling.mdx


Security Considerations

Server Responsibilities

  • Validate all tool inputs against schema
  • Implement proper access controls
  • Rate limit tool invocations
  • Sanitize tool outputs

Client Responsibilities

  • Prompt for user confirmation on sensitive operations
  • Show tool inputs to user before calling server
  • Validate tool results before passing to LLM
  • Implement timeouts for tool calls
  • Log tool usage for audit purposes

Trust Model

  • Tool annotations are hints only — not security boundaries
  • Human-in-the-loop should be default, especially with untrusted servers
  • Clear UI should indicate which tools are being invoked

Key Files Reference

Component Location
Tool specification docs/specification/draft/server/tools.mdx
Schema types schema/draft/schema.ts (lines ~1500–1800)
Request/response examples schema/draft/examples/CallToolRequest/ etc.
SDK Protocol base typescript-sdk: packages/core/src/shared/protocol.ts
SDK Server (low-level) typescript-sdk: packages/server/src/server/server.ts
SDK McpServer (high-level) typescript-sdk: packages/server/src/server/mcp.ts
SDK Client typescript-sdk: packages/client/src/client/client.ts
SDK Client middleware typescript-sdk: packages/client/src/client/middleware.ts
Filesystem server (TS) servers: src/filesystem/index.ts
Fetch server (Python) servers: src/fetch/src/mcp_server_fetch/server.py
Sampling specification docs/specification/draft/client/sampling.mdx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment