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.