Skip to main content
Back to Blog

Design MCP Tools That Are Safe to Test and Operate

A single manage_tenant tool cannot be annotated, approved or retried safely, because one name both lists and deletes. This part splits it by risk, makes the destructive write accept only a caller-bound plan from a read-only preview plus an idempotency key, and asks the user through elicitation. It runs on the MCP TypeScript SDK 1.32.2 and shows two SDK behaviours a spec-driven test would miss: output validation that fails after the write has happened, and unknown tools returned as isError results.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Oct 31, 202615 min read
View AI-ready Markdown
On the left, one orange-outlined panel holds a jumble of grey and orange bars. It fans out into three narrow green lanes. The top lane runs right to an arrow. The middle lane emits a ringed token that travels down into the bottom lane, which sits inside a dashed boundary, passes a ringed checkpoint and reaches one green outcome node. A dashed orange path branching from the checkpoint ends at a crossed-out circle.
Listen to this article · 17 min
Quick answer: Give each risk level its own tool, so that annotations, scopes and host approvals attach to one kind of action. Make every write take a server-issued plan from a read-only preview, plus an idempotency key, and ask the user through elicitation before the write commits. Declare an outputSchema on every tool, return error codes a test can match, and cap what one call can read or change. Each of those choices exists so a test can prove it: that read-only tools write nothing, that a retry applies once, that a stale plan or a declined prompt changes nothing, and that a list call stays bounded.

A QA platform team exposes its test environments to an agent through one MCP tool. It is called manage_tenant, it takes an action string and a free-form payload, and it can list tenants, reseed them or wipe them. The demo goes well. Then someone asks what the test suite for it should assert, and nobody can answer. The tool cannot be annotated honestly, because the same name both lists and deletes. A host cannot approve listing without approving deletion. A retried call after a timeout might wipe a tenant twice. Every safety rule lives in how the model happens to read the word "action".

The opening part of this series mapped the test layers for an MCP server, Part 2 checked the protocol layer against the conformance suite, and Part 3 covered who may call a server at all. This part is about the tools themselves: how to shape them so that each safety property is something a test can check, rather than something a prompt has to ask for nicely.

Versions checked on 2026-10-26: @modelcontextprotocol/sdk 1.32.2 (npm latest), Zod 4.6.7, Vitest 5.0.5, Node.js 26.0.0 on macOS arm64. The TypeScript SDK also ships a v2 line as split packages (@modelcontextprotocol/server 2.4.0); the example stays on 1.32.2 to match the conformance suite in Part 2. Behaviour is described against MCP revision 2025-11-25, which SDK 1.32.2 negotiates. Where the current revision, 2026-07-28, changes a design decision, the text says so. All output below came from running the example on that runtime.

Split the god-tool along its risk lines

This is the version that is hard to test:

// Wrong: one name for reading, reseeding and deleting
server.registerTool('manage_tenant', {
  description: 'Manage test tenants',
  inputSchema: { action: z.string(), payload: z.record(z.string(), z.unknown()) },
}, async ({ action, payload }) => dispatch(action, payload));

Ask a client what it knows about this tool and the answer is nothing. Listing it through the SDK prints manage_tenant annotations: undefined. Under the 2025-11-25 schema, an absent readOnlyHint defaults to false and an absent destructiveHint defaults to true, so a host that honours hints treats every call, including a harmless list, as a possible destructive write. Adding hints does not help, because annotations belong to a tool, not to an action. There is no honest value of readOnlyHint for a tool that sometimes deletes.

Other controls work at the same grain. The MCP client best practices for code mode let a host grant categorical approval, such as allowing one tool for a whole script run, instead of prompting on every iteration. Approve manage_tenant that way and the script may delete as often as it likes. Scopes, covered in Part 3, can only be checked per tool if the tools differ in what they do.

So split by risk, not by noun:

ToolEffectAnnotations
list_tenantsReads at most 50 rows per callreadOnlyHint: true, openWorldHint: false
preview_tenant_resetComputes a plan; changes no tenantreadOnlyHint: true, openWorldHint: false
reset_tenantDeletes one tenant's rows and loads a fixturereadOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false

openWorldHint is set explicitly because it defaults to true, which describes a tool that reaches external entities such as the web. These three touch one internal store.

Treat every annotation as a claim the suite verifies

The 2025-11-25 tools page says clients MUST consider annotations untrusted unless they come from trusted servers, and the schema calls every field in ToolAnnotations a hint. That rule protects clients from other people's servers. For your own server, the useful reading is the reverse: an annotation is a claim you publish, and your suite is the only thing that will check it. Two tests cover it.

