MCP Server Testing: Bad Input, Timeouts and Disconnections

An MCP server is software, so it deserves proper testing. This is where your QA skills become a real advantage. MCP server testing means checking the normal path, and also what happens when input is wrong, tools hang or connections drop. In this lesson you will write tests that need no AI model.

Advertisement

📖 What You’ll Learn

  • What to test in an MCP server
  • How to test with a real MCP client in code
  • How to handle timeouts and disconnections
  • Why retrying is not always safe

💡 What to Test in MCP Server Testing

Explain Like I’m 5: Testing a New Toy Car

Imagine: You check that it rolls. You also try it on a rough floor, with flat batteries, and see what happens if you pull them out.

What it really means: Test normal use and the ways that things break.

In testing: Send bad input, slow tools and dropped connections.

AreaTest ideaExpected result
DiscoveryList the toolsThe exact expected names appear
Happy pathCall each tool with valid inputCorrect content is returned
Bad inputWrong type or out-of-range valueisError: true and a clear message
Unknown toolCall a name that does not existA protocol error
Missing dataAsk for a test that does not existA tool error with a hint
TimeoutMake a dependency hangThe call ends with an error
DisconnectionStop the server mid-callThe client reports it and recovers
Output channelCheck stdout on a stdio serverOnly protocol messages appear
Large outputReturn a huge reportThe size is limited
SecretsInspect logs and resultsNo keys or passwords appear

🔍 Test with a Real Client

Explain Like I’m 5: A Practice Customer

Imagine: Before the shop opens, a friend pretends to be a customer. They order something real, and the shop serves them properly.

What it really means: A real client in your test code uses the same protocol as a real AI app.

In testing: No model is needed, so each run is free and repeatable.

Install the client package in the project from Build an MCP Server for QA.

npm install @modelcontextprotocol/client

Create src/server.test.ts. It runs your server inside the test, with no network and no AI model.

import assert from 'node:assert/strict';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createServer } from './create-server.js';

const handler = createMcpHandler(createServer);
const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
  fetch: (url, init) => handler.fetch(new Request(url, init)),
});
const client = new Client(
  { name: 'qa-test', version: '1.0.0' },
  { versionNegotiation: { mode: 'auto' } },
);
await client.connect(transport);

const { tools } = await client.listTools();
assert.deepEqual(tools.map((tool) => tool.name), ['list_failed_tests', 'get_test_error']);

const valid = await client.callTool({ name: 'list_failed_tests', arguments: { limit: 1 } });
assert.notEqual(valid.isError, true);

const badInput = await client.callTool({ name: 'list_failed_tests', arguments: { limit: 999 } });
assert.equal(badInput.isError, true);

const unknownTest = await client.callTool({ name: 'get_test_error', arguments: { name: 'ghost' } });
assert.equal(unknownTest.isError, true);

await assert.rejects(client.callTool({ name: 'delete_all', arguments: {} }));

await client.close();
await handler.close();
console.log('All MCP checks passed');
All MCP checks passed

Run it with npx tsx src/server.test.ts. You can move these checks into your usual test runner later.

To smoke test the real stdio server in a pipeline, use the Inspector’s command-line mode.

npx @modelcontextprotocol/inspector --cli npx tsx src/server.ts --method tools/list

🧩 Timeouts and Disconnections

Explain Like I’m 5: Waiting for a Friend

Imagine: If your friend does not reply, you do not wait at the door forever. You decide how long to wait and what to do next.

What it really means: Every call needs a time limit and a plan for failure.

In testing: End a hung tool, then report it clearly.

This small program has no dependencies. It simulates a slow tool and a dropped connection.

type ToolResult = { text: string; isError: boolean };

function slowTool(delayMs: number): Promise<ToolResult> {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ text: 'report ready', isError: false }), delayMs);
  });
}

async function withTimeout(
  call: () => Promise<ToolResult>,
  limitMs: number,
): Promise<ToolResult> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<ToolResult>((resolve) => {
    timer = setTimeout(
      () => resolve({ text: `Timed out after ${limitMs} ms`, isError: true }),
      limitMs,
    );
  });
  try {
    return await Promise.race([call(), timeout]);
  } finally {
    clearTimeout(timer);
  }
}

