Meta-tool contract
tools/list returns exactly three tools, whatever the catalog contains. The two SDKs publish the
same argument names, types and defaults. The key order of a published schema, and the dialect
decoration a generator writes around it — a $schema keyword, the numeric bounds of an integer —
are framework detail, not contract.
Source of truth. The search and card semantics are normative in
packages/http/spec/search-semantics.mdand the loaded tool's shape intool-definition.schema.json; where they differ from this page, they win.
The three tools
load_tool, search_tools, invoke_toolEvery one of them carries a _meta stamp naming the catalog generation it was listed for:
{ "sezzlee/catalogGeneration": 0 }When the catalog reloads, the number changes and the server sends notifications/tools/list_changed.
A client that caches tool definitions can compare the stamp instead of diffing them.
The number counts reloads inside one server process, and every process starts from the same value,
so it says nothing about whether two replicas hold the same catalog. To make sure a call lands on a
replica that agrees with what the agent read, pin the call to the tool's
version.
search_tools
{
"type": "object",
"properties": {
"query": {
"description": "Keywords matched by prefix against operation names, descriptions, declared search terms, routes, argument names and tag text; keywords rank results, they do not filter them. Empty lists everything. To require a whole tag, use tags.",
"type": "string",
"default": ""
},
"limit": {
"description": "Maximum number of results, 1-50.",
"type": "integer",
"default": 20
},
"detail": {
"description": "Shape of each result: \"card\" for the compact card, \"schema\" for the full definition load_tool would return. Any other value is card. A schema page is much larger; pair it with a small limit.",
"type": "string",
"enum": ["card", "schema"],
"default": "card"
},
"tags": {
"description": "Tags every result must carry, matched against the whole tag and insensitive to case and accents. Empty applies no filter; the answer's tags field lists what is available.",
"type": "array",
"items": { "type": "string" },
"default": null
}
}
}limit is clamped to 1..50 rather than rejected, so an out-of-range value returns results
instead of an error. detail is clamped the same way: anything that is not exactly schema —
absent, empty, misspelled, or Schema — is card.
tags is a conjunction: a result carries every tag in the list. Matching is on the whole tag after
the same folding search uses, so case and accents are ignored, but a tag is never split into words
— Order Notes is one tag and not two, and order does not match Orders. An empty list filters
nothing, and a tag nobody carries returns nothing rather than everything. Unlike limit, the list
is not clamped: dropping a tag would widen the answer, so a clamp would return operations the
caller did not ask for.
Its default is null rather than [] in both SDKs. A C# parameter default has to be a
compile-time constant and null is the only one an array type has, so the NestJS shape publishes
the same null to keep the two schemas identical; both mean "no filter".
The result is a total and a list of compact cards:
{
"total": 8,
"tags": ["billing", "orders"],
"results": [
{
"name": "create_order",
"description": "Creates a new order.",
"parameters": "item: string (required), quantity: integer"
},
{
"name": "get_order",
"description": "Fetches one order by id.",
"parameters": "id: integer (required)"
}
]
}total counts what the caller may see, before limit is applied. tags is the folded tag
vocabulary of that same visible set — the catalog, not the result set — so an agent that narrowed
to one tag still sees its siblings and can widen without an exploratory call. It is omitted when
the visible catalog carries no tags, and omitted rather than shortened when there are more than the
SDK will list, because a shortened list would say that a tag it left out does not exist. parameters is a one-line
rendering of the input schema's top level, not the schema — an agent that intends to call has to
load_tool first. Card descriptions are truncated at 160 characters on a word boundary.
A card gains authUncertain: true when visibility could not be resolved for that caller. The card
never carries policy names; see visibility decision.
Schemas for the results you searched
With detail: "schema" each entry of results is exactly what load_tool would return for that
tool, so an agent that expects to invoke one of these results calls invoke_tool next. The two are
not interchangeable in intent: detail: "schema" answers which of these matches do I want, while
load_tool answers what does this operation, whose name I already have, take. Reach for
load_tool when you hold a name and have nothing to search for:
{
"total": 8,
"results": [
{
"name": "get_order",
"description": "Fetches one order by id.",
"inputSchema": {
"type": "object",
"properties": { "id": { "type": "integer" } },
"required": ["id"]
},
"outputSchema": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"total": { "type": "number" }
}
},
"annotations": { "readOnlyHint": true, "idempotentHint": true }
}
]
}It is a trade, not a free win: a page of schemas is far larger than a page of cards, and detail
changes nothing else — ranking, limit, total and visibility filtering are what they were. At
the default limit a schema page will usually exceed the response budget, and the refusal names
detail alongside query and limit so the next call can drop back to cards rather than only
shrink the page. limit keeps one meaning: no smaller cap applies in schema mode.
load_tool
Takes name, exactly as search_tools returned it.
{
"name": "get_order",
"description": "Fetches one order by id.",
"inputSchema": {
"type": "object",
"properties": { "id": { "type": "integer", "description": "Order id" } },
"required": ["id"],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"item": { "type": "string" },
"quantity": { "type": "integer" },
"owner": { "type": "string" }
},
"required": ["id", "item", "quantity", "owner"]
},
"annotations": { "readOnlyHint": true, "idempotentHint": true },
"version": "6361f59c4d6b968c"
}version is a short fingerprint of everything above it except authUncertain. The same definition
gets the same version on every replica running the same build, and any change to what the agent
reads — the description, an argument, the output schema, a hint — gives a new one. Treat it as an
opaque string: it is meant to be passed back to invoke_tool, not parsed.
search_tools with detail: "schema" carries it too, because each of those results is the
load_tool answer.
inputSchema is always a flat object: path, query, header and body members are all top-level
arguments. How a backend type becomes that schema is
schema conversion.
outputSchema is what the call returns, so an agent can plan a chain of calls before making the
first one. It is present only when the endpoint declares a success body: the status codes 200,
201, 202 and 204 are tried in that order, then the lowest remaining 2xx, and a 204 or an
endpoint with no 2xx at all publishes no outputSchema. A response whose root is not an object —
an array, a scalar — is wrapped as {"type":"object","properties":{"result":…},"required":["result"]},
because MCP requires the root to be an object.
Unlike inputSchema, a response schema keeps read-only members: a get-only property is a response
field precisely because the server is the one that computes it. Curation does not reach
outputSchema either, so every variant of one operation publishes the same one. Where the
declaration comes from is
telling the agent what a tool returns.
annotations carries only the hints that apply — the field is omitted rather than filled with
false. load_tool is subject to visibility: a tool the caller cannot see returns unknown_tool,
so a hidden tool and a nonexistent one are indistinguishable. auth is never included.
invoke_tool
Takes name and arguments, an object whose keys are the input schema's properties. The call runs
through the backend's own pipeline with the caller's identity.
arguments is published with a description and no type, so a value that is not an object reaches
the SDK and comes back as an sezzlee envelope instead of a protocol error. Two shapes are accepted
anyway: null composes as {}, and a string that parses to a JSON object is unwrapped and composed
as that object, with the rewrite written to the server's log. Anything else — a string that is not
JSON, an array, a scalar — is invalid_type, and the message names the kind that arrived. The flag
stays retryable: false: it describes replaying the same call, not repairing it.
version is optional. Pass the version that load_tool returned, and the call runs only if the
replica that answers holds that same definition. During a rolling deploy an agent can load a tool
from a new replica and have the call answered by an old one; with version set, that call is
refused with tool_changed before it reaches your backend, and the agent loads the tool again.
Without version, nothing is checked. A name that does not exist is unknown_tool whatever
version says.
{ "name": "get_order", "arguments": { "id": 1 }, "version": "6361f59c4d6b968c" }Success returns the HTTP result:
{
"status": 200,
"body": {
"id": 1,
"item": "mechanical keyboard",
"quantity": 2,
"owner": "alice"
}
}Failure returns an error envelope with isError set. Both layers of that are on
invoke result envelope.
The envelope every result shares
Results are always JSON serialized into a single text content block:
const payload = JSON.parse(result.content[0].text);There is no structuredContent and no multi-block result. isError is true on failure and
absent on success — test it for truthiness, not for false.
Sessions
The transport is Streamable HTTP and there are no sessions: protocol revision 2026-07-28 removed
them along with the Mcp-Session-Id header, so every request is served on its own and GET and
DELETE are answered with 405. There is no session-mode option on either SDK.
A 2025-era client is still served, per request, through the same endpoint. What it does not get is a
server-to-client channel: catalogue changes reach a 2026-07-28 client that opened a
subscriptions/listen stream, and nothing is sent to a client that opened none.