const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
  list_tenants: {},
  preview_tenant_reset: { tenantId: 'tn_checkout01', fixture: 'empty' },
};

it('every tool states all four hints explicitly', async () => {
  const { client } = await connect([]);
  const { tools } = await client.listTools();
  for (const t of tools) {
    const a = t.annotations ?? {};
    const explicit = a.readOnlyHint === true
      ? ['readOnlyHint', 'openWorldHint']
      : ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'];
    expect(Object.keys(a), t.name).toEqual(expect.arrayContaining(explicit));
    expect(t.outputSchema, `${t.name} has no outputSchema`).toBeDefined();
  }
});

it('a tool marked readOnlyHint changes nothing', async () => {
  const { client, store } = await connect([tenant('tn_checkout01', { rows: 812 })]);
  const before = JSON.stringify([...store.tenants]);
  const { tools } = await client.listTools();
  for (const t of tools.filter((t) => t.annotations?.readOnlyHint)) {
    const args = SAMPLE_ARGS[t.name];
    expect(args, `add sample args for ${t.name}`).toBeDefined();
    const res = await client.callTool({ name: t.name, arguments: args });
    expect(res.isError, t.name).toBeFalsy();
  }
  expect(store.writes).toBe(0);
  expect(JSON.stringify([...store.tenants])).toBe(before);
});

The read-only list comes from tools/list, not from a hand-kept array, so a new tool marked read-only is tested the day it ships, and the suite fails until someone gives it sample arguments. The schema says destructiveHint and idempotentHint mean something only when readOnlyHint is false, which is why read-only tools need two explicit hints and write tools need four.

connect() is a small harness that links a real SDK Client to the server with InMemoryTransport.createLinkedPair(). Every call goes through the SDK's JSON-RPC handling and Zod validation, with no network in the test.

A preview that hands back the only key to the write

The write tool does not accept a tenant id and a fixture name. It accepts a planId that only the preview tool issues:

const plan: Plan = {
  planId: `pl_${randomUUID()}`, caller: extra.authInfo?.clientId ?? 'local', tenantId, fixture,
  tenantVersion: tenant.version, expiresAt: store.now() + PLAN_TTL_MS,
};
store.plans.set(plan.planId, plan); // a plan is scratch state, not a change to the tenant
return ok({ planId: plan.planId, tenantId, fixture, rowsDeleted: tenant.rows,
  rowsInserted: FIXTURES[fixture], expiresAt: new Date(plan.expiresAt).toISOString() });

That structure does three jobs. The user sees numbers before anything happens, such as 812 rows deleted and 0 inserted. The write can only do what was previewed, because the plan records the tenant and fixture and the reset tool takes no other inputs. And the plan pins the tenant's version, so a plan made before someone else reseeded the tenant is refused:

it('refuses a plan the tenant has moved past', async () => {
  const { client, store } = await connect([tenant('tn_checkout01')]);
  const planId = await plan(client);
  const t = store.tenants.get('tn_checkout01')!;
  store.tenants.set(t.id, { ...t, rows: 5000, version: 2 }); // someone else seeded it meanwhile
  const res = await client.callTool({ name: 'reset_tenant', arguments: { planId, idempotencyKey: randomUUID() } });
  expect(res.isError).toBe(true);
  expect(JSON.parse((res.content as { text: string }[])[0].text).code).toBe('plan_stale');
  expect(store.writes).toBe(0);
});

A plan id is what the MCP security best practices call a state handle. They say servers MUST NOT treat possession of one as authentication, SHOULD mint it with a secure random generator, and SHOULD bind it server-side to the authenticated user. Hence the UUID and the stored caller. The ten-minute expiry and deletion on apply shrink the window in which a leaked plan id is worth anything. Storing plans in memory, as this example does, only works for a single process. Behind a load balancer the plan store has to be shared.

Retries must get the first answer back

The schema defines idempotentHint: true as "calling the tool repeatedly with the same arguments will have no additional effect". A reset is not naturally idempotent, since a second reset deletes whatever was loaded after the first. It becomes idempotent once a caller-supplied key is part of the arguments and the server remembers what each key did:

const done = store.completed.get(`${caller}:${idempotencyKey}`);
if (done && done.planId !== planId) return fail('idempotency_key_reused', 'This idempotencyKey was used for a different plan. Generate a new one.');
if (done) return ok({ ...done.result, replayed: true });

The lookup runs before the confirmation prompt, so a retry does not ask the user a second time. The test asserts both properties:

