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.useorder, then the tool’smiddleware, then the handler. Results unwind in reverse. - A middleware sees the validated
call.input,call.tool(name, title, description, annotations) andcall.ctx. It can return a result without callingnext()(short-circuit) or change the resultnext()returns. - Errors travel through the chain as exceptions and are mapped once, at the outside:
ToolErrormessages reach the client, anything else is masked. Timeouts and cancellation cover the whole chain. Callingnext()twice is an error. app.useapplies immediately, also to connected clients and to tools served from other registries (resolveServer). Filtering which tools a caller sees isresolveServer’s job, not middleware’s. For HTTP-level middleware, usehandler.hono.