How to Test an MCP Server: Protocol to Safety
A drafts server that passes its happy-path tests can still write a draft after the client has timed out, because the official TypeScript client opens with the 2025 handshake by default and the stateless legacy path never delivers the cancellation. This guide tests an MCP server layer by layer against the 2026-07-28 specification with SDK 2.4.0: era pinning, raw envelope probes with real -32602, -32020 and -32022 responses, per-principal tools/list, refusals checked against storage, cancellation in both eras, and an audit log that records refused calls.

initialize handshake unless told otherwise. Then send hand-built requests to check the per-request envelope, version errors and header checks. Capture tools/list once per principal, and assert protocol errors and isError tool results separately. Prove that a refused or timed-out write leaves storage unchanged, not only that the call failed. Run every behaviour test in both eras if the server serves both. Keep one audit line per JSON-RPC request, refusals included.A small MCP server manages blog drafts. It exposes list_drafts to everyone and create_draft to callers with a write scope, and it has no way to publish. Its tests list the tools, create a draft and read it back, and they all pass. Then an agent's call to create_draft times out after 100 ms. The client reports REQUEST_TIMEOUT, yet half a second later the draft is in storage, so an agent that retries creates a second one. The handler does check its abort signal. The client that hit the timeout was speaking the 2025 protocol, and on that path the cancellation never reached the handler.
That run comes from the lab behind this article. A friendly client and a valid JSON-RPC reply show that the server is reachable, not which protocol revision it served, how it rejects bad input, or whether it stops writing when the caller gives up.
@modelcontextprotocol/server and @modelcontextprotocol/client 2.4.0 (released 2026-10-23) with zod 4.6.7 on Node.js 26.0.0, macOS arm64. Every request goes through the SDK's createMcpHandler in-process via handler.fetch, so there is no network and no real OAuth: the caller's identity is injected as authInfo. The newest stable conformance package is @modelcontextprotocol/conformance 0.1.16. All outputs below are real, trimmed only for width.What a happy-path run leaves untested
Each layer below has its own failure and its own evidence. A happy-path run touches parts of two rows.
| Layer | What to assert | Evidence to keep |
|---|---|---|
| Era and version | Which revision each client actually negotiated; how an unknown version is refused | getProtocolEra() per test, the -32022 body |
| Request envelope | Missing _meta fields and header/body disagreements are rejected | HTTP status plus JSON-RPC code per probe |
| Tool contract | tools/list per principal, strict input schemas, outputSchema honoured | One captured list per scope set |
| Tool behaviour | Success, tool errors and protocol errors, each asserted its own way | Result bodies and storage state |
| Destructive actions | Forbidden effects cannot happen through any argument | Storage unchanged after each refusal |
| Cancellation | A timed-out write does not land later | Storage after the timeout, per era |
| Authorization and conformance | Token audience and scope checks; spec scenarios | Parts 3 and 2 of this series |
Pin the protocol era before any other assertion
The 2026-07-28 revision removed the initialize handshake. Every request now carries its protocol version and client capabilities in _meta, and the server accepts or rejects each request on its own. Servers must implement a new server/discover method that returns supported versions, capabilities and identity. The specification calls the old handshake-based revisions (2025-11-25 and earlier) legacy and the per-request ones modern, and it allows a server to serve both.
The official TypeScript SDK does serve both, and its defaults decide which one your tests exercise. A Client constructed without options performs the 2025 handshake byte for byte; versionNegotiation: { mode: 'auto' } probes with server/discover first. The same writer credentials against the same handler:
const plain = await connect(handler, writer);
console.log('default client era:', plain.getProtocolEra());
const w = await connect(handler, writer, { versionNegotiation: { mode: 'auto' } });
console.log('auto client era: ', w.getProtocolEra());
default client era: legacy
auto client era: modern
A suite built on the default client tests the legacy path only, whatever the server's version string says. In tests, use a pin, { pin: '2026-07-28' }, rather than 'auto'. A pin never falls back: if the server stops offering the revision, connect() rejects with ERA_NEGOTIATION_FAILED, where 'auto' would drop back to the handshake and keep passing. Assert getProtocolEra() in each test so a silent downgrade fails loudly.
The two eras also refuse an unknown version differently. Under the 2025 rules, a server that does not support the version in initialize answers with a different version it does support. Under 2026-07-28, it must return UnsupportedProtocolVersionError with a list of supported versions. Sent as an initialize to the lab's dual-era handler, an unknown version is quietly replaced:
initialize asks 2099-01-01 -> HTTP 200, server answers protocolVersion 2025-11-25
The same version on a modern request gets the -32022 error shown in the probe table below, so a version assertion needs one expectation per era. One more default matters. createMcpHandler serves 2025-era traffic statelessly unless you pass legacy: 'reject', so an endpoint described as "2026 only" still answers initialize. If modern-only is the intent, assert that initialize gets -32022.
Probe the envelope with requests no SDK would send
An SDK client always builds well-formed requests, so it cannot tell you how the server treats malformed ones. Send those yourself. On Streamable HTTP, every POST must carry MCP-Protocol-Version and Mcp-Method, and tools/call must also carry Mcp-Name. The server must reject a request whose headers disagree with its body. The specification's reason is concrete: a load balancer may route on the header while the server executes the body.
const meta = (v = '2026-07-28') => ({
'io.modelcontextprotocol/protocolVersion': v,
'io.modelcontextprotocol/clientInfo': { name: 'raw-probe', version: '1' },
'io.modelcontextprotocol/clientCapabilities': {}
});
const std = (method, name, v = '2026-07-28') => ({
'MCP-Protocol-Version': v, 'Mcp-Method': method, ...(name ? { 'Mcp-Name': name } : {})
});
await post('no _meta version', { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
std('tools/list'));
await post('header/body mismatch', { jsonrpc: '2.0', id: 4, method: 'tools/list',
params: { _meta: meta() } }, std('tools/list', null, '2025-11-25'));
await post('Mcp-Name mismatch', { jsonrpc: '2.0', id: 5, method: 'tools/call',
params: { name: 'list_drafts', arguments: {}, _meta: meta() } },
std('tools/call', 'create_draft'));
Here post() sends one Request through handler.fetch and prints the status and the error or result. Seven probes against the drafts server, the last one against a copy created with legacy: 'reject':
discover HTTP 200 {"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true}},...}
no _meta version HTTP 400 {"code":-32602,"message":"Invalid params: ... missing the required per-request envelope key(s): _meta"}
unsupported version HTTP 400 {"code":-32022,"message":"Unsupported protocol version: 2099-01-01","data":{"supported":["2026-07-28"],...}}
header/body mismatch HTTP 400 {"code":-32020,"message":"Bad Request: ... body envelope names protocol version 2026-07-28 but the MCP-Protocol-Version header names 2025-11-25"}
Mcp-Name mismatch HTTP 400 {"code":-32020,"message":"Bad Request: ... params.name=\"list_drafts\" but the Mcp-Name header names \"create_draft\""}
unknown tool HTTP 200 {"code":-32602,"message":"Tool publish_post not found"}
initialize, reject HTTP 400 {"code":-32022,"message":"Unsupported protocol version: 2025-11-25",...}
Two details are easy to get wrong. The unknown-tool error arrives with HTTP 200, so a transport-level check that expects a 4xx will fail on a correct server. Assert the JSON-RPC code. Second, the numbers are new. The 2026-07-28 changelog renumbered the error codes that its drafts had introduced: HeaderMismatch went from -32001 to -32020 and UnsupportedProtocolVersion from -32004 to -32022. The range from -32020 to -32099 is now reserved for the specification. A test copied from a pre-release example will assert the wrong code.
Capture tools/list once for each scope set
Under 2026-07-28, tools/list must not vary per connection, but it may vary by the authorization on the request. The drafts server uses that rule: its factory receives authInfo and registers create_draft only for callers holding drafts:write.
createMcpHandler(({ authInfo }) => {
const scopes = authInfo?.scopes ?? [];
const server = new McpServer({ name: 'content-drafts', version: '0.1.0' });
// list_drafts registered for everyone ...
if (scopes.includes('drafts:write')) {
server.registerTool('create_draft', {
description: 'Create an unpublished draft. This server cannot publish; drafts stay unpublished.',
inputSchema: z.object({ title: z.string().min(1).max(120), body: z.string().min(1) }).strict(),
outputSchema: z.object({ id: z.string(), status: z.literal('draft') }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
}, createDraftHandler);
}
return server;
});
writer tools: list_drafts, create_draft
create_draft additionalProperties: false
reader tools: list_drafts
A contract test that captures one list with an admin credential never sees what a lower-privileged agent is offered. Capture the list for every scope set you issue and diff each against an expected table. The .strict() call is what puts additionalProperties: false on the wire, and the next section relies on it. For checking the schemas themselves (stale required entries, invalid x-mcp-header values, provider name rules), see checking MCP tool schemas before an agent calls them.
Assert protocol errors and tool errors as different things
MCP reports tool failures through two channels. Protocol errors are JSON-RPC errors for unknown tools, malformed requests and server faults. Tool execution errors are ordinary results with isError: true, and the specification lists input validation failures among them. The intent is that the model sees the message and can correct its arguments. In the TypeScript SDK the two surface differently: a protocol error rejects the callTool promise, and a tool error resolves.
create ok: {"id":"d_1","status":"draft"} isError: false
publish field: true Input validation error: Invalid arguments for tool create_draft: Unrecognized key: "publish"
reader call threw: ProtocolError -32602 Tool create_draft not found
A test written as await assert.rejects(callTool(...)) for the publish case never passes, because nothing rejects. A test that only checks isError on the reader's call never runs its assertion, because the call throws. Write one helper per channel and make each test say which one it expects:
const res = await writer.callTool({ name: 'create_draft',
arguments: { title: 'T', body: 'B', publish: true } });
assert.equal(res.isError, true);
assert.match(res.content[0].text, /Unrecognized key: "publish"/);
assert.equal(drafts.length, 0);
The last line matters most, and the next section explains why.
Prove that a refusal changed nothing
The requirement for this server is "can create drafts, cannot publish". There are three places that guarantee could live, and only one of them is the server's.
Annotations are not it. Every ToolAnnotations field is a hint, and clients must treat annotations as untrusted unless the server is trusted. destructiveHint defaults to true, so omitting it makes a tool look more dangerous, not less. Asserting destructiveHint: false checks a label, not behaviour.
Host confirmation is not it either. The specification says hosts should let a human deny tool calls, and the client best-practices guide adds that approving a code-mode script does not approve every call it makes. Those are host properties; another host may skip them.
The server owns what it accepts and what it does. This one has no publish tool, and the strict schema turns an extra publish argument into a tool error before the handler runs. The assertion that matters is on storage: after each refusal, the record count and every record's status are unchanged. Repeat it for a refused principal and an unknown tool. A server that returns a polite error after doing the work fails only this check.
Handles need the same test. With no protocol sessions, state that spans calls travels as a handle in tool arguments, and the security guidance says possession of a handle must not count as authentication. Create a draft as one principal and call with its id as another. Part 4 on designing MCP tools that are safe to test covers how to shape tools so these checks stay cheap.
A client timeout is not a rollback
Cancellation in MCP is advisory. Under 2026-07-28, on Streamable HTTP, closing a request's response stream is the cancellation signal, and the server must treat a disconnect as cancellation. On stdio the client sends notifications/cancelled. In both cases stopping work is a should, not a must, and the cancel may arrive after the work has finished. The client gives up on its timer either way.
The drafts handler waits on a 300 ms storage call. The test calls it with a 100 ms timeout, then waits half a second and counts drafts. The wrong handler ignores the signal; the right one passes it to the slow call and checks it again immediately before the write:
// wrong: the write lands after the client has given up
await new Promise(r => setTimeout(r, storageDelayMs));
drafts.push(draft);
// right: the slow call is abortable, and the commit is guarded
await sleep(storageDelayMs, ctx.mcpReq.signal);
ctx.mcpReq.signal.throwIfAborted();
drafts.push(draft);
honourSignal=false: SdkError REQUEST_TIMEOUT: Request timed out
drafts 500 ms later: 1
honourSignal=true: SdkError REQUEST_TIMEOUT: Request timed out
drafts 500 ms later: 0
That result came from a client in 'auto' mode, which negotiated the modern era. The same test with a default client is the failure from the opening. Here is the wire traffic for each:
+25ms POST initialize id=0
+36ms POST notifications/initialized
+37ms GET
+38ms POST tools/call id=1
+140ms POST notifications/cancelled requestId=1
+141ms REQUEST_TIMEOUT
legacy drafts: 1
+20ms POST server/discover id=server-discover-probe-1
+35ms POST tools/call id=0
+136ms REQUEST_TIMEOUT
modern drafts: 0
The legacy client cancels with a separate POST of notifications/cancelled. The handler's default stateless legacy mode builds a fresh server instance for each request, so the instance that receives the cancellation never saw request 1. The 2025-11-25 cancellation rules say to ignore notifications for unknown request IDs, so it does. Meanwhile, request 1's instance finishes and writes. Each piece follows its era's rules, and the draft lands anyway.
Run the cancellation test once per era, as a loop over modes:
✔ legacy: reader is not offered create_draft (39.8ms)
✔ legacy: unknown "publish" key is a tool error and writes nothing (3.2ms)
✖ legacy: a timed-out create leaves no draft behind (505.5ms)
✔ modern: reader is not offered create_draft (29.5ms)
✔ modern: unknown "publish" key is a tool error and writes nothing (3.7ms)
✔ modern: a timed-out create leaves no draft behind (504.3ms)
There are two fixes, each with a cost. Refusing legacy traffic with legacy: 'reject' breaks every 2025-only client. Making the late write harmless, with a caller-supplied idempotency key so the retry returns the first draft, adds a field and a lookup to every write. Neither removes the race between a cancel and a commit: the signal check narrows it but is not a transaction, and the specification expects both sides to handle a cancel that arrives after completion.
Authorization and conformance, briefly
The lab's scopes test how the server uses identity, not how it checks tokens. The SDK documentation says createMcpHandler verifies no token and validates no Host or Origin header: authInfo is whatever the code in front of it passes in. The specification puts those checks on the server. It must answer 403 to an invalid Origin, accept only tokens issued for itself as audience (RFC 8707), answer 401 to invalid or expired tokens and 403 with insufficient_scope to missing scope, and never pass a client's token through to a downstream API. Stdio servers should skip the HTTP authorization flow and read credentials from the environment. Part 3 tests resource indicators, audience and scopes; the JWT decoder shows a token's aud claim while you write those cases, though decoding is not verifying.
The official conformance framework runs specification scenarios against a live URL with npx @modelcontextprotocol/conformance server --url http://localhost:3000/mcp, and supports a YAML baseline of expected failures so it can gate CI before an implementation is complete. Note its dates. The latest tag on npm is 0.1.16, published on 2026-03-30, four months before the 2026-07-28 revision. Newer work ships on the alpha tag, at 0.2.0-alpha.12 since 2026-10-01. Part 2 runs the framework in CI against the 2025-11-25 revision that the stable release targets. Conformance answers whether the server speaks the protocol correctly; the behaviour tests above answer whether its tools do what you intended.
Record every request where it enters, not where it succeeds
The first version of the lab audited inside the create_draft handler. After the success-and-refusal run from the error-channel section, it held one line, for the successful create. Neither refusal appeared in it: the SDK rejected the publish argument and the reader's call before any handler ran. If you need evidence that a refused action was attempted, record it where the request enters the server:
function withEdgeAudit(handler) {
return async (request, authInfo) => {
const body = await request.clone().json().catch(() => null);
const response = await handler.fetch(request, { authInfo });
if (body?.id === undefined) return response; // notifications
const text = await response.clone().text();
const json = JSON.parse(text.replace(/^event: message\ndata: /m, '').trim() || 'null');
edgeLog.push({ id: body.id, principal: authInfo?.clientId, method: body.method,
tool: body.params?.name, http: response.status,
outcome: json?.error ? `error ${json.error.code}`
: json?.result?.isError ? 'tool error' : json?.result ? 'ok' : 'no result' });
return response;
};
}
{"id":0,"principal":"editor-bot","method":"tools/call","tool":"create_draft","http":200,"outcome":"ok"}
{"id":1,"principal":"editor-bot","method":"tools/call","tool":"create_draft","http":200,"outcome":"tool error"}
{"id":0,"principal":"summary-bot","method":"tools/call","tool":"create_draft","http":200,"outcome":"error -32602"}
{"id":2,"principal":"editor-bot","method":"tools/call","tool":"create_draft","http":499,"outcome":"no result"}
Two things in that log are worth designing around. Request id 0 appears twice, from two principals. The specification only requires an ID to be unique among the sender's own requests still awaiting a response, so a request ID alone does not identify a request. Key audit records on principal, ID and timestamp, or on the traceparent key, which 2026-07-28 reserves in _meta for OpenTelemetry trace context. Second, the timed-out call shows up as HTTP 499 with no result. Paired with a draft count of zero, that line is the evidence that the timeout left nothing behind.
Where a model belongs in this suite
Nothing above needs a model, and it should stay that way: a safety assertion that passes three runs out of four is not an assertion. A model is useful as one more consumer. Give it each principal's captured tools/list and some tasks, and note where it picks the wrong tool or invents arguments. Those are findings about descriptions and schemas, and their fixes land in the deterministic layers. The JSON Schema generator can turn a sample of real arguments into a schema to compare with the advertised one.
Run the layers against your server today
- Find every
new Client(in your MCP tests. Give each one an explicitversionNegotiationmode, and assertgetProtocolEra()in the test. - Add the seven envelope probes as raw HTTP requests. Assert the JSON-RPC code and the HTTP status separately.
- Capture
tools/listfor each scope set you issue and commit the lists as fixtures. A change to them should need review. - For every write tool, add one refusal test and one timeout test that assert storage state, and run both in every era the endpoint serves.
- Move audit logging to the request edge and check that a refused call produces a line.
The evidence to keep is a per-layer report: the era each client negotiated, the probe table, the tool lists by principal, storage counts after each refusal and timeout, and the edge audit lines keyed by principal and request ID.
Questions about testing MCP servers
Is the MCP Inspector enough for testing a server?
It is a good exploration tool, and its CLI runs one named method per invocation with exit codes, which suits CI smoke checks. It is still one client with one set of defaults. The cases that break servers, such as header and body disagreements, legacy and modern traffic against one endpoint, and a timeout during a slow write, need requests you build on purpose and assertions on storage.
Do the HTTP probes apply to a stdio server?
The headers do not, because stdio has none. Version errors, the _meta envelope, tool errors and refusals all still apply. Cancellation differs: on stdio the client must send notifications/cancelled. The SDK's serveStdio builds one instance per connection, not one per request, so the stateless-fallback race above does not carry over directly. Test it rather than assume it.
Should a new server still serve 2025-era clients?
Only if such clients will call it, and then every behaviour test runs twice. For a new internal server, legacy: 'reject' plus a probe asserting -32022 for initialize is less to keep correct.
Primary references
- MCP 2026-07-28 key changes. Removal of the
initializehandshake and protocol sessions,server/discover, requiredresultType, and the error-code renumbering to-32020,-32021and-32022. - Versioning and Compatibility, revision 2026-07-28. The legacy, modern and dual-era terms,
UnsupportedProtocolVersionErrorwith itssupportedlist, and the compatibility matrix. - MCP basic protocol overview, 2026-07-28. Required
_metafields and the-32602/400 rule, request-ID uniqueness, the reserved error-code range, statelessness, and the reservedtraceparentkey. - server/discover in the 2026-07-28 specification. The mandatory discovery RPC and why
serverInfomust not drive security decisions. - Streamable HTTP transport, 2026-07-28. Required
MCP-Protocol-Version,Mcp-MethodandMcp-Nameheaders, header/body validation with-32020, Origin validation, and disconnect as cancellation. - Tools page of the 2026-07-28 specification. Per-authorization
tools/list, protocol errors versusisErrorresults, untrusted annotations, stateful handles, and the server and client security duties. - MCP schema reference, 2026-07-28.
ToolAnnotationsas hints, withdestructiveHintdefaulting totrue. - Cancellation pattern, 2026-07-28. Transport-specific cancellation, timeouts, and race handling when a cancel arrives after completion.
- Cancellation utility in the 2025-11-25 revision.
notifications/cancelledand ignoring unknown request IDs, the rule behind the legacy-era race. - Lifecycle in the 2025-11-25 revision. Version negotiation in
initialize, where the server answers with another supported version. - MCP authorization, 2026-07-28. Audience validation per RFC 8707, 401 for invalid tokens,
insufficient_scopeon 403, and the ban on accepting or passing through other tokens. - Security best practices for MCP. Token passthrough and state handle hijacking, including binding handles to the verified user.
- Client best practices from the MCP docs. Per-call authorization for code-mode calls even after a script is approved.
- TypeScript SDK v2 guide to protocol versions. The default
legacynegotiation mode,'auto'and pins,ERA_NEGOTIATION_FAILED, and the default stateless legacy serving. - Testing a server with the TypeScript SDK v2. In-process testing through
handler.fetch, andisErrorresults resolving rather than throwing. - Serving over HTTP with the TypeScript SDK v2. The per-request factory, and that the handler verifies no token and validates no Host or Origin header.
- MCP Inspector command-line client. One method per run, exit codes, and the non-interactive flags meant for CI.
- typescript-sdk release v2.4.0. The server and client package versions used in the lab.
- MCP conformance framework at v0.1.16. The server command, scenarios, and the expected-failures baseline.