# 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.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-31T13:30:00.000Z

- Updated: 2026-10-31T13:30:00.000Z

- Category: AI in Testing

- Canonical source: https://automationtester.in/blog/ai-in-testing/design-safe-mcp-tools-testability

- Tags: mcp, ai-agents, tool-design, idempotency, typescript, vitest

**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](/blog/ai-in-testing/how-to-test-mcp-server-protocol-auth-safety) mapped the test layers for an MCP server, [Part 2](/blog/ai-in-testing/mcp-conformance-framework-ci) checked the protocol layer against the conformance suite, and [Part 3](/blog/ai-in-testing/test-mcp-authorization-resource-audience-scopes) 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:

| Tool | Effect | Annotations |
| --- | --- | --- |
| `list_tenants` | Reads at most 50 rows per call | `readOnlyHint: true`, `openWorldHint: false` |
| `preview_tenant_reset` | Computes a plan; changes no tenant | `readOnlyHint: true`, `openWorldHint: false` |
| `reset_tenant` | Deletes one tenant's rows and loads a fixture | `readOnlyHint: 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](/tools/json-schema), then add the bounds and enums it cannot infer before you declare it.

## Redesign one write tool this week

- 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.

- 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`.

- 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.

- 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.

- 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](/blog/ai-in-testing/how-to-test-mcp-server-protocol-auth-safety) 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

- [Tools, MCP specification 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools). Annotations as untrusted unless from trusted servers, outputSchema MUST/SHOULD rules, the two error channels with unknown tools as protocol errors, and the client SHOULDs on confirmation.

- [Schema reference for 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/schema). `ToolAnnotations` as hints, each hint's default, and `destructiveHint`/`idempotentHint` meaningful only when not read-only.

- [Elicitation in the 2025-11-25 revision](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation). Declared modes, the MUST NOT on unsupported modes, and the accept, decline and cancel actions.

- [Pagination utility, 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination). Opaque cursors and server-chosen page size.

- [Tools in the 2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28/server/tools). `InputRequiredResult` and the retry with `inputResponses` and `requestState`.

- [Multi round-trip requests, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr). `requestState` as attacker-controlled, integrity protection, and single use enforced server-side.

- [MCP security guidance on state handles and sessions](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices). State handles: not authentication, random generation, and server-side binding to the user. Also the statement that MCP has no protocol-level sessions as of 2026-07-28.

- [Client best practices for MCP hosts](https://modelcontextprotocol.io/docs/develop/clients/client-best-practices). Categorical per-tool approval in code mode, partial side effects reported by the model, and the progressive discovery threshold.

- [McpServer source at tag 1.32.2](https://github.com/modelcontextprotocol/typescript-sdk/blob/1.32.2/src/server/mcp.ts). Errors wrapped as `isError` results, output validation after the handler, and the unset `maxToolInputElements` default.

- [Client source at tag 1.32.2](https://github.com/modelcontextprotocol/typescript-sdk/blob/1.32.2/src/client/index.ts). `callTool` validating structured output only against schemas cached by `listTools`.

- [npm registry entry for @modelcontextprotocol/sdk](https://registry.npmjs.org/@modelcontextprotocol/sdk). 1.32.2 on the `latest` tag on 2026-10-26.

## Continue reading on AutomationTester.in

- [Check MCP Tool Schemas Before an Agent Calls Them](https://automationtester.in/blog/tools-reviews/check-mcp-tool-schemas-before-agents-call-them)
- [Validate AI Agent Tool Calls in TypeScript: Schema, Authorization, Approval](https://automationtester.in/blog/ai-in-testing/validate-ai-agent-tool-calls-typescript)
- [Test API Idempotency Under Retries and Timeouts](https://automationtester.in/blog/automation-tutorials/test-api-idempotency-retries-timeouts)
- [Make TypeScript Type-Level Tests Earn Their Place](https://automationtester.in/blog/automation-tutorials/typescript-type-level-tests-contract-value)
- [Test TypeScript Error Handling Without Losing Causes](https://automationtester.in/blog/automation-tutorials/typescript-error-handling-preserve-causes-tests)

Source: [Design MCP Tools That Are Safe to Test and Operate](https://automationtester.in/blog/ai-in-testing/design-safe-mcp-tools-testability) by Shashank Rawlani.
