Middleware

Middleware wraps tool calls. App middleware runs for every tool, a tool’s own middleware only for that tool.

Where this fits: Build with TypeScript. Middleware runs inside each call, between argument validation and your handler; errors are mapped after it.

On this page

Around every call, or one tool’s

ts
import { createApp, type ToolMiddleware, ToolError, z } from "@kervan/core"
import { createTestClient } from "@kervan/transport/testing"

const app = createApp({ name: "middleware", version: "0.1.0" })

// For every tool: how long each call took, in the server's log.
app.use(async (call, next) => {
  const started = Date.now()
  try {
    return await next()
  } finally {
    app.logger.info(`${call.tool.name} took ${Date.now() - started} ms`)
  }
})

// For one tool: refuse callers without the admin scope (ctx.auth comes from authenticate).
const requireAdmin: ToolMiddleware = async (call, next) => {
  if (!call.ctx.auth?.scopes.includes("admin")) throw new ToolError("This tool needs the admin scope.")
  return next()
}

app.tool("reset_counter", {
  description: "Resets the counter.",
  input: z.object({ to: z.number().int().default(0) }),
  middleware: [requireAdmin],
  handler: ({ to }) => `counter is ${to}`,
})

const client = await createTestClient(app)
const result = await client.callTool({ name: "reset_counter", arguments: {} })
console.log((result.content as { text: string }[])[0]?.text)
await client.close()

The test client has no authInfo, so the admin check refuses the call; the timing middleware still logs it ([kervan] info: reset_counter took 0 ms on stderr). To test a caller with scopes, pass authInfo to createTestClient (testing).

The rules

  • Order: app middleware in app.use order, then the tool’s middleware, then the handler. Results unwind in reverse.
  • A middleware sees the validated call.input, call.tool (name, title, description, annotations) and call.ctx. It can return a result without calling next() (short-circuit) or change the result next() returns.
  • Errors travel through the chain as exceptions and are mapped once, at the outside: ToolError messages reach the client, anything else is masked. Timeouts and cancellation cover the whole chain. Calling next() twice is an error.
  • app.use applies immediately, also to connected clients and to tools served from other registries (resolveServer). Filtering which tools a caller sees is resolveServer’s job, not middleware’s. For HTTP-level middleware, use handler.hono.