mcp-auth-kit
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-auth-kitlog in with email and verification code"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.2.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.
Related MCP server: Model Context Protocol Template
Quick start
import { z } from "zod";
import { createMcpServer, createMemoryStorage } 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: [
{
name: "list_slots",
description: "List available appointment slots for today.",
inputSchema: z.object({}),
handler: async (_input, _ctx) => ({
content: [{ type: "text", text: "09:00, 10:00, 11:00" }],
}),
},
{
name: "book_slot",
description: "Book an appointment slot.",
scope: "write",
inputSchema: z.object({ slot: z.string() }),
mutating: {
preview: async (input) => {
const { slot } = input as { slot: string };
return { summary: `book ${slot}`, data: { slot } };
},
execute: async (data) => {
const { slot } = data as { slot: string };
return { content: [{ type: "text", text: `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 |
|
| Yes | Public base URL of this server (used in OAuth discovery and redirect URIs). Must be |
|
| 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. Omit to use a custom provider. |
|
| 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 | Resolve unregistered |
|
| No | Exact-match allowlist for the browser |
ScopeConfig
Field | Type | Description |
|
| Scope name (e.g. |
|
| Human-readable description. |
|
| 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 |
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 MCP tool calls per user per hour. |
|
| 10 | Max OAuth authorize attempts per IP per hour (brute-force guard). |
|
| 30 | Max requests per IP per hour to |
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 |
|
| Called after every tool invocation. |
|
| Called on OAuth lifecycle events ( |
|
| Called (awaited) after a mutating tool's execute phase succeeds. |
Tool definitions
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.
{
name: "book_slot",
description: "Book an appointment slot.",
scope: "write",
inputSchema: z.object({ slot: z.string() }),
mutating: {
preview: async (input) => {
const { slot } = input as { slot: string };
return { summary: `book ${slot}`, data: { slot } };
},
execute: async (data, ctx) => {
const { slot } = data as { slot: string };
// carry out the side effect here
return { content: [{ type: "text", text: `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 |
|
| RFC 8414 authorization server metadata |
|
| RFC 9728 protected resource metadata |
|
| 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 SSE |
|
| 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 registrationisMutating(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
This server cannot be deployed
Maintenance
Related MCP Connectors
Hosted MCP server with managed OAuth for 15+ toolkits: Google Workspace, Fitbit, Oura, Kalshi, etc.
- StytchOAuthdev.stytch.mcp
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 mandates, delegation, policy-gated execution, credential grants, and audit.
OAuth-protected, read-only-by-default MCP server for provenance-labeled QuillCaddie project memory.
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
- AlicenseAqualityCmaintenanceA production-ready foundation for building secure, observable MCP servers with built-in authentication, rate limiting, and reference tools like database-query and semantic-search.1511 npmMIT
- AlicenseNot gradedqualityCmaintenanceA production-ready MCP server template with OAuth 2.1, RBAC, and audit logging for building secure, observable tool servers.MIT