Skip to main content
Documentation

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

AnnotationEffect
readOnlyHintThe tool changes nothing. It skips the audit entry, and read-only API keys may call it.
destructiveHintGoes through the confirmation gate (below).
idempotentHintAdvisory; 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:

topLeveltools/listfind_tools
truelistedfound
falsenot listedfound — this is what deferred discovery is for
predicate → falsenot listednot 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 destructiveHint return a preview instead of acting unless called with confirm: 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 injects confirm into the schema it serves for a gated tool and strips it again before your validator runs, so a raw spec compiled with additionalProperties: false works unchanged. Declare confirm in 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_executed with 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).