mcp tools/list: The Field-Level Reference [288 Tools]
Every field a tools/list response returns, what the spec requires, and how often each one actually appears across 288 tools on 14 live MCP servers.
7 min read
tools/list is the JSON-RPC method an MCP client calls to discover what a server can do. It returns an array of tool objects, each carrying a name, an inputSchema, and a set of optional fields. Only two of those are load-bearing in practice, and the gap between what the spec permits and what servers actually send is wide.
This page documents both. Every field is checked against the 2025-11-25 specification [1], and every frequency figure comes from a single run against 14 live public MCP servers on 5 August 2026, which returned 288 tools between them.
TL;DR:
nameandinputSchemaare the only fields you can rely on. Both appeared on 288 of 288 tools in our sample.titleis nearly absent in the wild: 10% of tools carried one, despite being the field designed for display.annotationsis far more common than you would guess at 91%, and the spec says clients "MUST consider tool annotations to be untrusted unless they come from trusted servers" [1].- Nothing in our sample paginated. Zero of 14 servers returned a
nextCursor, which is exactly why the pagination bug is easy to ship and hard to notice.
The request
A tools/list request is an ordinary JSON-RPC call. The cursor parameter is optional and omitted on the first page.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
Over Streamable HTTP this is a POST to the server's MCP endpoint, and it must carry the protocol version negotiated during initialize, plus the session id if the server issued one:
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2025-11-25
Mcp-Session-Id: 1868a90c...
The response, field by field
The result is an object with a tools array and an optional nextCursor. Each entry describes one tool.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "get_weather",
"title": "Weather Information Provider",
"description": "Get current weather information for a location",
"inputSchema": {
"type": "object",
"properties": {
"location": { "type": "string", "description": "City name or zip code" }
},
"required": ["location"]
}
}
],
"nextCursor": "next-page-cursor"
}
}
Here is every field the spec defines, next to how often it actually turned up across our 288 tools:
| Field | Spec status | Present in 288 tools | What it is |
|---|---|---|---|
name |
required | 100% | Unique identifier for the tool |
inputSchema |
required, MUST be a valid JSON Schema object (not null) |
100% | JSON Schema defining expected parameters |
annotations |
optional | 91% | Properties describing tool behaviour. Untrusted by default |
description |
optional in the schema, effectively mandatory in practice | see below | Human-readable description of functionality |
title |
optional | 10% | Human-readable display name |
outputSchema |
optional | 6% | JSON Schema for structured results |
icons |
optional | not observed | Array of icons for display |
execution.taskSupport |
optional, defaults to "forbidden" |
not observed | Whether the tool supports task-augmented execution |
Two things stand out. title is the field most servers skip, even though it exists purely to give humans something better than a snake_case identifier to read. And annotations is on nine tools in ten, which matters more than its optional status suggests, because the spec attaches an explicit warning to it: clients "MUST consider tool annotations to be untrusted unless they come from trusted servers" [1]. A tool that annotates itself as read-only is making a claim, not a guarantee.
On description
The spec lists description as a plain data field with no MUST attached, but it is the field that decides whether a tool is usable by a model at all. In our sample, every one of the 14 connected servers had at least one tool carrying a description. That is the same bar the submission gate applies: a server exposing entirely undocumented tools is not ready to list, because a tool nobody can describe is a tool no model can pick.
What do real tool names look like?
Short, snake_case, and well inside the spec's limits. The specification says names "SHOULD be between 1 and 128 characters", "SHOULD be considered case-sensitive", and that the only allowed characters should be ASCII letters, digits, underscore, hyphen and dot [1].
Across 288 tools:
| Convention | Share |
|---|---|
snake_case |
83% |
| single word or other | 11% |
kebab-case |
5% |
camelCase |
0% |
Name lengths ran from 3 to 36 characters, median 15. Nothing came close to the 128-character ceiling, and not one tool used camelCase despite the spec explicitly listing getUser as a valid example. If you are naming tools and want to match what clients and models have already seen most of, snake_case at roughly 15 characters is the centre of the distribution.
The pagination trap
tools/list supports pagination. When more tools remain, the result carries a nextCursor, and the client is expected to send it back to fetch the next page:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/list",
"params": { "cursor": "next-page-cursor" }
}
Zero of the 14 servers in our sample paginated. That is precisely what makes this dangerous. A client that reads only the first page works perfectly against every server you are likely to test against, then silently under-reports the moment it meets a large one. The failure is invisible: you do not get an error, you get a smaller number.
For anything that publishes a tool count, that undercount is not a rounding error, it is a wrong fact printed next to someone else's product. Our own tester follows nextCursor to the end and aborts with an explicit error rather than truncating if a server paginates past a configured page limit:
do {
if (++$pages > $maxPages) {
throw new McpTransportException('The server paginated its tool list past the page limit.');
}
$page = $client->post($url, [
'jsonrpc' => '2.0',
'id' => $requestId,
'method' => 'tools/list',
'params' => $cursor === null ? (object) [] : ['cursor' => $cursor],
], $headers, awaitId: $requestId, deadline: $deadline);
// ... collect $pageResult['tools'] ...
} while ($cursor !== null);
Loud failure beats a quiet undercount. A number we cannot stand behind does not get published.
Staying in sync after the first call
A server that declares the listChanged capability tells you its tool list can change while you are connected:
{ "capabilities": { "tools": { "listChanged": true } } }
When it does, the server "SHOULD send a notification" of method notifications/tools/list_changed [1], and the client is expected to call tools/list again. If you cache a tool list, this notification is the invalidation signal.
What we keep from a tools/list response
MCP Hunter records exactly what came back and nothing derived from it: whether the connection succeeded, the tool count, the tool names, whether any tool carried a description, one round trip in milliseconds, the negotiated protocol version, and the raw payload itself. Empty strings are normalised to null rather than treated as content, so a server that sends "description": "" is recorded as documenting nothing, which is what it did.
The raw response is stored deliberately. When a maker disputes a result, the payload settles it without anyone re-running a test whose answer may have changed since. That single stored exchange is what every listing on the weekly board is built from, and it is the reason a listing can only ever make a dated, past-tense claim about a server. We tested once, on the date shown. We do not re-test on a schedule, so nothing here describes a server's present state, and we say so plainly rather than implying otherwise with a badge.