it('applies a retried call once', async () => {
  const { client, store, prompts } = await connect([tenant('tn_checkout01', { rows: 812 })]);
  const args = { planId: await plan(client), idempotencyKey: randomUUID() };
  const first = await client.callTool({ name: 'reset_tenant', arguments: args });
  const retry = await client.callTool({ name: 'reset_tenant', arguments: args });
  expect(first.structuredContent).toMatchObject({ rowsDeleted: 812, version: 2, replayed: false });
  expect(retry.structuredContent).toMatchObject({ rowsDeleted: 812, version: 2, replayed: true });
  expect(store.writes).toBe(1);
  expect(prompts).toHaveLength(1); // the retry did not ask the user again
});

A single-use plan looks as if it already handles retries, and deleting the key lookup shows why it does not. With the lookup removed, the second call does not write twice. It fails:

FAIL  tenants.test.ts > reset_tenant > applies a retried call once
AssertionError: expected undefined to match object { rowsDeleted: 812, version: 2, …(1) }
+ Received:
undefined

The retry got plan_not_found as an error. To an agent whose first response was lost to a timeout, that reads as "the reset did not happen". The sensible next move is to preview again and reset again, which really does delete the tenant twice. The key is what lets the server answer "this already happened, here is the result".

Ask the user from the server side

The tools page says applications SHOULD present confirmation prompts, and clients SHOULD prompt for confirmation on sensitive operations. Those are host duties, and a server cannot check them. Elicitation lets the server ask directly. Under 2025-11-25, a server MUST NOT send an elicitation mode the client did not declare, and the user's answer comes back as one of three actions: accept, decline or cancel.

const caps = server.server.getClientCapabilities();
if (!caps?.elicitation) return fail('confirmation_unavailable', 'This client cannot ask the user to confirm, so reset_tenant will not run.');
const answer = await server.server.elicitInput({
  mode: 'form',
  message: `Delete ${tenant.rows} rows in ${tenant.id} and load fixture "${plan.fixture}" (${FIXTURES[plan.fixture]} rows)?`,
  requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean', title: 'Reset this tenant' } }, required: ['confirm'] },
});
if (answer.action !== 'accept' || answer.content?.confirm !== true) {
  return fail('not_confirmed', `The user did not confirm (${answer.action}). Nothing was changed.`);
}

The design choice is to fail closed when the client cannot ask. The test runs every non-accepting path:

it.each(['decline', 'cancel', 'none'] as const)('changes nothing when the answer is %s', async (answer) => {
  const { client, store } = await connect([tenant('tn_checkout01')], answer);
  const res = await client.callTool({ name: 'reset_tenant', arguments: { planId: await plan(client), idempotencyKey: randomUUID() } });
  expect(res.isError).toBe(true);
  expect(store.writes).toBe(0);
});

Know the limit of this. The server receives whatever the client returns, and nothing in the protocol proves that a person read the prompt. Elicitation moves the request to confirm into the server's code, where a test can see it. Whether the host shows that prompt to a human is still the host's behaviour.

The 2026-07-28 revision changes how this works on the wire. Instead of sending elicitation/create mid-call, the server returns an InputRequiredResult, and the client retries tools/call with inputResponses and an opaque requestState. The multi round-trip page says servers MUST treat requestState as attacker-controlled, MUST protect its integrity if it affects business logic, and MUST enforce single use server-side where that matters, because principal, expiry and request binding alone do not guarantee it. A plan stored server-side, with an owner, an expiry and deletion on apply, already has those properties. Moving to the new revision changes how confirmation is carried over the wire. The three tools can stay as they are.

An outputSchema bug reports failure after the write

The spec says that when a tool declares an outputSchema, servers MUST return conforming structured results and clients SHOULD validate them. In SDK 1.32.2, McpServer does the server-side check itself, and it runs after the handler returns. A small probe with a write tool that returns archived_at where the schema says archivedAt:

archive_run result: {"content":[{"type":"text","text":"MCP error -32602: Output validation error: Invalid structured content for tool archive_run: Invalid input: expected string, received undefined at archivedAt"}],"isError":true} | writes = 1

The archive happened, and the caller was told the call failed. For a read tool that costs nothing. For a destructive tool it is the timeout case again: the agent sees an error and is likely to retry. This is one more reason the idempotency key belongs on every write. The retry test above would catch this bug, because it asserts on structuredContent and not only on isError. A failed output check returns an error result with no structuredContent at all. On the client side, Client.callTool validates against output schemas it cached from a previous listTools() call. A client that never lists tools never validates, which is why the test harness lists once after connecting.

Error results a test can match exactly

