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

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

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

- Updated: 2026-10-30T07:46:55.656Z

- Category: AI in Testing

- Canonical source: https://automationtester.in/blog/ai-in-testing/how-to-test-mcp-server-protocol-auth-safety

- Tags: mcp, model-context-protocol, ai-agents, api-testing, protocol-testing, test-design

**Quick answer:** Test an MCP server in layers, and drive each layer with a deterministic client rather than a model. First pin the protocol era, because the official TypeScript client still opens with the 2025 `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.

**Versions checked on 2026-10-26:** the current MCP specification revision is 2026-07-28. The lab uses `@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](/blog/tools-reviews/check-mcp-tool-schemas-before-agents-call-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](/tools/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](/blog/ai-in-testing/mcp-conformance-framework-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](/tools/json-schema) 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 explicit `versionNegotiation` mode, and assert `getProtocolEra()` in the test.

- Add the seven envelope probes as raw HTTP requests. Assert the JSON-RPC code and the HTTP status separately.

- Capture `tools/list` for 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](https://modelcontextprotocol.io/specification/2026-07-28/changelog). Removal of the `initialize` handshake and protocol sessions, `server/discover`, required `resultType`, and the error-code renumbering to `-32020`, `-32021` and `-32022`.

- [Versioning and Compatibility, revision 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning). The legacy, modern and dual-era terms, `UnsupportedProtocolVersionError` with its `supported` list, and the compatibility matrix.

- [MCP basic protocol overview, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/index). Required `_meta` fields and the `-32602`/400 rule, request-ID uniqueness, the reserved error-code range, statelessness, and the reserved `traceparent` key.

- [server/discover in the 2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28/server/discover). The mandatory discovery RPC and why `serverInfo` must not drive security decisions.

- [Streamable HTTP transport, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http). Required `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers, header/body validation with `-32020`, Origin validation, and disconnect as cancellation.

- [Tools page of the 2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools). Per-authorization `tools/list`, protocol errors versus `isError` results, untrusted annotations, stateful handles, and the server and client security duties.

- [MCP schema reference, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/schema). `ToolAnnotations` as hints, with `destructiveHint` defaulting to `true`.

- [Cancellation pattern, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation). Transport-specific cancellation, timeouts, and race handling when a cancel arrives after completion.

- [Cancellation utility in the 2025-11-25 revision](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation). `notifications/cancelled` and ignoring unknown request IDs, the rule behind the legacy-era race.

- [Lifecycle in the 2025-11-25 revision](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle). Version negotiation in `initialize`, where the server answers with another supported version.

- [MCP authorization, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization). Audience validation per RFC 8707, 401 for invalid tokens, `insufficient_scope` on 403, and the ban on accepting or passing through other tokens.

- [Security best practices for MCP](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices). Token passthrough and state handle hijacking, including binding handles to the verified user.

- [Client best practices from the MCP docs](https://modelcontextprotocol.io/docs/develop/clients/client-best-practices). Per-call authorization for code-mode calls even after a script is approved.

- [TypeScript SDK v2 guide to protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions). The default `legacy` negotiation mode, `'auto'` and pins, `ERA_NEGOTIATION_FAILED`, and the default stateless legacy serving.

- [Testing a server with the TypeScript SDK v2](https://ts.sdk.modelcontextprotocol.io/v2/testing). In-process testing through `handler.fetch`, and `isError` results resolving rather than throwing.

- [Serving over HTTP with the TypeScript SDK v2](https://ts.sdk.modelcontextprotocol.io/v2/serving/http). The per-request factory, and that the handler verifies no token and validates no Host or Origin header.

- [MCP Inspector command-line client](https://modelcontextprotocol.io/docs/tools/inspector/cli). One method per run, exit codes, and the non-interactive flags meant for CI.

- [typescript-sdk release v2.4.0](https://github.com/modelcontextprotocol/typescript-sdk/releases/tag/v2.4.0). The server and client package versions used in the lab.

- [MCP conformance framework at v0.1.16](https://github.com/modelcontextprotocol/conformance/tree/v0.1.16). The server command, scenarios, and the expected-failures baseline.

## Continue reading on AutomationTester.in

- [Use Appium MCP Without Confusing Exploration and Tests](https://automationtester.in/blog/ai-in-testing/appium-mcp-exploration-vs-test-suite)
- [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 TypeScript Error Handling Without Losing Causes](https://automationtester.in/blog/automation-tutorials/typescript-error-handling-preserve-causes-tests)
- [Review AI-Generated pytest Tests with Mutation Checks](https://automationtester.in/blog/ai-in-testing/review-ai-generated-pytest-tests-mutation-checks)

Source: [How to Test an MCP Server: Protocol to Safety](https://automationtester.in/blog/ai-in-testing/how-to-test-mcp-server-protocol-auth-safety) by Shashank Rawlani.
