Tools
Publish a route as a tool a model can call, and read each call typed in your client.
A tool is usually written three times: a JSON Schema for the model, a typed event for the client, and a function that runs it. A route already carries all three, so say tool: true and it publishes.
Say which routes publish
tool: true takes the route's summary as what a model reads before calling, so a route that publishes has to carry one. Its input is the { params, query, body } the route already declares, and its output is the body of its 2xx response.
Say how it behaves
tool also takes an object. description, title and the four hints are MCP's own, field for field:
| Field | Meaning |
|---|---|
description | What the model reads when it decides whether to call. Defaults to summary |
title | A human-readable name for display. MCP's ToolAnnotations.title |
readOnlyHint | The route only reads. Defaults to true for GET and HEAD |
idempotentHint | Calling twice does what calling once did. Defaults to true for PUT and DELETE |
destructiveHint | The route may remove or overwrite. Defaults to true for DELETE |
openWorldHint | The route reaches something outside this API |
needsApproval | The person approves each call before it runs. Kizuna's own |
The hints come from the method's RFC 9110 semantics, so a GET is already marked read only and a DELETE already destructive. Set one to say what the method cannot.
needsApproval is the one field MCP has no way to declare. A client reads the hints and decides for itself whether to ask; needsApproval asks, and declining leaves the handler uncalled. It sits on the declaration rather than in the handler because the handler also answers HTTP, where there is nobody to ask.
needsApproval asks about what the model chose to do in someone's name, not about what they may do. A rule that depends on the input, such as a refund limit, belongs in the handler. HTTP callers meet it too, and a model reads the refusal as the tool's result, with the Problem Details that explain it:
Over MCP the person is asked through an elicitation, named after the route's summary. On a stream the call waits for them, as waiting for approval shows.
Publish them
mcpPlugin serves the endpoint. Which routes it offers is each route's own business, so it takes nothing but its name:
A route that publishes is still an ordinary route. An HTTP caller reaches GET /forecast/Oslo, a model calls weather_get_forecast, and both run the same handler through the same validation, guards and Problem Details.
Put them on a stream
Naming tools on a streamed response adds three events, typed against the routes you name:
| Event | Payload |
|---|---|
tool_call | { id, name, input } |
tool_result | { id, name, output } |
tool_error | { id, name, message } |
id ties a result back to its call, and input carries the { params, query, body } the route takes. Every route named under tools declares tool, and k.routes throws on one that does not, or on one that streams or takes a form body.
Running the calls
A handler whose stream names tools receives tools, bound to the request that is streaming. It holds the routes that response names and nothing else, so an assistant that names weatherRoutes cannot reach a route outside them.
Describe them to the model
tools.definitions has one entry per route: the name a model calls it by, the description it reads, and inputSchema as JSON Schema.
Run what the model asks for
tools.run takes a call as the model sent it and runs the route as the person who made the request. The route's own validation, guards and handler run, with the credentials the stream request carried, so a handler never passes a token along. Delegate to it with yield*: it sends tool_call, then tool_result or tool_error, and returns what the model reads.
result.content is the { status, body } the route answered with, as JSON. When the route refuses, because the input fails its schema, a guard says no, or the handler answers 4xx, result.isError is true and content carries the same Problem Details an HTTP caller would get, so the model reads why and can try again.
Wait for approval
Some calls should wait for the person. A notes assistant can read notes freely, but saving one should need a yes. needsApproval on the route says so:
This is what happens when the person types "remember the demo is on Friday":
-
The model asks to call
notes_add. Kizuna does not run it. The stream sends the call markedneedsApproval, andtools.runreturnsneeds-approval, so the loop above ends the stream: -
The app asks the person.
readToolCallsgives the call as a row withstate: 'needs-approval'and the note ininput, so the app can put it in its own words. -
They tap Yes. The app sends a second request carrying the call it showed and the answer.
-
The server runs that call. The note is saved as the signed-in person,
tool_resultstreams back, and the model carries on.
Had they tapped No, nothing is saved, and the model reads Declined, so nothing ran.
The call comes back from the app in step 3 rather than from the model a second time. The server keeps nothing between the two requests, and a model asked again would make a new call, with a new id and maybe different wording. Sending back what the person saw means exactly that runs.
On the client
Ask on a needs-approval row, and send the row back with the answer:
send starts the next request to the same route.
On the server
Take the answer in the body with ToolAnswerSchema:
When one arrives, run its call before asking the model anything, and put the result in the conversation. result.call is the call as the model knows it:
The call runs as the person who sent the answer, through the route's guards, so an edited answer can do nothing their own request could not.
Reading the calls
A call arrives as one event and its answer as another. readToolCalls folds them back into one row per call, carrying the tool's name, a state of running, needs-approval, done or failed, and the payloads narrowed to that tool.
TypeScript imports it from kizunajs; the Swift and Kotlin generators emit it beside the route's Event type.
streamOptions from @kizunajs/tanstack-query already holds the messages received so far, so there is no state to keep at all.
Reaching one tool payload
Narrow on the tool name where you want its own data. The generated types carry the payload, so there is no cast.
Rename a field on the route and every one of these stops compiling.
Publishing over MCP
A route that declares tool already has a name, a description, an input schema, an output schema and a handler, which is what an MCP tool is. mcpPlugin serves them:
A route names itself users_get_user over MCP, from its dotted key. If it needs an identity, the same guard runs on a tool call as on an HTTP request, and a refusal reaches the model as an execution error.
See MCP for the rest of the endpoint, including OAuth.
How it works underneath
Nothing here is a new transport. Every piece reduces to something Kizuna already had.
The events are folded into the stream at k.routes time. Before a route is validated, expandStreamTools turns each route you named into three Zod schemas and merges them into the response's stream record, then moves the tools field under a symbol, where the handler's tools reads it. From that point on the response is an ordinary named-event stream, which is why the four adapters, the OpenAPI generator, the fetch client, and the Swift and Kotlin generators needed no changes to carry tools.
Each event is a discriminated union on the route's dotted key. tool_call is z.discriminatedUnion('name', [...]) with one arm per route, each carrying that route's own input. The type-level side walks the same tree to the same dotted keys, so message.data.input narrows in TypeScript, the Swift generator emits an enum with one case per tool, and the Kotlin generator emits a sealed interface.
The dotted key is the discriminator, not the published name. weather.getForecast on the wire, weather_get_forecast over MCP. Deriving the snake-case name at the type level would need a string algorithm that has to agree exactly with the runtime one, and disagreeing silently would be worse than reading the name off a definition.
Execution is the route's own pipeline. A tool call runs the route handler through the same validation, guards and Problem Details an HTTP request does, so there is one place a route runs whoever asked. On a stream, tools.run enters that pipeline after routing, with the stream request's headers and the adapter's handler context, and the tool's input in place of the URL and body.
What Kizuna.js leaves out
- Models. Kizuna never calls one, holds a conversation, or runs an agent loop. It validates input, runs a handler, validates output. The loop lives in your route handler, the way a job's transport lives outside kizuna.
- Provider wire shapes. There is no
toAnthropicToolsortoOpenAITools, because those track a vendor changelog.tools.definitionshands you a name, a description and JSON Schema, and the.mapto a provider is a few lines you can read. - Progressive tool input. A model streams a tool's arguments as partial JSON, which has no schema until it is whole. A half-parsed
{ city: "Os" }would type-check as a finished value while being wrong, so Kizuna emitstool_callonce the arguments are complete.
A note on standards
The declaration follows the Model Context Protocol Tool object, and its schemas follow JSON Schema 2020-12. The three event names are Kizuna's own, since no standard covers streamed tool calls. See standards.