mcp-oauth-kit
v0.3.0
Published
A production-minded MCP server kit: OAuth/PKCE, rate limiting, scope-gated tools, and two-phase confirm — bring your own tools, identity, and storage.
Maintainers
Readme
mcp-oauth-kit
A production-minded MCP server kit: OAuth 2.1/PKCE, rate limiting, scope-gated tools, and two-phase confirm — bring your own tools, identity, and storage.
⚠️ Status: pre-1.0 (
0.3.0). The OAuth core is independently reviewed and tested, but the API may still change and there are deliberate design limitations to understand before production use — most importantly: single-use code redemption, refresh-token rotation, and confirm idempotency are best-effort, not exactly-once on a storage backend without compare-and-swap; and per-IP rate limiting needs a correctipExtractorwhen you're not behind Cloudflare. Read SECURITY.md before deploying, and pin a version.
New here? Start with the How-to-use guide for a step-by-step walkthrough (including an end-to-end OAuth flow you can run with curl). This README is the full config/API reference.
Install
npm install mcp-oauth-kitPeer dependencies (not bundled):
npm install hono @modelcontextprotocol/sdk zodNode 22+ is required, and your project must be ESM ("type": "module" in
package.json) — the package publishes an ESM-only exports map with no require condition.
Quick start
import { z } from "zod";
import { createMcpServer, createMemoryStorage, defineTool } from "mcp-oauth-kit";
const app = createMcpServer({
baseUrl: "https://mcp.example.com",
storage: createMemoryStorage(), // swap for createCloudflareKvStorage in production
scopes: [
{ name: "account:read", default: true },
{ name: "write", default: true },
],
identity: {
fields: [
{ name: "email", label: "Email", type: "email", required: true },
{
name: "code",
label: "Verification Code",
type: "text",
required: true,
},
],
verify: async (fields) => {
// Return a stable userId string on success, null to reject.
const user = await db.lookupUser(fields.email, fields.code);
return user ? user.id : null;
},
},
tools: [
defineTool({
name: "list_slots",
description: "List available appointment slots for today.",
inputSchema: z.object({}),
handler: async () => "09:00, 10:00, 11:00", // strings become text content
}),
defineTool({
name: "book_slot",
description: "Book an appointment slot.",
scope: "write",
inputSchema: z.object({ slot: z.string() }),
mutating: {
// `slot` is typed from inputSchema; `data` in execute is typed from preview's return.
preview: async ({ slot }) => ({ summary: `book ${slot}`, data: { slot } }),
execute: async ({ slot }) => `Booked ${slot}.`,
},
}),
],
});
// app is a Hono instance — export it for your runtime adapter.
export default app;See examples/appointments/server.ts for a complete working server.
Config reference
createMcpServer(config: McpServerConfig) accepts:
| Field | Type | Required | Description |
| -------------------------------- | ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| baseUrl | string | Yes | Public base URL of this server (used in OAuth discovery and redirect URIs). Must be https:// unless the hostname is localhost/127.0.0.1 — the server throws at construction time otherwise. |
| name / version | string | No | Server identity reported to MCP clients on initialize. Defaults to mcp-oauth-kit / the kit version. |
| storage | KvLike | Yes | Key-value store for tokens, rate-limit counters, and idempotency records. |
| scopes | ScopeConfig[] | Yes | OAuth scopes the server advertises. |
| identity | IdentityConfig | No | Built-in login-form identity provider. Omit to use a custom provider. |
| tools | Array<ToolDef \| MutatingToolDef> | Yes | Tool definitions registered on the MCP server. |
| rateLimits | RateLimitConfig | No | Per-hour thresholds for tool calls and OAuth endpoints. |
| hooks | ObservabilityHooks | No | Async callbacks for tool calls, OAuth lifecycle events, and mutation audit. |
| ipExtractor | (req: Request) => string | No | Override how the trusted client IP is derived for per-IP rate limiting (see below). |
| allowClientIdMetadataDocuments | boolean | No | Resolve unregistered https:// client_ids as Client ID Metadata Documents instead of requiring Dynamic Client Registration. Off by default (see "OAuth / PKCE client flow" below). |
| allowedOrigins | string[] | No | Exact-match allowlist for the browser Origin header on POST /mcp (DNS-rebinding protection). Requests with no Origin header are always allowed; a request WITH one is rejected unless it's in this list. |
ScopeConfig
| Field | Type | Description |
| ------------- | ---------- | ---------------------------------------------------- |
| name | string | Scope name (e.g. "account:read"). |
| description | string? | Human-readable description. |
| default | boolean? | Granted when the client requests no specific scopes. |
IdentityConfig
| Field | Type | Description |
| ---------- | ------------------------------------- | ------------------------------------------------------------------------ |
| fields | IdentityField[] | Fields rendered on the built-in login form. |
| branding | Branding? | App name, logo URL, and accent colour for the form. |
| verify | (fields) => Promise<string \| null> | Validate credentials. Return a stable userId string or null to reject. |
IdentityField
| Field | Type | Description |
| ---------- | ---------- | ------------------------------------------------------- |
| name | string | HTML input name and key in the submitted record. |
| label | string | Human-readable label. |
| type | string? | HTML input type ("text", "password", "email", …). |
| required | boolean? | Whether the field is required. |
Branding
| Field | Type | Description |
| ------------- | --------- | ------------------------------------- |
| appName | string | App name shown in the UI heading. |
| logoUrl | string? | URL to a logo image. |
| accentColor | string? | Hex accent colour (e.g. "#3b82f6"). |
RateLimitConfig
All limits are per-hour. Omit a field to use the default.
| Field | Type | Default | Description |
| -------------------- | --------- | ------- | --------------------------------------------------------------------- |
| userPerHour | number? | 50 | Max MCP tool calls per user per hour. |
| ipAuthorizePerHour | number? | 10 | Max OAuth authorize attempts per IP per hour (brute-force guard). |
| ipTokenPerHour | number? | 30 | Max requests per IP per hour to /token, /register, and /revoke. |
The /register and /revoke endpoints share the per-IP ipTokenPerHour bucket so that unauthenticated requests can't be used for storage-exhaustion abuse.
Rate-limit counters use a non-atomic read-modify-write (KV has no atomic increment) — counts may under-count under high concurrency. For strict enforcement, wrap createRateLimiter with a Durable Object counter or equivalent.
Trusted client IP. By default the per-IP source is CF-Connecting-IP (authoritative on Cloudflare) falling back to the first hop of X-Forwarded-For. X-Forwarded-For is client-spoofable unless a trusted proxy overwrites it, so off-Cloudflare deployments must pass a custom ipExtractor that derives the IP from a source you control — otherwise the brute-force guards can be bypassed by rotating the header:
createMcpServer({
// ...
ipExtractor: (req) => req.headers.get("True-Client-IP") ?? "unknown",
});ObservabilityHooks
All callbacks are fire-and-forget except onMutation (which is awaited). Errors are swallowed so a throwing hook never fails the request.
| Field | Type | Description |
| ------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onToolCall | (event) => Promise<void> | Called when a tool is invoked (including a mutating tool's preview call), concurrently with the handler. event.input is the same object the handler receives. |
| onAudit | (event) => Promise<void> | Called on OAuth lifecycle events (client_registered, token_issued, token_refreshed, token_revoked). |
| onMutation | (event) => Promise<void> | Called (awaited) after a mutating tool's execute phase succeeds. |
| onToolError | (event) => Promise<void> | Called when a handler, preview, or execute throws or returns an unserializable value, or a storage call in the mutating flow fails. event.phase is handler/preview/execute/storage; event.error is the real error the client never sees. A storage event after execute means the side effect happened but its result wasn't cached. |
Tool definitions
Wrap each tool in defineTool(...) so input is typed from inputSchema (and a mutating tool's execute data from preview) — the FastMCP-style ergonomics. A handler may return a string (one text block), an MCP { content: [...] } result (passed through), or any other JSON-serializable value (serialized as text). createMcpServer throws at startup on duplicate tool names, a tool named confirm_request, a scope missing from scopes, or an inputSchema that isn't a z.object — wrappers like .transform() (and .refine() under zod 3) are rejected because they'd advertise an empty schema; under zod 4, .refine() on the object is allowed and runs.
Errors. A thrown error reaches the client as a generic "Tool execution failed" (and is reported to the onToolError hook). To reject with a reason the client should see, throw ToolError (instances of ToolError, or an Error carrying its Symbol.for("mcp-oauth-kit.ToolError") brand so duplicate package copies interoperate, are forwarded — an unrelated error that merely has name === "ToolError" stays generic):
import { ToolError } from "mcp-oauth-kit";
preview: async ({ slot }) => {
if (await isBooked(slot)) throw new ToolError(`${slot} is already booked`);
return { summary: `book ${slot}`, data: { slot } };
},Each tool in the tools array is either a ToolDef (has a handler — called directly) or a MutatingToolDef (has a mutating.preview and mutating.execute — uses the two-phase confirm flow). The two shapes are mutually exclusive.
Standard tool (ToolDef)
{
name: "list_slots",
description: "List available slots.",
inputSchema: z.object({}),
scope: "account:read", // optional — omit for no scope check
handler: async (input, ctx) => ({
content: [{ type: "text", text: "..." }],
}),
}ctx is a ToolContext:
interface ToolContext {
userId: string;
scopes: string[];
storage: KvLike;
// The Hono request's `c.env` — Cloudflare Worker bindings when deployed there, or
// whatever your Hono adapter supplies for other runtimes (often `undefined`/empty on
// Node, Lambda, Vercel unless you've typed your own Hono `Env` generic). Cast to your
// own type.
env: unknown;
hooks: ObservabilityHooks;
}Scope gating
If a tool specifies scope, the kit checks the caller's token at dispatch time. The tool is
always listed in tools/list regardless of the caller's granted scopes — a client can
discover it exists and request the scope via step-up authorization. A caller whose token
lacks the required scope receives an isError result naming the missing scope instead of
the handler running; for a single (non-batch) tools/call, the HTTP response is also a 403
carrying a WWW-Authenticate: Bearer error="insufficient_scope", scope="<required>", ...
challenge (RFC 6750 §3). Scopes flagged default: true in the server config are
automatically granted when the client requests no explicit scopes. ctx.scopes inside a
handler reflects the token's full granted scope list.
Mutating tool (MutatingToolDef) — two-phase preview → confirm
Mutating tools never execute their side effect on the first call. The flow is:
- Preview phase — the MCP client calls the tool.
mutating.preview(input, ctx)runs, returns a{ summary, data }preview. The kit stores it under a single-use confirmation token (5-minute TTL) and returns the token to the client. - Confirm phase — the MCP client calls the built-in
confirm_requesttool with theconfirmationTokenfrom step 1 and a uniqueidempotencyKey.mutating.execute(data, ctx)runs, and the result is returned (and cached for 10 minutes under the idempotency key). The confirmation token is bound to the user who previewed it: a confirm from a different user is rejected and does not consume the token.
Idempotency — best-effort, not exactly-once: The kit writes a
"pending"sentinel before executing, so a concurrent retry that sees it backs off and asks the caller to retry. A retry that sees a cached result replays it without re-executing. Limitation: The underlying KV store has no compare-and-swap. The pending sentinel narrows — but does not fully close — the double-execute window. True exactly-once delivery requires a strongly consistent store (a Durable Object or equivalent). On execute failure the sentinel is deleted so a legitimate retry can re-run.
defineTool({
name: "book_slot",
description: "Book an appointment slot.",
scope: "write",
inputSchema: z.object({ slot: z.string() }),
mutating: {
preview: async ({ slot }) => ({ summary: `book ${slot}`, data: { slot } }),
execute: async ({ slot }, ctx) => {
// carry out the side effect here
return `Booked ${slot}.`;
},
},
});confirm_request tool
The kit registers one shared confirm_request tool automatically. Its input schema:
z.object({
confirmationToken: z.string(), // from the preview response
idempotencyKey: z.string(), // caller-generated, unique per logical operation
});Endpoints mounted by createMcpServer
The Hono app returned by createMcpServer must be served at the origin root (i.e. https://mcp.example.com/). RFC 8414 requires /.well-known/oauth-authorization-server to resolve at the domain root, and the protected resource is advertised as ${baseUrl}/mcp — mounting under a path prefix would break discovery and token validation for spec-compliant clients.
| Method | Path | Description |
| -------- | ----------------------------------------- | ------------------------------------------------------- |
| GET | /.well-known/oauth-authorization-server | RFC 8414 authorization server metadata |
| GET | /.well-known/oauth-protected-resource | RFC 9728 protected resource metadata |
| POST | /register | Dynamic Client Registration (RFC 7591) |
| GET | /authorize | Render built-in login form |
| POST | /authorize | Process login, issue auth code, 302 redirect |
| POST | /token | Token exchange (authorization_code + refresh_token) |
| POST | /revoke | Token revocation (RFC 7009) |
| POST | /mcp | MCP transport (stateless streamable-HTTP) |
| GET | /mcp | 405 — stateless mode, no SSE |
| DELETE | /mcp | 405 — stateless mode, no sessions |
Request body limit
All request bodies — OAuth endpoints and POST /mcp — are capped at 1 MB (HTTP 413 if exceeded). If your tools accept large inputs (e.g. document contents), pre-process or chunk them before sending.
OAuth / PKCE client flow
Transport vs. authorization spec version. This kit's authorization surface (discovery, DCR/CIMD, PKCE, tokens) targets MCP 2026-07-28. Its MCP transport layer is built on
@modelcontextprotocol/sdk^1, which implements the2025-11-25wire protocol (no2026-07-28-only features likeserver/discoverorresultType) — a fully2026-07-28-compliant client that sendsMCP-Protocol-Version: 2026-07-28will fall back to legacy negotiation per the spec's own backward-compatibility rules, and the flow below will still work. This will be resolved when the kit migrates to the SDK's v2 line; see the CHANGELOG.
The kit implements OAuth 2.1 with PKCE (S256). A standards-compliant MCP client discovers and authenticates as follows:
- Discovery —
GET /.well-known/oauth-authorization-server(RFC 8414) returns server metadata includingauthorization_endpoint,token_endpoint, andregistration_endpoint. - Client registration — either Dynamic Client Registration:
POST /registerwith{ "redirect_uris": ["https://your-client/callback"] }returns aclient_id; or, ifallowClientIdMetadataDocumentsis enabled, a Client ID Metadata Document: use anhttps://URL hosting a{ client_id, redirect_uris, ... }JSON document as theclient_iddirectly, with no registration call (MCP 2026-07-28 deprecates DCR in favor of this — DCR remains fully supported here). - Authorization — redirect the user to
GET /authorizewithresponse_type=code,client_id,redirect_uri,code_challenge(S256 PKCE), and optionallyscope. The built-in identity form collects credentials and calls youridentity.verify. On success, the server 302-redirects toredirect_uri?code=<auth_code>&iss=<baseUrl>(theissparameter, RFC 9207, lets a compliant client detect an authorization-server mix-up before redeeming the code). - Token exchange —
POST /tokenwithgrant_type=authorization_code,code,client_id,redirect_uri, andcode_verifier. Returns{ access_token, refresh_token, expires_in, token_type: "Bearer" }. - Call tools — send MCP JSON-RPC to
POST /mcpwithAuthorization: Bearer <access_token>. - Token refresh —
POST /tokenwithgrant_type=refresh_tokenandrefresh_token. Issues a new access + refresh token pair (rotation). Note: the prior access token remains valid until its TTL (~1 hour) expires naturally. Reuse detection (RFC 9700): all tokens rotated from one authorization share a family; presenting a refresh token that has already been rotated out revokes the entire family (its active access + refresh tokens), containing a stolen token. Detection is eventually-consistent — like single-use code redemption, a concurrent race isn't fully closed without a strongly-consistent store. Client implication: always refresh with the newest refresh token and never retry a refresh using a previously-rotated token — doing so is indistinguishable from theft and will revoke the whole session. - Revocation —
POST /revokewith the access or refresh token to invalidate it immediately (paired token is also revoked).
Bring your own storage
The default createMemoryStorage() is suitable for tests only — it is not persistent and is not shared across isolates or instances; never use it in production. createCloudflareKvStorage(kv) wraps a Cloudflare KV namespace for production. For any other backend, implement KvLike (three methods: get, put, delete) and pass it as storage.
See docs/storage-adapters.md for the interface definition and adapter examples (Redis, DynamoDB, Postgres).
Deploy
See docs/deploy.md for runtime-specific entry-point wrappers (Cloudflare Workers, Node, AWS Lambda, Vercel).
Public API
Primary API (start here)
createMcpServer(config)— factory; returns a Hono appcreateMemoryStorage()— in-memoryKvLikefor testscreateCloudflareKvStorage(kv)— wraps a Cloudflare KV namespaceregisterMutatingTool(server, tool, ctx)— low-level registration helperregisterConfirmTool(server, ctx, mutatingTools)— low-level confirm registrationdefineTool(tool)— typed tool helper (infers handler input frominputSchema)ToolError— throw to reject a tool call with a client-visible messageisMutating(t)— type guard:truewhentis aMutatingToolDef
Types: McpServerConfig, ScopeConfig, IdentityField, IdentityConfig, Branding, ObservabilityHooks, ToolContext, ToolDef, MutatingToolDef, RateLimitConfig, KvLike, KVNamespaceLike, AuthorizePageParams
Advanced / low-level API
Reach for these when you need to compose your own Hono app — custom middleware, a custom
OAuth UI, or wiring in your own routes alongside these — rather than using createMcpServer
directly. The origin-root requirement above still applies — none of these lower-level
pieces lift it; RFC 8414 discovery breaks under a path prefix regardless of which API layer
mounts the routes.
createOAuthProvider(config)— build the OAuth provider independently.OAuthProviderConfigfields:storage,scopes,baseUrl, optionalnow?: () => number(injectable clock for deterministic testing), and optionalallowClientIdMetadataDocuments?: boolean(see the Config reference table above).mountOAuthRoutes(app, deps)— mount/register,/authorize,/token,/revokeonto an existing Hono app.mountDiscovery(app, deps)— mount/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource.createRateLimiter({ storage, config? })— build the rate limiter independently.handleMcpRequest(req, deps)— handle a singlePOST /mcprequest; returns aPromise<Response>.renderAuthorizePage(identity, params)— render the built-in login form HTML (use when building a custom/authorizehandler).
Types: OAuthProvider, OAuthProviderConfig, TokenPair, OAuthRouteDeps, DiscoveryDeps, RateLimiter, McpRequestDeps
License
MIT