The 2025-11-25 tools page lists unknown tools under protocol errors, meaning JSON-RPC errors. SDK 1.32.2 catches almost every error in its tools/call handler and wraps it as a result. A call to a name that does not exist returns:

unknown tool: {"content":[{"type":"text","text":"MCP error -32602: Tool reset_tenants not found"}],"isError":true}

Input that fails the Zod schema comes back the same way, as isError: true with an Input validation error prefix. A test written from the spec that expects callTool to reject will fail against this SDK. Pin the SDK version, and assert on the shape it actually produces.

For your own failures, the example returns a JSON body with a stable code and a human message: plan_stale, not_confirmed, tenant_protected, idempotency_key_reused. Tests assert on the code, so rewording a message to help the model recover breaks no test. Where there is a next step, the message names the tool for it. That is the "actionable feedback" the spec wants from tool execution errors.

Cap what one call can read or change

Every tool here touches at most one tenant or one page. list_tenants defaults to 20 rows and its schema caps limit at 50, so limit: 500 is rejected before the handler runs. The test seeds 120 tenants and checks both the page and the refusal:

const page = await client.callTool({ name: 'list_tenants', arguments: {} });
const body = page.structuredContent as { tenants: unknown[]; nextCursor?: string };
expect(body.tenants).toHaveLength(20);
expect(body.nextCursor).toBe('tn_load019');
const huge = await client.callTool({ name: 'list_tenants', arguments: { limit: 500 } });
expect(huge.isError).toBe(true);

The cursor borrows the shape MCP uses for its own list methods, where cursors are opaque and the server picks the page size. The output schema also says max(50), so a later change that drops the slice fails output validation instead of filling a model's context.

Keeping writes to one tenant matters more once hosts use code mode. The client best practices describe models writing scripts that call tools in loops, and they leave the model responsible for reporting any side effects already committed when a script fails partway. A reset_all_tenants tool that fails on tenant 37 leaves a state nobody described. Thirty-seven separate calls each leave a plan, a key and an audit entry. The schema cannot stop a model from looping, and that is acceptable: every iteration still needs its own preview and its own confirmation.

On the input side, McpServer in 1.32.2 accepts a maxToolInputElements option that rejects arguments with too many nested elements before Zod parses them. It is unset by default, so there is no limit until you configure one.

What this design costs

Three tools take more context than one. The client best practices suggest hosts switch to progressive discovery once tool definitions pass a threshold, with 1 to 5 percent of the context window given as an example. A server that splits every capability into three is pushing hosts toward that threshold, so split by risk, not by every verb.

Plans and idempotency records are state, and they need an owner, a TTL and storage that every replica can read. The 2026-07-28 revision makes the protocol itself stateless, which makes that storage your problem.

Failing closed without elicitation will block some clients. That cost is intended for a destructive tool. A read-only server needs none of this: no preview, no key, no prompt. Its obligations are the annotation test and the bounds.

To draft an outputSchema from a real result, paste the result's JSON into the JSON Schema generator, then add the bounds and enums it cannot infer before you declare it.

Redesign one write tool this week

  1. List your server's tools and mark each one read, additive or destructive. Any tool that falls into two classes gets split before anything else.
  2. Set all four hints explicitly on every write tool and two on every read tool, then add the two annotation tests above. The read-only test should run in CI against a real SDK client over InMemoryTransport.
  3. Pick the most destructive tool. Give it a preview that issues a random, caller-bound, expiring plan, and make the write accept only that plan plus an idempotency key.
  4. Add the retry, stale-plan and decline/cancel/no-capability tests. Then delete the key lookup on a branch and confirm the retry test fails.
  5. Keep the test report and one audit entry per write as evidence: plan id, idempotency key, outcome and resulting version.

Questions about designing MCP tools

If my host honours destructiveHint, do I still need a server-side confirmation?

Yes, for anything you cannot undo. The hint only affects hosts that read it, and the spec tells clients to distrust annotations from untrusted servers. A confirmation in the server's code is something your own tests can prove; the host's prompt is not.

Can the model be trusted to generate the idempotency key?

It only has to generate a fresh UUID per intended action and reuse it on a retry. A key reused for a different plan is refused with idempotency_key_reused. Keys are stored per caller, so another principal's key cannot replay their result.

Does the v2 SDK behave the same way?

Not for unknown tools. Part 1 ran its lab on @modelcontextprotocol/server 2.4.0, where calling a tool that does not exist rejects callTool with a JSON-RPC -32602 protocol error, as the spec describes. The output-schema probe here was run on 1.32.2 only, so repeat it against your version before relying on it. Both behaviours belong to the SDK, not to the spec.

Primary references