Tools in TypeScript
When a tool needs logic, write it in TypeScript. @kervan/core builds the official SDK server; you write the handler.
Where this fits: Build with TypeScript. The first page of the group: the code API. Errors, middleware, the registry and testing follow; the programmatic API lists every export.
On this page
A complete server
This program defines a tool, then calls it the way a client would, through createTestClient
(in memory, no network). It is examples/calculator/src/calculator.ts in the repository; from a
clone, node examples/calculator/src/calculator.ts prints sum: 5. In a project made by
kervan create (or pnpm try:new), save it as src/calculator.ts and run it the same way:
import { createApp, ToolError, 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() }),
annotations: { readOnlyHint: true },
handler: ({ a, b }) => {
if (!Number.isFinite(a + b)) throw new ToolError("The sum is too large.")
return { sum: a + b }
},
})
const client = await createTestClient(app)
const result = await client.callTool({ name: "add", arguments: { a: 2, b: 3 } })
console.log(`sum: ${(result.structuredContent as { sum: number }).sum}`)
await client.close()To serve it instead, replace the last four lines with await serve(app) from
@kervan/transport/node (see transports).
| Option | Default | Description |
|---|---|---|
name, version | required | Server identity sent to clients |
title, instructions | Display name and server-level guidance | |
logger | console.error at info | Any { debug, info, warn, error }; must not write to stdout |
protocolLogging | false | Also forward ctx.log to the client (only when the request _meta has a logLevel) |
listCache | ttlMs: 0, private | Cache hint for tools/list (protocol 2026-07-28) |
limits.toolTimeoutMs | 30000 | Default per-call timeout |
limits.maxToolInputElements | 10000 | Maximum array elements plus object members in one call’s arguments |
Defining a tool
| Field | Description |
|---|---|
description | Required. The model’s main guidance. |
input | z.object(...); omit for a tool without arguments |
output | Zod schema; when set, the handler returns that type and Kervan builds structuredContent |
title, annotations | Display name and behavior hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) |
timeoutMs | Overrides limits.toolTimeoutMs |
handler(input, ctx) | Returns a string, a CallToolResult, or the output value |
The tool context
| Member | Description |
|---|---|
ctx.signal | Aborts on client cancellation or timeout. Pass it to fetch and other I/O. |
ctx.progress(value, total?, message?) | Sends progress if the client asked for it. Values must increase. |
ctx.log.debug/info/warning/error(message, data?) | Server logger (stderr) |
ctx.auth | AuthInfo from the HTTP layer, if any |
ctx.requestId, ctx.toolName | |
ctx.raw | The SDK context, for anything Kervan does not wrap yet |
Errors, middleware and runtime changes
Each has its own page: errors (ToolError and masking),
middleware (code around every call) and
a registry that changes at runtime (list_changed).
JSON Schema and raw results (experimental)
jsonSchema()andrawResult()are experimental: their behavior may change before 1.0.
inputandoutputalso acceptjsonSchema({...}), for schemas that come from data (another server, a spec file) rather than code. Arguments are validated against it;inputmust be an object schema.- A tool with an
outputschema can returnrawResult(callToolResult)to send a complete result unchanged (kervan devuses it to forward results). It skips Kervan’s normalization: no JSON text block is added andstructuredContentis not built for you. The SDK still validatesstructuredContentagainst the output schema for non-error results, so a missing or invalid one becomes an “Output validation error”.
A spec tool in code
Every kervan.yaml tool compiles to an ordinary tool definition. To mix both in one server,
load a spec into the app’s registry:
import { readFile } from "node:fs/promises"
import { createApp } from "@kervan/core"
import { applySpec, loadSpec } from "@kervan/spec-runtime"
const app = createApp({ name: "weather", version: "0.1.0" })
applySpec(app.registry, await loadSpec(await readFile("kervan.yaml", "utf8")))