MCP Protocol Internals · Lesson 2

Listing & Calling Tools

The two methods the whole agent economy runs on — tools/list and tools/call — decoded from real traffic.

In Lesson 1 you opened a session. Everything below reuses that same open session: the Mcp-Session-Id and MCP-Protocol-Version headers ride along on every request, but we'll stop drawing them so you can focus on the payloads. Two methods do all the work.

The mental model

A tool call is a two-step conversation. tools/list asks "what can you do, and what shape are the arguments?" — the server answers with machine-readable schemas. tools/call then says "do this one with these arguments." The schema from step one is what lets a model fill in step two correctly.spec

1 · tools/list — discovery

The request is almost nothing — just the method:

POST /mcp { "jsonrpc":"2.0","id":2,"method":"tools/list" }

The reply is a result with a tools array. Here is one real entry in full — the echo tool — because this object is the entire contract a model needs to call it:

{ "name": "echo", ← the id you pass to tools/call "title": "Echo Tool", ← human label (optional) "description": "Echoes back the input string", ← what the model reads to decide "inputSchema": { ← JSON Schema: the argument contract "type": "object", "properties": { "message": { "type": "string", "description": "Message to echo" } }, "required": ["message"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, "execution": { "taskSupport": "forbidden" } ← can't be run as a long async task }
inputSchema is the load-bearing field

It's a JSON Schema object describing the arguments: their types, which are required, and (here) additionalProperties: false meaning "no extra keys allowed." This is how an LLM knows to send {"message": "..."} and not guess. If no $schema is given, MCP assumes JSON Schema draft 2020-12; this server pins draft-07 explicitly.spec

Pagination: tools/list may return a nextCursor string. If present, send it back as params.cursor to fetch the next page. Our server's list is short, so no cursor appears — but real servers with hundreds of tools page this way.

Our server returned 13 tools. Their names alone:

[ "echo", "get-sum", "get-env", "get-structured-content", "get-tiny-image", "get-annotated-message", "get-resource-links", "get-resource-reference", "gzip-file-as-resource", "toggle-simulated-logging", "toggle-subscriber-updates", "trigger-long-running-operation", "simulate-research-query" ]

2 · tools/call — invocation

To run one, name it and pass arguments that satisfy its inputSchema:

POST /mcp { "jsonrpc":"2.0","id":3,"method":"tools/call", "params": { "name":"get-sum", "arguments":{ "a":7, "b":5 } } } ── result ── { "result": { "content": [ { "type":"text", "text":"The sum of 7 and 5 is 12." } ] }, "jsonrpc":"2.0","id":3 }

The result is a content array. It's an array because one call can return several blocks of mixed media — each block has a type: text, image, audio, resource_link, or an embedded resource.spec This is the "unstructured" channel — text and media meant for a human or the model to read.

3 · Structured results — structuredContent + outputSchema

Text is fine for a model to read, but a program wants typed data. A tool can declare an outputSchema and return a parallel structuredContent object. Watch get-structured-content return both — the same data twice:

POST /mcp { "jsonrpc":"2.0","id":4,"method":"tools/call", "params": { "name":"get-structured-content", "arguments":{ "location":"Chicago" } } } ── result ── { "result": { "content": [ { "type":"text", "text":"{\"temperature\":36,\"conditions\":\"Light rain / drizzle\",\"humidity\":82}" } ], "structuredContent": { ← typed, machine-readable, validates against outputSchema "temperature": 36, "conditions": "Light rain / drizzle", "humidity": 82 } }, "jsonrpc":"2.0","id":4 }
Why the data appears twice

The structuredContent object is the typed answer; the content text block is the same JSON serialized as a string. The spec says a tool returning structured content SHOULD also mirror it as text, so older clients that only read content still work. If the tool declared an outputSchema, the server MUST make structuredContent conform to it, and clients SHOULD validate it.spec

4 · Two kinds of failure — the channel matters

This is the subtlest, most useful part of the whole tools model. MCP has two separate error channels, and which one fires changes what an agent should do.spec

(a) Tool execution error → a normal result with isError: true

The call reached the tool, but the tool failed. It comes back as a successful JSON-RPC result — just with isError: true and an explanatory text block the model can read and retry against. On this server, even an unknown tool name and a type mismatch arrive this way:

// called get-sum with a:"oops" (a string, not a number) { "result": { "content": [ { "type":"text", "text":"MCP error -32602: Input validation error: … \"expected\":\"number\",\"received\":\"string\",\"path\":[\"a\"] …" } ], "isError": true ← still a result, id matches, model can fix & retry }, "jsonrpc":"2.0","id":6 }

(b) Protocol error → a top-level JSON-RPC error

The request itself was structurally wrong — here, an unknown method (not an unknown tool). There is no result at all; instead a JSON-RPC error object with a numeric code. A model usually can't "retry" its way out of this — it's a wiring bug:

// called method "tools/frobnicate" — no such method { "jsonrpc":"2.0","id":7, "error": { "code": -32601, "message": "Method not found" } } ← no result key at all
Spec model vs. this implementation

The spec lets a server report an unknown tool as a protocol error. But this server (like most SDKs) deliberately funnels unknown-tool and bad-argument failures into the isError: true result channel — so the model gets a readable message and can self-correct, rather than a dead-end error. The reliable rule when reading traffic: a top-level error key = protocol failure; a result with isError = the tool ran and failed. Don't judge by the -32602 number — judge by which key it's under.

🔧 Try it yourself (≈ 5 minutes)

Session still open from Lesson 1 (re-run bash assets/mcp-curl.sh init if needed). Use the helper's call verb — call NAME '<args-json>':

bash assets/mcp-curl.sh list # discovery bash assets/mcp-curl.sh call get-sum '{"a":40,"b":2}' # a clean call bash assets/mcp-curl.sh call get-structured-content '{"location":"New York"}' # structured bash assets/mcp-curl.sh call get-sum '{"a":"nope","b":2}' # force isError:true

Predict before you run each one: will the failure come back under result.isError or under a top-level error? Then try call get-env '{}' and read what a server can expose — a nice preview of why the auth lessons matter.

Check yourself

What is the inputSchema on a listed tool actually for?
A reply has a result object containing isError: true. What happened?
Why does get-structured-content return the same data in two places?
Primary source — read this next

MCP Specification 2025-11-25 — Server / Tools. Read the "Tool Result" and "Error Handling" sections: you've now seen content, structuredContent, outputSchema, and both error channels on the wire — the page formalises exactly what you observed.