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

> Source: <https://dev.to/robertpelloni/mcp-protocol-deep-dive-how-tool-discovery-actually-works-under-the-hood-13cf>
> Published: 2026-07-13 15:47:10+00:00

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.

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.

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.

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.

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.

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](https://tormentnexus.site)—explore our open-source MCP client SDKs and interactive playground for testing tool enumeration under real traffic.

*Originally published at tormentnexus.site*
