MCP server
Overview
The Model Context Protocol (MCP) is how AI assistants call software. Enabling
the kit's MCP server publishes your product's tools at /api/mcp, so Claude,
ChatGPT, an agent framework, or your own AI chat can read and act on your data
with the user's permission.
The kit is both sides of the protocol. It has always been an MCP client (the AI chat can consume other servers — see AI); this is the server half.
MCP_SERVER_ENABLED=true
# plus at least one way for clients to authenticate:
AUTH_API_KEYS_ENABLED=true # scripts, agents, your own backend
MCP_OAUTH_ENABLED=true # Claude Desktop, claude.ai, ChatGPT connectors
With the flag off, /api/mcp returns 404. There are no Docker or deployment
changes: the endpoint is an ordinary route in the web app.
Declaring tools
Tools live in the product seam src/product/mcp.ts — a file the kit creates
once and never touches again. Write them with defineMcpTool, which derives
the JSON Schema clients see and the validation the server runs from one Zod
schema:
import { z } from "zod";
import { defineMcpTool, type ProductMcpDefinition } from "@/lib/mcp/types";
import { jsonResult } from "@/lib/mcp/errors";
export const productMcp = {
tools: [
defineMcpTool({
name: "list_invoices",
title: "List invoices",
description:
"Lists the organization's invoices, newest first. Use it to answer questions about billing history.",
inputSchema: z.object({
limit: z.number().int().max(100).default(20),
status: z.enum(["paid", "open", "void"]).optional(),
}),
annotations: { readOnlyHint: true },
execute: async (ctx, input) => {
const invoices = await findInvoices(ctx.auth.organizationId, input);
return jsonResult({ invoices });
},
}),
],
} satisfies ProductMcpDefinition;
ctx.auth carries the authenticated principal: userId, organizationId,
scopes, and how they authenticated. Scope every query by
ctx.auth.organizationId exactly as you would in a tRPC procedure — an MCP
call is no less a request from a tenant.
The description is the interface
A tool's description is what the model reads to decide whether to call it,
and it is the text find_tools searches. Write it as instructions to a
capable colleague who cannot see your code: what it does, when to reach for
it, and anything it will not do.
Annotations
| Annotation | Effect |
|---|---|
readOnlyHint | The tool changes nothing. It skips the audit entry, and read-only API keys may call it. |
destructiveHint | Goes through the confirmation gate (below). |
idempotentHint | Advisory; surfaced to clients. |
A tool with no readOnlyHint counts as mutating, so forgetting the
annotation fails closed rather than open.
The kit's own tools
Some tools every MCP server needs. The kit ships them, configured in
src/config/mcp.config.ts — you never write these:
Deferred discovery — find_tools, tool_schema, run_tool
Assistants degrade when a server advertises hundreds of tools: the tool list
consumes the context window, and answer quality drops. Deferred discovery
solves it. List only the essentials with topLevel: true; mark the long tail
topLevel: false and the model finds them on demand — searching with
find_tools, fetching arguments with tool_schema, and calling through
run_tool.
Listing and permission are separate axes. topLevel: false is a
context-budget decision; the tool stays callable by name. Never rely on hiding
a tool to protect it — authorize inside the tool.
metaTools: { discovery: true } // the three ship together
discoveryResultLimit: 12 // or MCP_DISCOVERY_RESULT_LIMIT
Per-credential visibility
topLevel also takes a predicate, evaluated per request against the same
context your tools receive. That is how a multi-tenant catalogue lists
differently for two credentials — a tenant's enabled features, a key's
allow-list, a read-only credential:
topLevel: (ctx) => ctx.auth.scopes.includes("write"),
The two forms differ in reach, on purpose:
topLevel | tools/list | find_tools |
|---|---|---|
true | listed | found |
false | not listed | found — this is what deferred discovery is for |
predicate → false | not listed | not found |
A tool the predicate hides is one this credential can never use, so surfacing it in discovery would only spend tokens on a call destined to fail. Neither form is a permission: a hidden tool is still callable by name. The predicate must be pure and must not throw — it runs on every listing.
File uploads — request_file_upload
MCP has no streaming upload, and base64 in a tool argument exhausts the
context window on anything larger than an icon. This tool returns a
short-lived presigned PUT URL (plus a ready-to-run curl command, since the
signature covers Content-Type); the agent uploads out of band and passes the
returned key to your tool.
Off by default and requires storage to be configured; without a bucket it
reports not_configured and tells the agent to ask the user for a public URL
instead. The per-kind MIME allow-list is deliberately stricter than the kit's
own upload routes — those sit behind a human with a file picker, this one is
driven by a model acting on text it read somewhere.
metaTools: { upload: true }
upload: { ttlSeconds: 900, kinds: { image: [...], document: [...] } }
Some integrations invert the direction: the target system downloads the
file rather than accepting an upload. Turn on upload.returnDownloadUrl and
the result carries a downloadUrl — a presigned GET valid for the same TTL,
usable the moment the upload finishes. Off by default, because it is a
readable link the model can pass on.
list_organizations
Lets the model resolve the organizationId your tools take, instead of
guessing. Off by default.
Namespacing
If an assistant connects to several servers at once, generic names collide.
namePrefix: "acme_" renames every tool, product and kit alike
(acme_find_tools).
Authentication
API keys
Reuses the kit's API keys: the same sk_… token, revocation,
expiry, read/write scopes and organization binding. Clients may send it as
x-api-key or Authorization: Bearer sk_… — most MCP clients can only do the
latter.
A key with only the read scope cannot call a mutating tool.
Scopes
The read/write check applies to every authentication method, not just
API keys — an OAuth or product principal carrying scopes: ["read"] is
read-only too.
A principal naming neither scope is unrestricted. That is deliberate:
kit-issued OAuth tokens carry only the OIDC scopes (openid profile email),
and reading those as "no permissions" would lock out every assistant that
connects over OAuth. Enforcement begins the moment a credential names read
or write, which is what your authenticate hook should do if you want a
restricted machine credential.
OAuth 2.1
Claude Desktop, claude.ai and ChatGPT connectors cannot send a custom header,
so they need OAuth. Turning on MCP_OAUTH_ENABLED makes your app an
authorization server: a client discovers it from the 401 the MCP endpoint
returns, registers itself, sends the user through your sign-in page and a
consent screen, and gets an access token.
The user connects by pasting https://your-app.com/api/mcp into the
assistant's connector settings — everything else is discovery.
Migrating from your own OAuth wiring: if your product previously
registered Better Auth's mcp plugin through src/product/auth-plugins.ts,
remove it when you enable this flag. The kit registers the plugin itself, and
registering it twice conflicts.
Safety
- Confirmation gate. Tools marked
destructiveHintreturn a preview instead of acting unless called withconfirm: true, so destructive intent has to be stated twice and the user sees what would happen in between. It is a speed bump, not a permission check — authorize inside the tool as well. The kit injectsconfirminto the schema it serves for a gated tool and strips it again before your validator runs, so a raw spec compiled withadditionalProperties: falseworks unchanged. Declareconfirmin your own schema and the kit leaves it alone — it is yours. - Rate limits, applied before authentication so an unauthenticated flood
costs no database lookup: 120/min per credential (bucketed by hash — the raw
token never reaches the store) and 240/min per IP. Both are overridable per
deploy (
MCP_RATE_LIMIT_PER_CREDENTIAL_PER_MIN,MCP_RATE_LIMIT_PER_IP_PER_MIN). - Body cap of 1 MiB.
- Audit trail. Every non-read-only call is recorded as
mcp.tool_executedwith the arguments hashed, never stored — enough to correlate repeated calls, useless as a leak. If the credential names a delegated subject, it is recorded too (see Your own credential scheme). - Errors reaching the model are either a known domain error or a generic line; internals are logged, never returned.
Metering
Charge credits per call by declaring them on the tool:
credits: { amount: 5, reason: "mcp.generate_report" }
The kit reserves the credits before running, refunds them if the tool throws, and settles otherwise. Tools that meter their own usage — by real token counts, say — omit this and call the credits helpers directly.
Connecting a client
Claude Desktop / claude.ai / ChatGPT (OAuth): add
https://your-app.com/api/mcp as a connector and sign in when prompted.
Anything that can set headers (agents, scripts, the MCP Inspector): use an API key.
npx @modelcontextprotocol/inspector
# URL: https://your-app.com/api/mcp
# Header: Authorization: Bearer sk_…
Advanced
Widening the context
createContext builds what every tool receives — add an org-scope resolver, a
domain client, a decrypted tenant credential. authorize runs once per
request and gates the whole server (a plan entitlement, say). onToolCall is
a fire-and-forget hook for your own usage metering.
Its event carries errorCode when a call fails, so per-call telemetry can tell
a validation slip from an upstream outage. The kit's own codes are
scope_denied, invalid_arguments, confirmation_required, not_found,
forbidden, unauthorized, bad_request, conflict, rate_limited and
internal_error; an error your execute throws carrying a string code
passes through unchanged. There is no code when a tool simply returns
isError: true — the kit has nothing to classify.
Your own credential scheme
authenticate replaces the kit's credential resolution. It runs first; return
an McpAuth to accept the request or null to fall through to API keys and
OAuth.
Refusing with a reason. Returning null means "not my credential". To say
"mine, and rejected", return a rejection instead — it stops there and the
message reaches the client verbatim:
return { reject: { message: "This key was revoked. Issue a new one.", code: "key_revoked" } };
A revoked credential, an expired one and a blocked subscription are actionable
states, and one shared "Authentication required" sends the operator looking for
a key they already have. code rides along as the error's data.code. The
kit's own paths stay deliberately opaque — an unknown credential must remain
indistinguishable from a malformed one — so never use this to confirm that a
credential exists.
Recording who it acts for. McpAuth describes the credential, which is
right for a machine key and wrong for one a person delegated. If your flow
establishes a human — an OAuth hop that signs them in on your own system, say
— attach them as subject:
subject: { id: "42", kind: "moodle-user", label: "Ada Lovelace" }
The kit treats it as opaque: it never resolves against the user table and
never becomes actorUserId, because the subject is not a kit user. It goes
into the audit entry's metadata, and onToolCall receives it on auth.
/admin/audit-logs shows the label (or the id) in the Actor column instead of
"System".
label is a person's name in a table kept for two years, so the kit treats it
as PII: the retention job strips it at 90 days, on the
same pass that drops IP and user-agent. id and kind survive with the row.
McpAuth is discriminated by method. The kit's own methods (apiKey,
oauth) always resolve a real user, so userId is a string there. A
product principal may set userId: null — a machine credential with no human
behind it, which is honest in the audit trail (actorUserId is nullable)
rather than attributed to an invented user. One consequence: a tool declaring
credits needs a user-bound credential, because a credit hold resolves the
spend source from a member. Meter such tools yourself if machine credentials
must reach them.
Your own authorization server
The 401 an unauthenticated request gets is what bootstraps OAuth: its
WWW-Authenticate header tells the connector where to look. The kit's default
points at APP_URL's protected-resource metadata, and emits nothing at all
when MCP_OAUTH_ENABLED is off.
Neither is right for a product that runs its own authorization server, or
serves per-organization subdomains — the challenge has to come from the
request's Host. unauthorizedHeaders replaces the kit's:
unauthorizedHeaders: (req) => ({
"WWW-Authenticate": `Bearer resource_metadata="https://${new URL(req.url).host}/.well-known/oauth-protected-resource"`,
"Access-Control-Expose-Headers": "WWW-Authenticate",
}),
It replaces rather than merges, so returning {} sends no challenge at all.
Running MCP in a separate process
All of the logic is a plain Request → Response function, and the Next route
is a thin shim over it:
import { handleMcpRequest } from "@/lib/mcp/handler";
import { productMcp } from "@/product/mcp";
const response = await handleMcpRequest(request, productMcp);
Some products cannot serve MCP from the web app: a dedicated port, per-organization subdomains, or a single-replica constraint — an in-process queue, a stateful upstream connection — that the web app's horizontal scaling would break. For those, the kit ships a standalone entrypoint:
// mcp-server.ts, launched on its own
import { createMcpNodeServer } from "@/lib/mcp/node-server";
import { productMcp } from "@/product/mcp";
const server = createMcpNodeServer(productMcp, {
path: "/mcp", // default; a list serves several
healthzPath: "/healthz", // default
healthz: async () => queue.isHealthy(),
});
server.listen(Number(process.env.MCP_PORT ?? 8080));
It adapts node:http requests to Request and back, and calls the same
handleMcpRequest — rate limiting, auth, the confirmation gate, metering and
the audit trail are identical to /api/mcp. Listening is deliberately left to
you, so the port, signal handling and shutdown sequence stay yours.
The request URL is rebuilt from the incoming Host header, so a product
serving acme.mcp.example.com sees that host in createContext(base, req) and
can resolve the tenant from it. x-forwarded-for is filled in from the socket
peer when no proxy set it, so the per-IP rate limit still applies to direct
connections — an existing header is left alone, since the proxy is the one that
knows the real client.
Migrating off a hand-written shim? Pass a list to keep the old path alive while
clients move over — path: ["/mcp", "/api/mcp"]. The shim's own failures (an
unknown path, an oversized body, an unexpected throw) answer in JSON-RPC shape,
like every other error the endpoint produces.
One caveat if you launch the entrypoint with tsx or plain node rather than
through Next: the MCP modules import the server-only marker package, which
only Next resolves. Alias it to an empty module (a tsconfig path plus a stub
file, the same trick the kit's test setup uses).