Plugins

Add endpoints and helpers to your API, fully typed in your handlers, on any adapter.

A plugin adds endpoints, helpers your handlers can call, or both. It gets your contract, and everything it adds is typed.

Installing one

A plugin comes in two halves. The declaration goes on new Kizuna(), beside the rest of your API's vocabulary:

src/contract/k.ts
import { Kizuna } from '@ts-kizuna/core';
import { mcpPlugin } from '@ts-kizuna/mcp';

export const k = new Kizuna({
    tags,
    identities,
    plugins: {
        mcp: mcpPlugin({
            name: 'My API',
        }),
    },
});

The server half goes on server.api, under the same key:

src/server/api.ts
import { mcpPluginServer } from '@ts-kizuna/mcp/server';

export const api = server.api({
    router,
    plugins: {
        mcp: mcpPluginServer(),
    },
});

The key you choose is the plugin's name everywhere else: handlers read it under plugins.mcp, and both halves are keyed by it. Miss the second half and server.api throws, naming the module to import.

api.mount serves its routes along with yours. There is nothing extra to mount, and it is the same on Express, Fastify, Hono and Next.js.

Why two halves

Your contract is shared: the API app imports it, and so does every client, including the ones bundled for a browser. Plugins are declared on the contract, so the declaration stays browser-safe and the server half is free to use node:fs, a database driver or a heavy SDK.

That decides where your configuration goes. Pure data describing what the plugin serves goes in the declaration:

src/contract/k.ts
plugins: {
    audit: auditPlugin({ path: '/internal/audit' }),
}

Anything live goes to the server half:

src/server/api.ts
plugins: {
    audit: auditPluginServer({ store }),
}

Using what a plugin offers

A plugin can hand your handlers functions to call, under plugins:

src/server/router/index.ts
updateUser: async ({ params, body, plugins }) => {
    const user = await db.users.update(params.id, body);
    plugins.audit.record('users.updateUser');

    return {
        status: 200,
        body: user,
    };
},

It sits beside auth.* and requestContext.*, and for the same reason: plugin names come from other people, and one called body must not shadow your request body. Destructure it if you prefer the short form:

updateUser: async ({ params, body, plugins: { audit } }) => {

A contract with no plugins gets no plugins argument at all.

Plugin routes are not your routes

A plugin's routes run through the same pipeline as yours, with the same validation, guards, access gates and problem details. But they never join contract.routes.

your routesa plugin's routes
declared withk.routesthe plugin
served by api.mountyesyes
validated, guardedyesyes
in your fetch clientyesno
in OpenAPI, Swift, Kotlinyesno

That is deliberate. A protocol endpoint has no business being a method on your generated Swift client, and keeping the two apart makes that structural rather than something you remember to exclude.

If a plugin claims a path your contract already uses, server.api throws and names both.

Next

  • Create a Plugin to write your own
  • MCP, a plugin that turns every route into an AI-callable tool

On this page