npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 correct ipExtractor when 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-kit

Peer dependencies (not bundled):

npm install hono @modelcontextprotocol/sdk zod

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.

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:

  1. 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.
  2. Confirm phase — the MCP client calls the built-in confirm_request tool with the confirmationToken from step 1 and a unique idempotencyKey. 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 the 2025-11-25 wire protocol (no 2026-07-28-only features like server/discover or resultType) — a fully 2026-07-28-compliant client that sends MCP-Protocol-Version: 2026-07-28 will 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:

  1. Discovery — GET /.well-known/oauth-authorization-server (RFC 8414) returns server metadata including authorization_endpoint, token_endpoint, and registration_endpoint.
  2. Client registration — either Dynamic Client Registration: POST /register with { "redirect_uris": ["https://your-client/callback"] } returns a client_id; or, if allowClientIdMetadataDocuments is enabled, a Client ID Metadata Document: use an https:// URL hosting a { client_id, redirect_uris, ... } JSON document as the client_id directly, with no registration call (MCP 2026-07-28 deprecates DCR in favor of this — DCR remains fully supported here).
  3. Authorization — redirect the user to GET /authorize with response_type=code, client_id, redirect_uri, code_challenge (S256 PKCE), and optionally scope. The built-in identity form collects credentials and calls your identity.verify. On success, the server 302-redirects to redirect_uri?code=<auth_code>&iss=<baseUrl> (the iss parameter, RFC 9207, lets a compliant client detect an authorization-server mix-up before redeeming the code).
  4. Token exchange — POST /token with grant_type=authorization_code, code, client_id, redirect_uri, and code_verifier. Returns { access_token, refresh_token, expires_in, token_type: "Bearer" }.
  5. Call tools — send MCP JSON-RPC to POST /mcp with Authorization: Bearer <access_token>.
  6. Token refresh — POST /token with grant_type=refresh_token and refresh_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.
  7. Revocation — POST /revoke with 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 app
  • createMemoryStorage() — in-memory KvLike for tests
  • createCloudflareKvStorage(kv) — wraps a Cloudflare KV namespace
  • registerMutatingTool(server, tool, ctx) — low-level registration helper
  • registerConfirmTool(server, ctx, mutatingTools) — low-level confirm registration
  • defineTool(tool) — typed tool helper (infers handler input from inputSchema)
  • ToolError — throw to reject a tool call with a client-visible message
  • isMutating(t) — type guard: true when t is a MutatingToolDef

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. OAuthProviderConfig fields: storage, scopes, baseUrl, optional now?: () => number (injectable clock for deterministic testing), and optional allowClientIdMetadataDocuments?: boolean (see the Config reference table above).
  • mountOAuthRoutes(app, deps) — mount /register, /authorize, /token, /revoke onto an existing Hono app.
  • mountDiscovery(app, deps) — mount /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource.
  • createRateLimiter({ storage, config? }) — build the rate limiter independently.
  • handleMcpRequest(req, deps) — handle a single POST /mcp request; returns a Promise<Response>.
  • renderAuthorizePage(identity, params) — render the built-in login form HTML (use when building a custom /authorize handler).

Types: OAuthProvider, OAuthProviderConfig, TokenPair, OAuthRouteDeps, DiscoveryDeps, RateLimiter, McpRequestDeps

License

MIT