MCP
Install the MCP plugin so AI assistants can call your API as tools, on any adapter.
MCP support is new and still settling. The plugin options and the published tool surface may change while v2 is in beta, so pin your version if you depend on them.
@kizunajs/mcp adds an MCP (Model Context Protocol) endpoint to your API. Every route that declares tool is one an AI assistant can discover and call.
pnpm add @kizunajs/mcp@betabun add @kizunajs/mcp@betanpm install @kizunajs/mcp@betaSay which routes a model may call
A route publishes itself. tool: true takes the route's summary as what a model reads before calling:
getForecast: k
.route({
method: 'GET',
path: '/forecast/:city',
summary: 'Look up tomorrow forecast for one city',
tool: true,
responses: {
200: ForecastSchema,
},
})
.handler(/* ... */),See Tools for the rest of what tool takes.
Install the plugin
MCP is a plugin, named on defineConfig and served by api.mount:
import { defineConfig } from 'kizunajs';
import { mcpPlugin } from '@kizunajs/mcp';
import { routes } from './src/routes';
export default defineConfig({
adapter: expressAdapter(),
routes,
plugins: [
mcpPlugin({
name: 'My API',
}),
],
});The endpoint answers at /mcp by default. On Next.js it is served by the catch-all route file that already serves your routes, so with basePath: '/api' it lands at /api/mcp.
Which routes publish
Each route decides, so the plugin takes no list. A route with no tool is an HTTP endpoint and nothing more.
Two shapes never publish, even when they say tool:
- A route whose
contentTypeismultipart/form-dataorapplication/x-www-form-urlencoded, because tool input is JSON. - A route whose response streams, because a tool result is one value.
What to leave unpublished
Every published tool's name, description, and input schema sits in the model's context on every turn, called or not, and the more tools there are the more often the model picks the wrong one. So publish deliberately:
- What an assistant cannot use. File uploads, CSV exports, anything returning bytes.
- What exists for your own infrastructure. Health checks, readiness probes, webhook receivers.
- What you do not want reached by inference. Deleting a workspace is a person's decision, guard or no guard. If it does publish, give it a
confirm.
What each tool looks like
| Part | Where it comes from |
|---|---|
| Name | The route key in snake_case, so users.getUser becomes users_get_user |
| Title | tool.title |
| Description | tool.description or the route's summary, then its description, its method and path, and what it requires |
| Input | { params, query, body }, holding only the keys the route declares |
| Output | The success response's schema, so an assistant knows the shape before it calls |
| Annotations | The method's HTTP semantics, per RFC 9110, and anything tool overrides |
The specification allows the dot, but Claude Code and VS Code rewrite it before the name reaches the model, so publishing the rewritten form is what keeps your docs and the model's tool name the same. Names are capped at 128 characters. A key over that throws at startup, as do two keys that converge on one name.
The annotations tell a client how careful to be with a tool before calling it:
| Method | Hints set |
|---|---|
GET, HEAD, OPTIONS | readOnlyHint, idempotentHint |
PUT | idempotentHint |
DELETE | idempotentHint, destructiveHint |
POST, PATCH | none |
An absent hint is not a neutral one. MCP defaults destructiveHint and openWorldHint to true and readOnlyHint to false, so a client already treats POST and PATCH as destructive calls against an open world. The hints worth setting are the ones that make a tool safer than that, which is why GET gets readOnlyHint and POST gets nothing. Set one on the route's tool to say what the method cannot.
Handlers receive the same framework context they do over HTTP, passed through from the transport request, so one reading req or c keeps working as a tool.
What the model reads
From each route
A route's summary and description are what the tool carries:
searchUsers: k
.route({
method: 'GET',
path: '/users/search',
summary: 'Search users by name or email',
description: 'Matches a name or email substring, case insensitive. Returns at most 25 users, best match first.',
tool: true,
query: z.object({
term: z.string(),
}),
responses: {
200: z.array(UserSchema),
},
})
.handler(/* ... */),The tool that reaches the model carries both, followed by the request line and the identities the route requires:
Search users by name or email
Matches a name or email substring, case insensitive. Returns at most 25 users, best match first.
HTTP: GET /users/search
Requires: userThese are the same two fields that become operation.summary and operation.description in your OpenAPI document, and the doc comment on your generated Swift and Kotlin methods, which uses the summary and falls back to the description. Write them once for every reader. OpenAPI wants a short summary and a verbose account of the behaviour, which is what a model wants too, so naming the limit, the ordering, and the matching rule costs nothing and serves all three.
When the wording a model needs differs from the wording your API reference needs, tool.description overrides the summary for the model alone:
tool: {
description: 'Find a user before creating anything that belongs to one. Prefer this over listing every user.',
},From the whole server
instructions is a single string clients put in front of the model before it picks any tool. Kizuna builds one from your tags, so the groups in your API arrive with their titles and descriptions, and your own text is appended to it:
mcpPlugin({
name: 'My API',
instructions: 'Resolve a workspace before creating members. Every timestamp is UTC.',
}),Use it for what belongs to no single route: the order operations happen in, the conventions every route shares, which tool to reach for when two look alike.
Authentication
Secured tools authenticate via the headers of the MCP transport request. The endpoint runs the guards that sit on your identities, exactly like the HTTP pipeline: the credential is extracted from the headers, the guard verifies it, the route's auth is checked, and the handler receives its typed identity context.
A route's identities appear in its tool description, along with the roles and permissions that go with them, so an assistant can tell it will be refused without spending a call to find out:
Delete the workspace
HTTP: DELETE /workspace
Requires: member
Roles: ownerConfigure the MCP client to send the credential as a header on the connection. For example, with Claude Code:
claude mcp add --transport http my-api http://localhost:3000/mcp --header "Authorization: Bearer <token>"For an apiKey identity, send the header the identity names (e.g. x-workspace-token) instead.
When a guard denies the call, or the caller's role is not one the route accepts, the tool call returns an error result (isError: true) carrying the status and detail, so the assistant sees exactly why it was refused:
{
"status": 401,
"body": {
"detail": "Unauthorized"
}
}Routes with auth: false run without guards, credentials or not.
Asking before it runs
A route's confirm puts the question to the person before the handler is reached, over MCP's elicitation:
tool: {
confirm: 'This deletes the workspace and everything in it. It cannot be undone.',
},The first call answers with the elicitation rather than a result. Agreeing runs the handler; declining leaves it uncalled and answers isError: true.
OAuth
Point oauth at your oauth2 identity and the endpoint serves the discovery and challenges the MCP authorization specification asks of an OAuth 2.1 resource server. MCP clients log in through your authorization server (better-auth, Auth0, Keycloak) on their own.
mcpPlugin({
oauth: {
resource: 'https://api.example.com/mcp',
scheme: 'user',
},
}),resource is the canonical URI of the endpoint as clients reach it, and the audience your guard checks tokens against. scheme names the identity whose guard verifies the token on every request. The identity's issuer and flow scopes become the RFC 9728 metadata document, served at /.well-known/oauth-protected-resource/mcp.
With oauth configured, denials from that identity move to the HTTP level, where the specification puts them:
- No token, or a bad one, answers
401withWWW-Authenticate: Bearer resource_metadata="...". - A token that lacks a permission its role holds answers
403witherror="insufficient_scope"and the permissions it is missing. - A role that lacks a permission the route requires answers a plain
403.
The guard runs once per request, its context reaches handlers under auth.<scheme> as always, and every other identity keeps the in-band tool errors above.
Options
| Option | Default | Description |
|---|---|---|
slug | 'mcp' | What handlers reach this plugin under. Give a second endpoint its own |
path | '/mcp' | Path the endpoint is served from |
name | 'MCP Server' | Human-readable name shown to AI assistants |
version | '1.0.0' | Semantic version string |
instructions | built from your tags | Appended to the generated overview |
oauth | off | Serve the endpoint as an OAuth 2.1 resource server (OAuth) |
Connecting clients
Claude Code
claude mcp add --transport http my-api http://localhost:3000/mcpVS Code
{
"mcp.servers": {
"my-api": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp"]
}
}
}Cursor
{
"mcpServers": {
"my-api": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp"]
}
}
}Tool input structure
Tool inputs use structured keys that mirror the Kizuna handler args:
{
"params": { "id": "42" },
"query": { "page": 1, "limit": 25 },
"body": { "name": "Alice", "email": "alice@example.com" }
}Only the keys the route declares are present.
Tool responses
Every tool returns a JSON text block with the HTTP status and parsed response body:
{
"status": 200,
"body": {
"id": "42",
"name": "Alice"
}
}The same body is attached as structuredContent, matching the output schema the tool advertised, so a client can read the response as data rather than parsing the text.
Responses with status >= 400 set isError: true on the MCP result so the AI knows the call failed.
Standalone server
If you want to drive the transport yourself, for stdio or SSE, createMcpServer returns a raw McpServer:
import { createMcpServer } from '@kizunajs/mcp';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import kizuna from '../kizuna.config';
const server = createMcpServer(kizuna.api, {
name: 'My API',
version: '1.0.0',
});
await server.connect(new StdioServerTransport());It takes one option the plugin does not: onlyReadOnly keeps only the methods RFC 9110 calls safe, so nothing the assistant reaches can change data.
Reference
mcpPlugincreateMcpServer- Tools for what a route's
tooldeclares - Create a Plugin to write your own