MCP for outboundReference

Our MCP Server Returns 40 Tools and 0 Tool Annotations

An in-process tools/list probe of our own MCP server: 0 of 40 tools carry ToolAnnotations. What the documented defaults then make a client assume.

What we ranAn in-process tools/list probe of Kairon's own 40-tool MCP server at commit e026b4e7 on 2026-07-21, auditing every returned tool against all four ToolAnnotations hints — 0/40 carried any — plus the spec-documented defaults a client applies in their absence.

We issued a real tools/list against our own MCP server on 2026-07-21 and audited every tool it returned. 0 of 40 carry a ToolAnnotations object. No readOnlyHint, no destructiveHint, no idempotentHint, no openWorldHint. The finding is not the missing flags. It is what the documented defaults make a client assume in their absence.

The probe

Not a mock and not a fixture. We built the real server in-process and spoke MCP over the SDK's InMemoryTransport, so what came back is the same list a connected client receives. The counting half of the probe is below, and it produces the output block under it exactly; two further dumps — the raw envelope of one tool and the full name list — appear later in this post. Drop it in your own API package and run it with tsx.

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createMcpServer, type McpToolDeps } from '../src/mcp/mcp-server.js';

const principal = { organizationId: 'org_probe', ownerUserId: 'user_probe' };
const deps = new Proxy({}, { get: () => () => () => Promise.resolve({}) }) as McpToolDeps;

async function main(): Promise<void> {
  const server = createMcpServer(deps, principal);
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  const client = new Client({ name: 'annotations-probe', version: '1.0.0' });
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
  const { tools } = await client.listTools();
  console.log(`tools returned: ${tools.length}`);
  console.log('SDK: @modelcontextprotocol/sdk 1.29.0\n');

  const HINTS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const;
  for (const hint of HINTS) {
    const n = tools.filter((t) => (t.annotations as Record<string, unknown> | undefined)?.[hint] !== undefined).length;
    console.log(`  with annotations.${hint.padEnd(15)} ${n} / ${tools.length}`);
  }
  const any = tools.filter((t) => t.annotations).length;
  console.log(`\ntools with any annotations object at all: ${any} / ${tools.length}`);
  await client.close();
}
await main();
tools returned: 40
SDK: @modelcontextprotocol/sdk 1.29.0

  with annotations.readOnlyHint    0 / 40
  with annotations.destructiveHint 0 / 40
  with annotations.idempotentHint  0 / 40
  with annotations.openWorldHint   0 / 40

tools with any annotations object at all: 0 / 40

Same method as the two-minute check that found a dead jwks_uri in this server's OAuth discovery chain: ask your own server the question a client would ask, then read what comes back instead of what you remember writing.

Where the omission lives

apps/api/src/mcp/tool-catalogue.ts:42-50:

server.registerTool(
  tool.name,
  { title: tool.title, description: tool.description, inputSchema: tool.inputSchema },
  async (input) => ({ ... }),
);

Three fields go through. McpToolDef in the same file (lines 11-17) has no annotations member, so no domain catalogue can supply one even if it wanted to. It is one file's omission, not an architectural problem.

The SDK we already depend on supports it. registerTool's config accepts annotations?: ToolAnnotations (dist/esm/server/mcp.d.ts:155), and ToolAnnotationsSchema (dist/esm/types.d.ts:2361) declares all four hints. apps/api/package.json:53 declares ^1.29.0 and it resolved to 1.29.0. No upgrade needed. That is the least comfortable part of the audit: nothing blocked this.

The defaults are the story

The defaults are not an SDK invention. They are written into the spec's own schema/2025-06-18/schema.ts, on the ToolAnnotations interface, and the SDK's ToolAnnotationsSchema (dist/esm/types.js:1173) mirrors them comment for comment:

hintdoc commentdefault
readOnlyHint"If true, the tool does not modify its environment."false
destructiveHint"If true, the tool may perform destructive updates to its environment. If false, the tool performs only additive updates. (This property is meaningful only when readOnlyHint == false)"true
idempotentHint"If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment."false
openWorldHint"If true, this tool may interact with an 'open world' of external entities."true

Put our 0/40 against that table. A client reading the documented defaults should treat every one of our 40 tools as readOnlyHint: false, destructiveHint: true, openWorldHint: true. profile_fetch arrives carrying exactly the same behavioural profile as inmail_send.

