mcp-auth-kit
Provides a Cloudflare KV storage backend adapter (createCloudflareKvStorage) for persisting OAuth tokens, refresh tokens, rate-limit counters, and idempotency records, and supports trusted client-IP extraction for per-IP rate limiting when deployed behind Cloudflare.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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.4.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/server zodTypeScript projects that type-check libraries (skipLibCheck: false) also need
@types/node and "types": ["node"] in tsconfig.json — the SDK's published types
reference Buffer, and current TypeScript no longer includes @types/* packages unless listed.
Node 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.
On Node you also need the Hono adapter as a runtime dependency: npm install @hono/node-server
(Workers, Lambda and Vercel use their own adapters — see docs/deploy.md).
Related MCP server: Model Context Protocol Template
Quick start
npm install @hono/node-server # Node adapter; see docs/deploy.md for Workers, Lambda, Vercel// server.ts — run with `node server.ts` (Node 22.18+; or save as server.js)
import { serve } from "@hono/node-server";
import { z } from "zod";
import { createMcpServer, createMemoryStorage, defineTool } from "mcp-oauth-kit";
// Stand-in for your user store — replace with a real lookup.
const users = new Map([["ada@example.com", { id: "user-ada", code: "123456" }]]);
const app = createMcpServer({
// Your public https:// URL in production; http:// is allowed only for localhost.
baseUrl: "http://localhost:3000",
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 = users.get(fields.email);
return user && user.code === fields.code ? 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 — wrap it in your runtime's adapter.
serve({ fetch: app.fetch, port: 3000 }, () => console.log("listening on http://localhost:3000"));Point an MCP client at http://localhost:3000/mcp and it will run the OAuth flow against the
built-in login page.
Plain JavaScript works too. Nothing above needs TypeScript: save it as server.js and run
node server.js. defineTool is only a typing helper — in JS you can pass the tool objects
directly. (Node 22.18+ / 23.6+ runs the .ts file as-is via built-in type stripping; a relative
import of another .ts file must then spell the .ts extension — see the
how-to guide.)
Adding your own routes
createMcpServer returns a plain Hono app, so you can mount it inside your own:
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.text("Hello"));
app.route("/", createMcpServer({/* ... */}));Mount it at / (see the origin-root requirement under Endpoints)
and keep your routes off the paths the kit owns: /.well-known/oauth-authorization-server,
/.well-known/oauth-protected-resource (and its /mcp sub-path), /register, /authorize,
/token, /revoke, and /mcp. The kit's login page creates no browser session or cookie —
it only issues an OAuth code to the MCP client — so a web UI of your own needs its own auth.
See examples/appointments/server.ts for a complete working server.
Config reference
createMcpServer(config: McpServerConfig) accepts:
Field | Type | Required | Description |
|
| Yes | Public base URL of this server (used in OAuth discovery and redirect URIs). Must be |
|
| No | Server identity sent to MCP clients as |
|
| Yes | Key-value store for tokens, rate-limit counters, and idempotency records. |
|
| Yes | OAuth scopes the server advertises. |
|
| No | Built-in login-form identity provider. Without it |
|
| Yes | Tool definitions registered on the MCP server. |
|
| No | Per-hour thresholds for tool calls and OAuth endpoints. |
|
| No | Async callbacks for tool calls, OAuth lifecycle events, and mutation audit. |
|
| No | Override how the trusted client IP is derived for per-IP rate limiting (see below). |
|
| No | Override token lifetimes: |
|
| No | Resolve unregistered |
|
| No | Exact-match allowlist for the browser |
ScopeConfig
Field | Type | Description |
|
| Scope name (e.g. |
|
| Human-readable description, shown on the login page. |
|
| Granted when the client requests no specific scopes. |
IdentityConfig
Field | Type | Description |
|
| Fields rendered on the built-in login form. |
|
| App name, logo URL, and accent colour for the form. |
|
| Validate credentials. Return a stable userId string or |
Every field value reaches verify as a string (missing fields are ""); non-password fields
are trimmed, type: "password" fields are passed verbatim and never refilled after a failed
sign-in. The login page lists the scopes being granted. GET /authorize answers 400 instead
of rendering the form when the client_id is unknown or the redirect_uri isn't registered.
Per-account lockout is your job. The kit rate-limits /authorize per IP only
(ipAuthorizePerHour), which doesn't stop an attacker spreading guesses across IPs. If your
credential is guessable (a 6-digit code), count failures per account in verify — the second
argument carries the raw request and the trusted client ip (from ipExtractor).
IdentityField
Field | Type | Description |
|
| HTML input name and key in the submitted record. |
|
| Human-readable label. |
|
| HTML input type ( |
|
| Whether the field is required. |
Branding
Field | Type | Description |
|
| App name shown in the UI heading. |
|
| URL to a logo image. |
|
| Hex accent colour (e.g. |
RateLimitConfig
All limits are per-hour. Omit a field to use the default.
Field | Type | Default | Description |
|
| 50 | Max authenticated |
|
| 10 | Max OAuth authorize attempts per IP per hour (brute-force guard). |
|
| 30 | Max requests per IP per hour to |
A limited OAuth request gets 429 {"error":"temporarily_unavailable","error_description":"rate limit exceeded (<key>)"} naming the exhausted key. Every 429 (OAuth endpoints and /mcp) carries Retry-After set to the seconds until the hourly window resets. Windows are fixed clock hours, so a client can spend up to twice a limit across an hour boundary. Every OAuth round trip (including a scope step-up) costs one /authorize attempt, so during local development raise ipAuthorizePerHour or restart the (memory-storage) server when iterating on a client.
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. Its second argument is Hono's c.env; on @hono/node-server that carries the socket, so with no proxy in front use the real peer address:
import type { HttpBindings } from "@hono/node-server";
createMcpServer({
// ...
ipExtractor: (_req, env) => (env as HttpBindings).incoming.socket.remoteAddress ?? "unknown",
});Behind a proxy, read the header that proxy sets — e.g. (req) => req.headers.get("True-Client-IP") ?? "unknown". That is only safe if the proxy always overwrites the header; otherwise a client can send its own.
ObservabilityHooks
All callbacks are fire-and-forget except onMutation (which is awaited). Errors are swallowed so a throwing hook never fails the request. On runtimes with waitUntil (Cloudflare Workers), the fire-and-forget hooks are registered with it so they finish after the response is sent — pass the execution context through (fetch: (req, env, ctx) => app.fetch(req, env, ctx)).
Field | Type | Description |
|
| Called when a tool is invoked (including a mutating tool's preview call), concurrently with the handler. |
|
| Called on OAuth lifecycle events ( |
|
| Called (awaited) after a mutating tool's execute phase succeeds, with |
|
| Called when a handler, |
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() are rejected because they'd advertise an empty schema; .refine() on the object is allowed and runs. Zod 4 (^4.2) is required.
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;
// The runtime's waitUntil on Cloudflare Workers / edge (undefined on Node): hand it a
// promise to keep background work alive after the response is sent.
waitUntil?: (promise: Promise<unknown>) => void;
}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 tools/call for it from
a caller whose token lacks the scope is answered, before the handler runs, with HTTP 403
and a WWW-Authenticate: Bearer error="insufficient_scope", scope="<required>", resource_metadata="..."
challenge (RFC 6750 §3, MCP 2026-07-28) — including when the call is inside a legacy JSON-RPC
batch. 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 aPreviewResult({ status: "preview", summary, confirmationToken }) 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. Reusing anidempotencyKeywith a different confirmation token is refused rather than replaying the other result. Refusals come back asisError: truewith text{"success":false,"error":"..."}.
data is stored as JSON, so it must be JSON-serializable (no Date/Map/BigInt), and execute can run up to 5 minutes after preview — re-check anything that may have changed (availability, balances) instead of trusting the preview. summary is shown before the user confirms and passed to onMutation after, so phrase it tense-neutral ("Book 09:00", not "Booked 09:00").
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 — listed for a caller only when at least one mutating tool's scope is granted to them. 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 |
|
| RFC 8414 authorization server metadata |
|
| RFC 9728 protected resource metadata |
|
| Same metadata at the RFC 9728 path-suffixed location |
|
| Dynamic Client Registration (RFC 7591) |
|
| Render built-in login form |
|
| Process login, issue auth code, 302 redirect |
|
| Token exchange ( |
|
| Token revocation (RFC 7009) |
|
| MCP transport (stateless streamable-HTTP) |
|
| 405 — stateless mode, no GET stream |
|
| 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
Protocol versions. The MCP transport is built on the v2 SDK (
@modelcontextprotocol/server) and serves the 2026-07-28 revision (per-request_metaenvelope,server/discover, requiredMCP-Protocol-Version/Mcp-Method/Mcp-Nameheaders). Clients that still open with a 2025-erainitializeare served by the SDK's stateless legacy fallback, whose responses arrive as a single-event SSE stream. The authorization surface below targets 2026-07-28 too.
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>. A call to a tool whose scope the token lacks gets a403insufficient_scopechallenge; the client re-runs authorization for that scope (step-up) — non-default scopes are obtained this way, since the401challenge only advertises default scopes.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 by default; seetokenTtl) 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 — every access token in the lineage, older generations still inside their TTL included, plus the live refresh token — 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. This revokes the whole family, like reuse detection.
Authorization codes are single-use: a failed redemption (wrong PKCE verifier, client, or redirect URI) consumes the code, and replaying an already-redeemed code revokes the tokens minted from it (RFC 6749 §4.1.2). A code authorized without resource still yields tokens bound to ${baseUrl}/mcp.
The official TypeScript SDK client (@modelcontextprotocol/client) does all of this for you — see Connect with the TypeScript SDK client.
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 namespacedefineTool(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, PreviewResult, RateLimitConfig, ToolErrorPhase, 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.
OAuthError(code, message, status = 400)— what the built-in provider throws;/tokenand/revokeanswer with itscodeandstatus. Throw it from a customOAuthProvider— any other error from/tokenbecomesinvalid_grant(400) with its message.createOAuthProvider(config)— build the OAuth provider independently.OAuthProviderConfigfields:storage,scopes,baseUrl, optionalnow?: () => number(injectable clock for deterministic testing), optionaltokenTtl?: TokenTtlConfig, and optionalallowClientIdMetadataDocuments?: boolean(see the Config reference table above).mountOAuthRoutes(app, deps)— mount/register,/authorize,/token,/revokeonto an existing Hono app.OAuthRouteDeps:provider,baseUrl, and optionalidentity,hooks,rateLimiter(omit it and these endpoints are unthrottled),ipExtractor,scopes.mountDiscovery(app, deps)— mount/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource(plus its/mcpsub-path).DiscoveryDeps:baseUrl,scopes, optionalclientIdMetadataDocumentsSupported.createRateLimiter({ storage, config? })— build the rate limiter independently.handleMcpRequest(req, deps)— handle a singlePOST /mcprequest; returns aPromise<Response>.McpRequestDepsfields (see the how-to guide §4 for a full example):provider— theOAuthProviderthat verifies Bearer tokens.rateLimiter— theRateLimiterenforcinguserPerHour.storage— theKvLikepassed to tools asctx.storage(and used by two-phase confirm).tools— theToolDef/MutatingToolDefarray.baseUrl— the server's public base URL (used in the401/403challenges).serverName/serverVersion— theserverInforeported to clients.env— the runtime env (Hono'sc.env), exposed asctx.env.hooks—ObservabilityHooks({}for none).defaultScopes— scope names hinted in the401WWW-Authenticatechallenge.allowedOrigins?—Originallowlist (default[]: any request with anOriginis rejected).waitUntil?— the runtime'swaitUntilfor fire-and-forget hooks; omit on Node.
registerMutatingTool(server, tool, ctx)/registerConfirmTool(server, ctx, mutatingTools)— register a two-phase tool / the sharedconfirm_requesttool on your ownMcpServer(whathandleMcpRequestdoes per request).renderAuthorizePage(identity, params)— render the built-in login form HTML (use when building a custom/authorizehandler).
Types: OAuthProvider, OAuthProviderConfig, TokenPair, TokenTtlConfig, OAuthRouteDeps, DiscoveryDeps, RateLimiter, McpRequestDeps
License
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
One OAuth connector for your team's remote MCP servers, with Tool Packs, guardrails and logs.
Hosted MCP server with managed OAuth for 15+ toolkits: Google Workspace, Fitbit, Oura, Kalshi, etc.
The Stytch MCP server is a reference implementation that demonstrates remote MCP server authentication and authorization using Stytch Connected Apps. It provides OAuth 2.1-compliant authorization (including PKCE), Dynamic Client Registration, and validates Stytch-issued access tokens to enable AI agents to securely interact with external services through permissioned access, supporting scopes like openid, email, profile, and manage:project_data.
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceA production-ready MCP server that authenticates agents via OAuth 2.1 Bearer tokens, validates JWTs with JWKS, enforces tool-level scopes and roles, and logs the full delegation chain.-
- AlicenseNot gradedqualityDmaintenanceA production-ready MCP server template that connects LLMs and AI agents to external data, tools, and services with built-in OAuth 2.1 authentication, Redis-backed session management, and a modular tools engine.1MIT
- AlicenseAqualityDmaintenanceA production-ready foundation for building secure, observable MCP servers with built-in authentication, rate limiting, and reference tools like database-query and semantic-search.1517 npmMIT
- AlicenseNot gradedqualityDmaintenanceA production-ready MCP server template with OAuth 2.1, RBAC, and audit logging for building secure, observable tool servers.MIT