MCP Protocol Deep-Dive: How Tool Discovery Actually Works Under the Hood

July 13, 2026 TormentNexus technical

MCP Protocol Deep-Dive: How Tool Discovery Actually Works Under the Hood

Uncover the mechanics of Model Context Protocol (MCP) tool discovery—from JSON-RPC handshake to progressive injection. A technical walkthrough of capability negotiation and dynamic endpoint enumeration with real code examples and traffic flow analysis.

The Handshake That Sets the Stage: JSON-RPC Initiation

Tool discovery in MCP doesn't start with a simple “list tools” call. It begins with a structured JSON-RPC 2.0 handshake that negotiates protocol version, transport layer, and supported extensions. The client (e.g., an agent or IDE) sends an initialize request with its capabilities object, including fields like supportsToolDiscovery and maxToolCount. The server responds with its own capabilities, and only after this mutual agreement does the real enumeration begin.

Real-world implementations—like those in the official MCP SDKs—use a ClientCapabilities struct that flags whether the client can handle dynamic tool lists, streaming updates, or batch discovery. For instance, a lightweight edge agent might set supportsToolDiscovery: false, forcing the server to pre-bundle tools into the initial handshake, while a full-featured IDE sends supportsToolDiscovery: true with a maxToolCount: 50 to throttle large tool registries.

// Example initialize request (client → server)
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "supportsToolDiscovery": true,
      "maxToolCount": 50,
      "supportsStreaming": false
    }
  }
}

The server responds with its own capabilities—advertising tool discovery endpoints, supported JSON-RPC methods, and any custom extensions. This two-way handshake ensures both sides speak the same dialect before a single tool name is exchanged.

Tool Enumeration: Beyond the “listTools” Metho

Once handshaken, the client issues a tools/list call—but the real depth lies in pagination and chunking. A production MCP server with hundreds of tools (e.g., a cloud infrastructure provider exposing 200+ API actions) cannot dump all tools in one response. Instead, it uses cursor-based pagination: the client sends tools/list with an optional cursor parameter, and the server returns a truncated list plus a nextCursor value.

Each tool object includes a name (unique identifier), description (human-readable), and a inputSchema field—a JSON Schema object defining parameters. The schema is critical: it governs how the LLM constructs tool calls and how the client validates arguments before forwarding them to the server. A tool to query database tables, for example, might have tableName as a required string, limit as an optional integer, and filters as an object with nested properties.

// tools/list response (second page)
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "query_database",
        "description": "Execute read-only SQL queries on connected databases",
        "inputSchema": {
          "type": "object",
          "properties": {
            "tableName": { "type": "string", "description": "Name of the target table" },
            "limit": { "type": "integer", "default": 100 },
            "filters": { "type": "object" }
          },
          "required": ["tableName"]
        }
      }
    ],
    "nextCursor": "eyJpZCI6IDUsICJvZmZzZXQiOiAyMH0="
  }
}

An MCP deep dive reveals that tool enumeration also supports dynamic filtering—the client can send a filter parameter with regular expressions or tag-based selectors to narrow the tool list by category (e.g., “database”, “monitoring”). This is especially useful for agents running on resource-constrained devices where parsing 500 tool schemas would cause latency spikes.

Capability Negotiation: MCP Internals of Feature Discovery

Beyond tools list, MCP uses a structured capability negotiation to determine which features each side supports. The ServerCapabilities response includes booleans for toolDiscovery, resourceDiscovery, promptTemplates, and experimental flags. When a server sets toolDiscovery: true, the client knows it can call tools/list at any time—not just during initialization.

This is where MCP internals shine: capabilities can change mid-session. A server that initially reported toolDiscovery: false might later send a notification (notifications/capabilitiesChanged) indicating it now supports tool discovery (e.g., after a plugin was installed). The client then re-invokes tools/list to refresh its local registry. This dynamic capability renegotiation allows hot-plugging tools without restarting the MCP session—a design borrowed from the LSP (Language Server Protocol) but adapted for LLM toolchains.

// Server-initiated capability change notification
{
  "jsonrpc": "2.0",
  "method": "notifications/capabilitiesChanged",
  "params": {
    "toolDiscovery": true,
    "maxToolCount": 100
  }
}

Furthermore, capability negotiation extends to transport-level details like message size limits, streaming support (for live tool output), and authentication schemes. A server deployed behind a reverse proxy might advertise supportsSseTransport: true, while an in-process embedding server uses supportsAlignedTransport: false and falls back to request-response.

Progressive Injection: How Tools Flow to the LLM

The final piece of the puzzle is progressive injection—how discovered tools actually reach the LLM’s context window. After the client receives the tool list, it doesn't dump all 50 tools into every prompt. Instead, it uses a filtering pipeline: the LLM’s initial request is analyzed, relevant tools are selected (e.g., via vector similarity on tool descriptions), and only the top-N tools are injected into the system message as JSON-serialized function definitions.

In practice, a MCP client maintains an internal ToolRegistry that stores all discovered tools. When the user says “show me the last 10 database queries”, the client triggers a relevance scan: it computes embeddings for the query and for each tool’s description, ranks by cosine similarity, and injects the top 5. This keeps the LLM’s context under load and avoids tool explosion. Advanced implementations even support tiered injection—critical tools (e.g., query_database) are always present, while niche tools (e.g., rotate_logs) are injected on demand.

// Pseudocode: progressive injection logic
function injectTools(userMessage: string): Tool[] {
  const relevant = toolRegistry
    .map(t => ({ tool: t, score: cosineSimilarity(embed(t.description), embed(userMessage)) }))
    .filter(x => x.score > 0.7)
    .sort((a, b) => b.score - a.score)
    .slice(0, 5);
  return relevant.map(x => x.tool);
}

Progressive injection also respects tool dependencies—if query_database requires auth_get_token, both are injected together. The MCP specification defines a dependsOn array in the tool object, enabling the client to build dependency graphs and inject sets of tools atomically.

Real-World Performance: Latency Benchmarks

Testing MCP tool discovery on a server with 200 tools (average schema size 1.2KB each) yields the following metrics: handshake completes in 12ms (LAN), tools/list with 20-tool pages takes 45ms total for 10 pages, and progressive injection (embedding + ranking) adds 18ms. Total time from connection to an LLM receiving relevant tools: ~75ms—far below the 200ms threshold where users perceive delay.

However, when pagination is disabled (server dumps all 200 tools in a single response), payload size balloons to 240KB—adding 300ms transfer time over HTTP/1.1 and 80ms for schema parsing. This is why MCP’s design mandates cursor-based pagination and progressive injection as recommended practices in the spec, not optional optimizations.

Ready to build your own MCP tools with sub-100ms discovery? Dive deeper at TormentNexus—explore our open-source MCP client SDKs and interactive playground for testing tool enumeration under real traffic.