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:

examples/calculator/src/calculator.ts
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).

OptionDefaultDescription
name, versionrequiredServer identity sent to clients
title, instructionsDisplay name and server-level guidance
loggerconsole.error at infoAny { debug, info, warn, error }; must not write to stdout
protocolLoggingfalseAlso forward ctx.log to the client (only when the request _meta has a logLevel)
listCachettlMs: 0, privateCache hint for tools/list (protocol 2026-07-28)
limits.toolTimeoutMs30000Default per-call timeout
limits.maxToolInputElements10000Maximum array elements plus object members in one call’s arguments

Defining a tool

FieldDescription
descriptionRequired. The model’s main guidance.
inputz.object(...); omit for a tool without arguments
outputZod schema; when set, the handler returns that type and Kervan builds structuredContent
title, annotationsDisplay name and behavior hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
timeoutMsOverrides limits.toolTimeoutMs
handler(input, ctx)Returns a string, a CallToolResult, or the output value

The tool context

MemberDescription
ctx.signalAborts 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.authAuthInfo from the HTTP layer, if any
ctx.requestId, ctx.toolName
ctx.rawThe 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() and rawResult() are experimental: their behavior may change before 1.0.

  • input and output also accept jsonSchema({...}), for schemas that come from data (another server, a spec file) rather than code. Arguments are validated against it; input must be an object schema.
  • A tool with an output schema can return rawResult(callToolResult) to send a complete result unchanged (kervan dev uses it to forward results). It skips Kervan’s normalization: no JSON text block is added and structuredContent is not built for you. The SDK still validates structuredContent against 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:

ts
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")))