TypeScript SDK
AuthPlane handles the OAuth; your TypeScript MCP server only validates JWTs. The @authplane/mcp adapter returns Express middleware plus an RFC 9728 Protected Resource Metadata handler — five lines of auth code on top of the official @modelcontextprotocol/sdk.
Install
Section titled “Install”The TypeScript SDK is published on npm as a core package plus framework adapters. Install the one that matches your stack, at the pinned version:
| Your stack | Install | Import |
|---|---|---|
Express + @modelcontextprotocol/sdk | npm i @authplane/mcp@0.2.0 | import { authplaneMcpAuth } from "@authplane/mcp" |
| FastMCP (TypeScript) | npm i @authplane/fastmcp@0.2.0 | import { authplaneAuth } from "@authplane/fastmcp" |
| Any other framework | npm i @authplane/sdk@0.2.0 | import { AuthplaneResource } from "@authplane/sdk" |
The packages are ESM-only and require Node.js 22+. CommonJS consumers hit ERR_REQUIRE_ESM — set "type": "module" in your package.json.
Protect your MCP server
Section titled “Protect your MCP server”A complete, runnable server — this is the examples/typescript/01-mcp-server-basic example that ships with AuthPlane (pinned alongside @modelcontextprotocol/sdk 1.29.0, express 4.22.0, zod 4.4.3):
import crypto from "node:crypto";import express from "express";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";import { z } from "zod";
// authplane:beginimport { authplaneMcpAuth } from "@authplane/mcp";const auth = await authplaneMcpAuth({ issuer: process.env.AUTHPLANE_ISSUER!, resource: process.env.AUTHPLANE_RESOURCE!, scopes: ["mcp:echo"], devMode: true,});// authplane:end
const app = express();app.use(express.json());app.get(auth.protectedResourceMetadataPath, auth.protectedResourceMetadataHandler);
const sessions = new Map<string, StreamableHTTPServerTransport>();
app.all("/mcp", auth.bearerAuth, async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; let transport = sessionId ? sessions.get(sessionId) : undefined; if (!transport) { const newSessionId = crypto.randomUUID(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId, }); const server = new McpServer({ name: "demo-server", version: "1.0.0" }); type EchoArgs = { text: z.ZodString }; const echoShape: EchoArgs = { text: z.string() }; server.registerTool<EchoArgs, EchoArgs>( "echo", { inputSchema: echoShape }, async ({ text }) => ({ content: [{ type: "text" as const, text }] }), ); await server.connect(transport); sessions.set(newSessionId, transport); } await transport.handleRequest(req, res, req.body);});
const PORT = Number(process.env.PORT ?? 8080);app.listen(PORT, "0.0.0.0", () => { console.log(`MCP server listening on :${PORT}`);});With AuthPlane running locally (the authplane/authserver Docker image listens on :9000 public, :9001 admin), the SDK needs two env vars:
# Where the SDK discovers AuthPlane metadata + JWKS. Must resolve to the# same hostname as the AS-side AUTHPLANE_SERVER_ISSUER.AUTHPLANE_ISSUER=http://localhost:9000
# This server's Resource URI: the JWT `aud` claim the verifier requires AND# the path the Protected Resource Metadata document is served from.AUTHPLANE_RESOURCE=http://localhost:8080/mcpauthplaneMcpAuth() does AS metadata discovery and the JWKS fetch at module load (note the top-level await). AuthPlane must be reachable when the module loads or the process fails to start — bring the AS up first, or wrap the call in a bounded retry:
let auth;for (let i = 0; i < 30; i++) { try { auth = await authplaneMcpAuth({...}); break; } catch (e) { await new Promise(r => setTimeout(r, 1000)); }}if (!auth) throw new Error("authserver unreachable after 30s");How verification works
Section titled “How verification works”authplaneMcpAuth() performs RFC 8414 AS metadata discovery against issuer, fetches the JWKS, and returns two things you mount:
auth.bearerAuth— Express middleware. It extracts theAuthorization: Bearerheader, validates the JWT signature against AuthPlane’s JWKS, then checks the audience,exp, and required scopes. A missing or invalid token returns401with aWWW-Authenticate: Bearer resource_metadata="..."header; a missing scope returns403 insufficient_scope.auth.protectedResourceMetadataHandler— serves the RFC 9728 PRM document atauth.protectedResourceMetadataPath, which evaluates to/.well-known/oauth-protected-resource/<mcp-path>per the MCP spec.
JWKS handling is automatic: the SDK force-refreshes on an unknown kid, so AuthPlane key rotation needs no redeploy. The issuer check is strict — the SDK enforces metadata.issuer == config.issuer and fails fast on mismatch.
Audience binding. Unlike the Python adapter, the TypeScript adapter takes the full Resource URI directly as resource: — no path derivation. The JWT aud must equal that string, so three things must agree byte-for-byte (scheme, host, port, path): the resource you pass the adapter, the uri you register at the AS, and the URL MCP clients actually reach.
Confirm the PRM document is live:
curl -sS http://localhost:8080/.well-known/oauth-protected-resource/mcp | jq .# {# "resource": "http://localhost:8080/mcp",# "authorization_servers": ["http://localhost:9000"],# "scopes_supported": ["mcp:echo"],# "bearer_methods_supported": ["header"]# }DPoP. The adapter also validates DPoP sender-constrained tokens (RFC 9449). DPoP is off by default on the AS — set AUTHPLANE_DPOP_ENABLED=true at startup, or the discovery document silently omits it.
Retrofit an existing server
Section titled “Retrofit an existing server”Already running an Express + MCP SDK server? Three changes — this is the before/after diff from the examples/typescript/retrofit-existing-mcp-server example:
import { authplaneMcpAuth } from "@authplane/mcp";const auth = await authplaneMcpAuth({ issuer: process.env.AUTHPLANE_ISSUER!, resource: process.env.AUTHPLANE_RESOURCE!, scopes: ["mcp:tools"], devMode: true,});const app = express();app.use(express.json());app.get(auth.protectedResourceMetadataPath, auth.protectedResourceMetadataHandler);app.all("/mcp", async (req, res) => { /* ...transport... */ });app.all("/mcp", auth.bearerAuth, async (req, res) => { /* ...transport... */ });That’s (a) the five-line auth block, (b) the PRM route, (c) the auth.bearerAuth middleware on /mcp. Tools, transport, and app.listen stay untouched.
Troubleshooting
Section titled “Troubleshooting”Most opaque invalid_token failures trace to one of three byte-for-byte rules:
| Rule | What breaks it |
|---|---|
Issuer URLs match. The AS-side AUTHPLANE_SERVER_ISSUER (what the AS announces and stamps into iss) and the SDK-side AUTHPLANE_ISSUER (where it discovers metadata + JWKS) must resolve to the same hostname. | MCP server in the same Docker network as the AS → http://authserver:9000 on both. On the host or public → http://localhost:9000 (or your real hostname) on both. Mixing them 401s every call even though the token is valid. |
Resource URI matches everywhere. The resource: you pass authplaneMcpAuth({...}), the uri registered at the AS, and the resource= form param on the token request must match — scheme, host, port, path. | Changing PORT without updating AUTHPLANE_RESOURCE. |
One scope string, four places. The SDK scopes: [...] array, POST /admin/resources, POST /admin/clients, and the scope= param on POST /oauth/token. | Any drift produces invalid_scope from the AS or insufficient_scope from the SDK. |
TypeScript-specific pointers:
ERR_REQUIRE_ESM→ the packages are ESM-only. Use"type": "module"and Node.js 22+.- Process fails to start → the top-level
await authplaneMcpAuth(...)couldn’t reach AuthPlane. Bring the AS up first or use the retry loop above. 406 Not Acceptableon MCP calls → the request is missing the mandatoryAccept: application/json, text/event-streamheader.grant type not supported→client_credentials, DPoP, and token exchange are off by default. Set the matching env var (AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true,AUTHPLANE_DPOP_ENABLED=true,AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true) and restart the AS.
Next steps
Section titled “Next steps”- Quickstart — run AuthPlane, register a Resource and a client, mint a token
- How it works — the full token flow end to end
- FastMCP (Python) · Go SDK
- Deploy with Docker · Configuration