Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
106 NOTES IN PRINT
FOLIO XC 2026-05-13 · 8 MIN · LONG-FORM

Tools, Part 1: Discovery and the inputSchema Contract

What the model actually receives when it lists a server's tools, how the schema is built from a Go type, and why a tool's annotations cannot be trusted

Diagram · folio xc
flowchart LR
  C["client"] -->|"tools/list"| S["server"]
  S -->|"tools + inputSchema"| C
  C -->|"the JSON, verbatim"| M["model"]
  M -->|"picks a tool, fills args"| C
  C -->|"tools/call"| S

Discovery in MCP is one method, tools/list, and its response is the exact JSON the model reads to decide whether to call a tool and how to fill in its arguments. Not the Go behind it. This JSON. This post reads that response, shows how the schema inside it is generated from a Go type, rebuilds the same schema by hand to show what the generator does, and explains why the annotations attached to a tool are hints a client is required to distrust.

This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision 2025-11-25, with the official Go SDK at v1.6.1.

The first post listed a server’s tools and noted in passing that the result is what the agent sees. This post takes that seriously, because the quality of that JSON is the difference between a model that calls your tool correctly and one that guesses. The flow at the top of this post is the whole arc: the client lists tools, hands the schemas to the model unchanged, and the model picks one and fills its arguments from the schema alone.

§The Tool Object

A tools/list response is an array of tool objects. Each one has a small, fixed set of fields, and the spec pins down what each must contain:

  • name: the identifier the model calls. It should be 1 to 128 characters, case-sensitive, unique within the server, and limited to letters, digits, underscore, hyphen, and dot. No spaces. getUser, DATA_EXPORT_v2, and admin.tools.list are all valid.
  • title: an optional human-readable display name.
  • description: what the tool does, in prose the model reads.
  • inputSchema: a JSON Schema object describing the arguments. It must be a valid schema object and must not be null. A tool that takes no arguments uses {"type": "object", "additionalProperties": false}, not an omitted field.
  • outputSchema: an optional schema for the structured result, covered in the next post.
  • annotations: optional behavior hints, covered below.
  • icons and execution.taskSupport: optional display icons and a flag for whether the tool can run as a long-lived task, "forbidden" by default.

The schema language is JSON Schema, and if the schema carries no $schema field, it defaults to the 2020-12 draft. That default is the contract the model reads.

§inputSchema From a Go Type

In the SDK, you do not write that schema. You write a Go struct, and the SDK generates the schema from it. Here is a search_files tool whose input is three fields:

type SearchInput struct {
	Query         string `json:"query" jsonschema:"the text to search for"`
	MaxResults    int    `json:"max_results,omitempty" jsonschema:"how many matches to return"`
	CaseSensitive bool   `json:"case_sensitive,omitempty" jsonschema:"match case exactly"`
}

mcp.AddTool(server, &mcp.Tool{
	Name:        "search_files",
	Title:       "Search Files",
	Description: "Search the indexed files for a query string.",
	Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &openWorld},
}, search)

List that server and the search_files entry comes back like this, captured from a real tools/list:

{
  "name": "search_files",
  "title": "Search Files",
  "description": "Search the indexed files for a query string.",
  "annotations": { "readOnlyHint": true, "openWorldHint": false },
  "inputSchema": {
    "type": "object",
    "properties": {
      "query":          { "type": "string",  "description": "the text to search for" },
      "max_results":    { "type": "integer", "description": "how many matches to return" },
      "case_sensitive": { "type": "boolean", "description": "match case exactly" }
    },
    "required": ["query"],
    "additionalProperties": false
  }
}

Every part of that schema traces back to the struct. The mapping is mechanical, and worth knowing exactly, because it is the difference between the model seeing the contract you meant and one you did not.

In the Go struct
In the generated schema
json:"query"
the property name, "query"
jsonschema:"the text to search for"
the property's description
the Go type string, int, bool
"type": string, integer, boolean
a field with ,omitempty
left out of required (optional)
a field without it
listed in required
the struct as a whole
"type":"object", additionalProperties:false
an enum, minimum, or pattern
not expressible by tag, set the schema by hand