let drops = 1;
async function flakyReadOnlyTool(): Promise<ToolResult> {
  if (drops > 0) {
    drops--;
    throw new Error('connection closed');
  }
  return { text: 'read after reconnect', isError: false };
}

async function retryReadOnly(call: () => Promise<ToolResult>): Promise<ToolResult> {
  try {
    return await call();
  } catch {
    return await call();
  }
}

async function main() {
  console.log((await withTimeout(() => slowTool(5), 200)).text);
  console.log((await withTimeout(() => slowTool(200), 5)).text);
  console.log((await retryReadOnly(flakyReadOnlyTool)).text);
}

main();
report ready
Timed out after 5 ms
read after reconnect

This timeout stops the waiting, but not the work. Real code should also cancel the operation.

Retry only what is safe to repeat

If a connection drops, the request may be lost, or it may already have run. Retrying a read is safe. Retrying “create a bug” could create two. Give write tools a way to detect repeats, or ask a person.


🌍 Real-Life Example: The Forever Spinner

A report tool calls an internal service that hangs during a deployment. The tool has no time limit, so the assistant waits without end. A test with a fake hanging service would have caught this. The team adds a time limit and a clear error message.


🧪 Try It Yourself

  1. Add the test file and run it.
  2. Add a check for each row of the test table that applies.
  3. Temporarily add a console.log and see what breaks over stdio.
  4. Stop the server while the Inspector is connected.
  5. Use the prompt below to find cases that you missed.

📋 Ready-to-Copy Prompt

You are a senior QA engineer testing an MCP server.

Tool definitions:
[paste tool names, descriptions and schemas with no secrets]

Create test cases for: valid input, wrong types, boundary values, missing
fields, unknown tools, missing data, timeouts, disconnections, large output
and secrets in logs.

Return a table with: ID, Tool, Input, Expected Result, Error Type
(protocol or tool) and Safe to Retry (yes or no).

Do not invent tools or fields that are not listed.

⚠️ Common Mistakes

  • Testing only through a chat with an AI model
  • Writing no tests for bad input
  • Leaving tool calls without a time limit
  • Retrying write actions automatically
  • Assuming that a failed call did nothing

🎯 Key Takeaways

  • Test MCP servers like any other software interface.
  • A real client in test code needs no AI model.
  • Every external call needs a time limit.
  • Only retry actions that are safe to repeat.

🎤 Interview Questions

1. How would you test an MCP server?

Model answer: I would test discovery, valid calls, bad input, unknown tools, missing data, timeouts, disconnections, output limits and logs. I would automate this with a real MCP client and no model.

2. Why test without an AI model?

Model answer: A model adds cost and variation. Using a client directly makes tests fast, free and repeatable, and lets me choose exactly which request is sent.

3. How do protocol errors and tool errors affect your assertions?

Model answer: A protocol error makes the client call fail, so I assert that it rejects. A tool error is a normal result, so I assert that isError is true and check the message.

4. What should happen when a server disconnects during a call?

Model answer: The client should report the failure and can restart a stdio server. The request may be lost, so it should be sent again only if the action is safe to repeat.

5. Why is retrying a tool call risky?

Model answer: A call can succeed even though the response was lost. Retrying a write could repeat the effect, so write tools need idempotency checks or human review.


📝 Assignment

Write at least ten automated tests for your server, including one for each error type. Add a fake dependency that hangs, and prove that your time limit works. Record any bug that you find, using the format from Write Better Bug Reports with AI.


❓ FAQ

Can I use Playwright Test for these checks?

Yes. Its test runner works well for code-level checks like these. You do not need a browser for them.

Should these tests run in CI?

Yes. They are fast and need no model or API key, so they are a good fit for every pull request.


🚀 Next Steps

Well done on finishing this lesson. Complete the assignment, then continue with MCP Tool Design for QA: Browser, File, Test Runner and Report Tools.