MCP Server
Your app as tools a model can call — declared like endpoints, validated by the same schemas, protocol handled once.
MCP is how AI clients — Claude, Cursor, and everything speaking the protocol — call your product: you expose tools, a model reads their descriptions and calls them. The protocol underneath is JSON-RPC over HTTP with its own handshake, version negotiation, security checks, and OAuth discovery dance, and none of it is specific to your app.
mcp() handles that part once. What you write is the part no framework can: which tools exist, what they are called, and what they do.
// src/routes/mcp/server.ts
import * as v from "valibot";
import { mcp, tool } from "@implementjs/kit/mcp";
import { db } from "@/lib/db";
const getPost = tool({
name: "get_post",
description: "Fetch one post in full by its id.",
input: v.object({ id: v.string() }),
handle: async ({ input }) => await db.post(input.id),
});
export const { POST, GET, DELETE } = mcp({
serverInfo: { name: "blog", version: "1.0.0" },
instructions: "A blog. Posts are markdown; ids come from list_posts.",
tools: [getPost],
});
export const openapi = false;
That route is a complete MCP server. Point a client at https://your-app.com/mcp and it connects, lists the tools, and calls them — initialize and the protocol-version handshake, ping, tools/list, and tools/call are all answered for you.
The server is stateless and JSON-only, which the spec's Streamable HTTP transport explicitly allows: no SSE stream, no session ids, nothing pushed to the client. The route stays a pure function of one request, so it runs anywhere kit runs — the serverless adapters included.
The openapi = false is worth keeping: this route speaks JSON-RPC, and the OpenAPI document has nothing true to say about it.
Declaring tools
tool() is to a tool what handler() is to an endpoint — a schema for the input, a function for the work:
const createIssue = tool({
name: "create_issue",
description: "File a new issue. Call list_teams first if you do not know the teamKey.",
input: v.object({
teamKey: v.pipe(v.string(), v.maxLength(6)),
title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
description: v.optional(v.string(), ""),
}),
annotations: { readOnlyHint: false },
handle: async ({ input, event }) => await db.createIssue(event.locals.user, input),
});
input is anything implementing Standard Schema — the same contract handler() and defineEnv take. It does two jobs: every call's arguments are validated against it before handle runs, and tools/list converts it to the JSON Schema the model reads, through the same per-vendor detection the OpenAPI document uses. Declare no input and handle's input is undefined — an undeclared input is never read, exactly like an endpoint's undeclared body.
handle receives the validated input and the route's own RequestEvent — locals, cookies, fetch, all of it — so whatever your hooks establish for a request is there for a tool call too.
The description is the part to spend time on. A schema says what the arguments are; only prose says what the tool does, when to reach for it, and what to call first. Write it to the model, because that is who reads it. instructions on mcp() is the same thing for the server as a whole.
What a tool returns
| What the model sees | |
|---|---|
| any value | the value, serialized as JSON |
a string | the text, as it is |
undefined / null | success, with nothing to say |
tool.image(bytes, "…") | an image it can look at |
tool.audio(bytes, "…") | audio it can listen to |
tool.content(…blocks) | those blocks, in that order |
tool.structured({ … }) | the data as structuredContent, and as text |
tool.failure("…") | a failed result carrying the message |
a thrown error(404, "…") | a failed result: … (HTTP 404) — same as in an endpoint |
Failures a model can act on are failed results, not protocol errors: input the schema rejects comes back naming every issue, the same formatting an endpoint's 400 uses, so the model corrects the call and retries instead of guessing. A protocol error is reserved for a conversation that is actually broken — malformed JSON, an unknown method.
Prefer tool.failure() for the failures a tool expects — an issue that does not exist, a name already taken. Throwing works, but a return says the failure was part of the tool's contract.
Images and audio
A tool that answers with bytes — a screenshot hanging off an issue, a generated chart, a clip — returns them as the block the protocol has for them, and the model sees the picture:
const readAttachment = tool({
name: "read_attachment",
description: "Read the file behind an attachment id, rather than its metadata.",
input: v.object({ id: v.string() }),
handle: async ({ input }) => {
const file = await db.attachment(input.id);
return tool.image(file.bytes, file.mimeType);
},
});
data is base64 as a string, or Uint8Array/ArrayBuffer bytes kit encodes for you. tool.audio() is the same for a clip, and tool.content() takes as many blocks as the answer needs — a caption and the image it describes:
return tool.content(
{ type: "text", text: file.name },
{ type: "image", data: file.bytes, mimeType: file.mimeType },
);
The distinction is worth the call: base64 inside a text block is a wall of characters the model cannot read, which is the whole value of a tool that hands back a file.
Structured content
tool.structured(value) puts the answer in structuredContent — the same data as fields, for a client that would rather read them than parse them back out of the text. The JSON goes through as a text block too, which the spec asks for so a client reading only content still sees it; pass blocks after the value to say it differently there:
handle: async () => tool.structured(await db.counts(), { type: "text", text: "3 open issues" });
Reusing endpoints
Most tools are an existing endpoint wearing a description. tool.fromEndpoint() makes that spelling direct — the handler's own schemas become the tool's input, so there is nothing to restate and nothing to drift:
import * as issues from "../api/issues/[id]/server.ts";
const updateIssue = tool.fromEndpoint(issues.PATCH, {
name: "update_issue",
description: "Change an issue's title or status.",
path: "/api/issues/[id]",
});
The tool's input is an envelope of the parts the handler declares — { params, query, body } — and tools/list documents each from the handler's schemas, with the route's own path params filled in as strings where no schema narrows them.
A call dispatches through the handler by function call, not by HTTP: kit builds the request the input describes, hands it to the handler with this route's locals and cookies, and the handler's validation, permissions, and side effects run exactly as a real request's would. One implementation of every rule, no socket in between — the same property event.api has. A non-2xx response comes back as a failed result carrying the endpoint's own error message.
method defaults to POST when the handler declares a body and GET otherwise; pass it when the handler cares which verb it answered.
NOTE
Kit deliberately does not turn your whole route table into tools. Which operations a model may call, what they are named, and how they are described are product decisions — and a good tool set is curated, not generated. fromEndpoint makes the curating cheap.
Auth
An open server needs nothing. To require auth, pass authorize — it reads the event your hooks.server.ts already populated:
export const { POST, GET, DELETE } = mcp({
serverInfo: { name: "tracker", version: "1.0.0" },
tools,
authorize: (event) => event.locals.agent !== null,
});
false answers 401 with the WWW-Authenticate: Bearer resource_metadata="…" challenge RFC 9728 defines and the MCP spec requires. That header is how a client discovers where to authenticate — without it, the client does not fail loudly; it connects, shows zero tools, and offers no way to log in. authorize runs before the body is parsed, because an unauthenticated client needs the challenge, not a parse error.
The challenge points at /.well-known/oauth-protected-resource by default (resourceMetadata changes the path). Serving that document — and issuing and validating the tokens it leads to — stays your app's business: kit checks nothing itself, it asks authorize, and your hooks decide what a bearer token means the same way they decide what a session cookie means.
What the transport does for you
- Version negotiation.
initializeanswers in the client's protocol version when it is one kit speaks, so an older client is not forced to downgrade the connection itself. A request without theMCP-Protocol-Versionheader is assumed to be from a pre-header client and accepted, as the spec says. - Origin checking. The DNS-rebinding protection the transport spec requires — with the nuance that native clients are not websites:
vscode-file://vscode-app, a literal"null", and an absent header all pass, because answering them403is what makes a client show "connected, zero tools" instead of starting OAuth. - The stateless posture.
GETandDELETEanswer405— the spec's way of saying "no server-initiated stream, no session", which clients treat as a description, not a failure. Notifications get the bare202the spec asks for; batched requests are rejected, as the current protocol requires. - Arguments a model sent as text are read back. Models often spell a nested argument as the JSON text of that structure —
changes: "{\"status\":\"in_progress\"}"where the schema asks for an object — because the tool call is itself generated as text. The client cannot fix it; it has no schema. kit does, so a value is re-read as JSON when the schema leaves no room for doubt: the schema cannot accept a string there, and the parse lands on a kind it can accept. Av.string()field holding"{"stays that string, av.union([v.string(), v.object(…)])keeps the caller's spelling, and text that parses to the wrong kind is left alone so the schema rejects it with its own message. - A vendor kit does not know degrades; a converter that fails does not. A schema whose vendor kit has no converter for lists as unconstrained with a warning naming the tool — there is nothing kit can do about that one, and every call is still validated against the real schema. A converter kit does know that cannot be reached or that throws is a different thing: that is the build or the deployment being wrong, so
tools/listfails with the tool's name and the reason rather than serving arguments the model cannot see. A tool listed as{"type":"object"}is one the model calls incorrectly forever, and aconsole.warnin a serverless log is nobody's idea of a signal.
One cost to know about, the same one as api.openapi.path: a route defining tools pulls your schema library and its JSON-Schema converter into the production server bundle, because tools/list converts and tools/call validates at runtime. kit's Vite plugin puts the converter there for you — it emits a static import of every converter package your app has installed (zod, @valibot/to-json-schema), which is what makes an adapter that ships a self-contained bundle with no node_modules work at all. Install the converter your schema library needs; a devDependency is enough, since the build is what reads it.
Publishing a schema yourself
inputJsonSchema overrides input for tools/list, and is the escape hatch when kit's own conversion is not what you want — a vendor it does not know, or options it does not pass. Convert with a static import, so the bundler ships the converter, and hand the finished document over:
import { toJsonSchema } from "@valibot/to-json-schema";
const input = v.object({ id: v.string() });
const getPost = tool({
name: "get_post",
description: "Fetch one post by its id.",
input,
inputJsonSchema: async () => toJsonSchema(input, { errorMode: "ignore" }),
handle: async ({ input }) => await db.post(input.id),
});
input still validates every call — this only changes what the model is told.
Connecting a client
The server's URL is just the route. For Claude Code:
claude mcp add --transport http blog https://your-app.com/mcp
Anything else speaking Streamable HTTP configures the same URL. In dev it is http://localhost:5173/mcp, and the dev server answers it like any other route.