Two parts of that deserve a sentence. The ,omitempty on MaxResults and CaseSensitive is why they are absent from required: the model is told they are optional. And additionalProperties: false tells the model not to invent arguments the tool never declared, which heads off a whole class of hallucinated parameters.

§When the Tags Run Out, Write the Schema

The jsonschema tag is description-only in the current generator. It cannot express an enum, a minimum, or a regex pattern; try to put enum= in the tag and the SDK refuses it outright, reserving that syntax for later. For anything past names, types, and descriptions, you set the InputSchema field directly to a schema you wrote:

var manualSchema = map[string]any{
	"type": "object",
	"properties": map[string]any{
		"query": map[string]any{"type": "string", "description": "the text to search for"},
		"mode":  map[string]any{"type": "string", "enum": []string{"fuzzy", "exact"}, "description": "match strategy"},
	},
	"required":             []string{"query"},
	"additionalProperties": false,
}

mcp.AddTool(server, &mcp.Tool{Name: "search_files_manual", InputSchema: manualSchema}, handler)

That schema reaches the wire exactly as written, enum included:

"inputSchema": {
  "type": "object",
  "properties": {
    "query": { "type": "string", "description": "the text to search for" },
    "mode":  { "type": "string", "enum": ["fuzzy", "exact"], "description": "match strategy" }
  },
  "required": ["query"],
  "additionalProperties": false
}

The generated schema and the hand-written one are the same kind of object on the wire. The generator is a convenience for the common case, and the InputSchema field is the escape hatch for everything the tags do not reach. A precise enum here is worth more than a sentence in the description, because the model is far better at picking from a listed set than at inferring one from prose.

§Annotations Are Hints, and Hints Can Lie

The annotations object carries four behavior hints, and the search_files capture above set two of them. All four:

  • readOnlyHint: the tool does not modify anything. Default false.
  • destructiveHint: the tool may make destructive changes. Default true, and only meaningful when readOnlyHint is false.
  • idempotentHint: calling again with the same arguments changes nothing further. Default false.
  • openWorldHint: the tool touches an open set of external things, like a web search, rather than a closed one, like a calculator. Default true.

These exist so a client can build sensible behavior around a tool: auto-approve a read-only query, warn before a destructive one, skip a confirmation for an idempotent retry. Useful, and they are also the most dangerous fields in the object, because of one rule the spec states plainly: clients “MUST consider tool annotations to be untrusted unless they come from trusted servers.”

The reason is that the server supplies the annotations, and a hostile or compromised server can lie. A tool named cleanup can declare readOnlyHint: true and then delete everything it can reach. So annotations are a hint for the user interface, never a basis for a security decision. A client that auto-approves anything flagged read-only, on the word of a server it does not trust, has handed that server a switch to bypass its own confirmation step. The same caution applies to the description: it is model-facing text the server controls, and the security post later in the series shows what a server can do with that channel. For now, the line to hold is that everything in a tool object is a claim by the server, useful for display, never trusted for safety.

§Pagination and Change

Two details round out discovery. A server with many tools does not have to return them all at once: tools/list is paginated. The client sends an optional opaque cursor, and the result carries a nextCursor when more pages remain. The cursor is the server’s to define and the client’s to pass back unread.

And a tool list is not frozen. A server that declared the tools capability with listChanged: true, which the SDK does automatically, promises to send a notifications/tools/list_changed notification when its set of tools changes, so a long-lived client knows to call tools/list again rather than work from a stale catalog. That notification is a server-to-client message, the same direction as the sampling call from earlier in the series.

This is the surface txn2/mcp-data-platform exposes: tools like trino_query, datahub_search, and api_invoke_endpoint, each with a schema generated the way the one above was. Its default-deny personas filter that tools/list per user, so an analyst’s agent and an admin’s agent are handed different catalogs from the same server, and its admin portal can override a tool’s description without touching the code.

§What’s Next

Discovery tells the model what it can call and how. The next post, Tools, Part 2: Calling, Content Types, and Structured Output, is the other half: what comes back from tools/call. It reads the content array and its types, text, image, audio, resource links, and embedded resources, the structuredContent field validated against outputSchema, and the isError flag that lets a failed tool hand the model something it can recover from instead of a dead protocol error.


The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.

← back to all notes