Skip to content

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.

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 stackInstallImport
Express + @modelcontextprotocol/sdknpm i @authplane/mcp@0.2.0import { authplaneMcpAuth } from "@authplane/mcp"
FastMCP (TypeScript)npm i @authplane/fastmcp@0.2.0import { authplaneAuth } from "@authplane/fastmcp"
Any other frameworknpm i @authplane/sdk@0.2.0import { 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.

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):

server.ts
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:begin
import { 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:

.env
# 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/mcp

authplaneMcpAuth() 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");

authplaneMcpAuth() performs RFC 8414 AS metadata discovery against issuer, fetches the JWKS, and returns two things you mount:

  • auth.bearerAuth — Express middleware. It extracts the Authorization: Bearer header, validates the JWT signature against AuthPlane’s JWKS, then checks the audience, exp, and required scopes. A missing or invalid token returns 401 with a WWW-Authenticate: Bearer resource_metadata="..." header; a missing scope returns 403 insufficient_scope.
  • auth.protectedResourceMetadataHandler — serves the RFC 9728 PRM document at auth.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:

Terminal window
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.

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.

Most opaque invalid_token failures trace to one of three byte-for-byte rules:

RuleWhat 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 Acceptable on MCP calls → the request is missing the mandatory Accept: application/json, text/event-stream header.
  • grant type not supportedclient_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.