So the omission does not fail open. On the documented defaults it fails toward over-warning, and that has a cost. We tested no client to see which ones materialise those defaults rather than prompting uniformly regardless — this is what the spec says a client should assume, not a measurement of shipped behaviour. The spec's trust-and-security guidance says there "SHOULD always be a human in the loop with the ability to deny tool invocations", that applications SHOULD "Present confirmation prompts to the user for operations, to ensure a human is in the loop", and that clients SHOULD "Prompt for user confirmation on sensitive operations". A client following that guidance against our metadata has one honest option: prompt on all 40. From there the argument is habituation, and we are reasoning rather than measuring: a prompt that fires on every call carries no information, and the user who clears forty of them a day is not reading the forty-first. If that holds, undifferentiated warnings and no warnings converge on the same behaviour. We have not measured it here.

We wrote the warning where a client cannot act on it

Here is inmail_send exactly as tools/list returned it, input schema elided:

{
  "name": "inmail_send",
  "title": "Send an InMail",
  "description": "Send an InMail (optional subject). Address the recipient with `target` — `{ type, value }` (url, public_identifier, or provider_id). Requires the Sales Navigator seat and a live InMail credit; refused fast otherwise. This contacts a real person and consumes a credit — there is no human approval step.",
  "inputSchema": { "...": "elided" },
  "execution": { "taskSupport": "forbidden" }
}

Read what that envelope actually says. The consequential facts — contacts a real person, consumes a credit, no human approval step — live in description. That is free text aimed at the model. The envelope carries an execution object and no annotations object at all, so the machine-readable field a client could gate a confirmation prompt on carries nothing. We wrote the warning in the one place a client cannot act on programmatically.

The same shape shows up on the way out: when a call to this server fails, the error collapses into one isError flag with the reason in prose — missing metadata on the way in, unstructured errors on the way out.

Five of the 40 mutate state on an external platform: invite_send, message_send, message_edit, message_delete, inmail_send. They are not equally reversible, and our own catalogue says so if you read it. Two of the five are the catalogue's own undo operations: message_edit replaces the body of a sent message, message_delete retracts one, both addressed by the id that message_send returned. So a message_send can be walked back by a later call. Three cannot be: invite_send, because no invitation-withdrawal tool exists among the 40; inmail_send, which by its own description consumes a credit; and message_delete itself.

That distinction is exactly what destructiveHint and idempotentHint exist to carry, and we ship none of it. The sharpest example is message_delete, whose description ends with the word "Idempotent." The tool states its own idempotency in prose, to the model, and leaves idempotentHint empty.

The full list, in registration order:

profile_fetch, company_fetch, profile_posts, search_people, search_companies,
search_posts, filter_resolve, search_sales_navigator, invite_send, message_send,
message_edit, message_delete, inmail_send, icp_create, icp_revise, icp_list,
icp_get, company_load_details, company_add, company_qualify, company_list,
evidence_search, lead_add, lead_load_profile, lead_list, lead_get,
leadlist_create, leadlist_list, leadlist_get, leadlist_update, leadlist_delete,
leadlist_add_leads, leadlist_remove_leads, companylist_create, companylist_list,
companylist_get, companylist_update, companylist_delete,
companylist_add_companies, companylist_remove_companies

The other 35 need classifying one at a time, against what each one actually writes. Guessing in bulk is what produced the noise this post is about.

The limit worth stating

Annotations are hints, and the spec says so on the interface itself: "all properties in ToolAnnotations are hints. They are not guaranteed to provide a faithful description of tool behavior (including descriptive properties like title)." It follows that with "Clients should never make tool use decisions based on ToolAnnotations received from untrusted servers", and the tools page repeats it as a requirement — clients "MUST consider tool annotations to be untrusted unless they come from trusted servers". A server asserting readOnlyHint: true has asserted something, not enforced it. Filling these four fields in improves how a client can present our tools and what it can reasonably decline to interrupt the user about. It is not a permission system, and shipping it does not give us one.

Scope of the absence claim

A repo-wide grep at commit e026b4e7, all file types, excluding node_modules and .git:

grep -rn --exclude-dir=node_modules --exclude-dir=.git \
  "readOnlyHint\|destructiveHint\|idempotentHint\|openWorldHint" .

Zero hits in the tracked tree, and zero in apps/api/src. Every match is an untracked working file: an internal benchmark note, this post's artifact record, and the probe above. Run that command yourself after this post is published and the count goes up, because the post now matches its own grep — the load-bearing half is the tracked tree, and that is empty. An observation about this repo at this commit, nothing wider.

The change is three parts, all in one file. Import ToolAnnotations from @modelcontextprotocol/sdk/types.js; it appears nowhere in apps/api/src today. Add an annotations?: ToolAnnotations member to McpToolDef (apps/api/src/mcp/tool-catalogue.ts:11-17), and thread it through defineTool (lines 23-37), which builds all 40 tools and currently neither accepts nor forwards one. Only then does a fourth key at line 44 carry anything —

{ title: tool.title, description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations },

— then re-run the probe and read the counts again.