Testing

createTestClient connects the official MCP client to an app in memory: no process, no port, the same protocol a real client speaks.

Where this fits: Build with TypeScript. Tests call tools the way clients do, so they cover validation, middleware and errors too.

On this page

Both protocol eras

ts
import assert from "node:assert/strict"
import { createApp, z } from "@kervan/core"
import { createTestClient } from "@kervan/transport/testing"

const app = createApp({ name: "calculator", version: "0.1.0" })
app.tool("add", {
  description: "Adds two numbers.",
  input: z.object({ a: z.number(), b: z.number() }),
  output: z.object({ sum: z.number() }),
  handler: ({ a, b }) => ({ sum: a + b }),
})

// The same checks for a 2026-07-28 client and a 2025-era one, in memory.
for (const era of ["modern", "legacy"] as const) {
  const client = await createTestClient(app, { era })
  const result = await client.callTool({ name: "add", arguments: { a: 2, b: 3 } })
  assert.deepEqual(result.structuredContent, { sum: 5 })
  // Arguments that do not fit the input schema never reach the handler.
  const refused = await client.callTool({ name: "add", arguments: { a: "two", b: 3 } })
  assert.equal(refused.isError, true)
  await client.close()
  console.log(`${era}: passed`)
}

A project made by kervan create has the same kind of test in test/app.test.ts, run with Vitest (npm test); any test runner works.

Options

ts
import { createTestClient } from "@kervan/transport/testing"

const client = await createTestClient(app) // era: "modern" (default) or "legacy"
await client.callTool({ name: "add", arguments: { a: 1, b: 2 } })
await client.close()

This needs @modelcontextprotocol/client as a (dev) dependency. Pass authInfo to test tools that read ctx.auth, registry to serve another tool set, and client for SDK client options such as listChanged. Both eras receive list_changed.