TypeScript

TL;DR — Five packages. @authplane/mcp targets the official MCP TypeScript SDK. @authplane/fastmcp targets punkpeye/fastmcp. @authplane/hono and @authplane/nestjs target their respective web frameworks. @authplane/sdk is the core — everything else builds on it. All adapters share the same option surface (issuer, resource, scopes, requiredScopes, inboundDPoP, revocationChecker, devMode, …) and produce the same conceptual return value (client, verifier, framework-native middleware, PRM handler).

Install

# Official MCP TypeScript SDK
npm install @authplane/sdk @authplane/mcp @modelcontextprotocol/sdk express zod

# punkpeye FastMCP
npm install @authplane/sdk @authplane/fastmcp fastmcp zod

# Hono
npm install @authplane/sdk @authplane/hono hono

# NestJS
npm install @authplane/sdk @authplane/nestjs @nestjs/common @nestjs/core reflect-metadata rxjs

# Core primitives only
npm install @authplane/sdk

Requires Node 20 LTS or newer for @authplane/mcp and @authplane/sdk. @authplane/hono and @authplane/nestjs require Node 22 LTS or newer. @authplane/sdk is a peer of every adapter.


@authplane/mcp — Official MCP TypeScript SDK adapter

Quickstart

import express from "express";
import crypto from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { authplaneMcpAuth } from "@authplane/mcp";
import { z } from "zod";

const server = new McpServer({ name: "my-server", version: "1.0.0" });

server.tool(
  "echo",
  "Echo message",
  { message: z.string() },
  async ({ message }) => ({ content: [{ type: "text", text: message }] }),
);

const auth = await authplaneMcpAuth({
  issuer: "http://localhost:9000",
  resource: "http://localhost:3000/mcp",
  scopes: ["tools/echo"],
});

const app = express();
app.use(express.json());
app.get(auth.protectedResourceMetadataPath, auth.protectedResourceMetadataHandler);

const transports = new Map<string, StreamableHTTPServerTransport>();
app.all("/mcp", auth.bearerAuth, async (req, res) => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
  if (sessionId && transports.has(sessionId)) {
    await transports.get(sessionId)!.handleRequest(req, res, req.body);
    return;
  }
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID(),
  });
  transports.set(transport.sessionId ?? crypto.randomUUID(), transport);
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000);

authplaneMcpAuth(options) — what you get back

Return fieldTypeUse
clientAuthplaneClientOwns JWKS + metadata refresh; use for client.exchange(), client.introspect(). Call client.close() on shutdown.
verifierAuthplaneResourceDirect token verification if you need to bypass middleware.
bearerAuthExpress middlewareVerifies token, enforces requiredScopes, attaches req.auth.
protectedResourceMetadataPathstringPath for the PRM handler (/.well-known/oauth-protected-resource/mcp).
protectedResourceMetadataHandlerExpress handlerServes the RFC 9728 PRM document.
protectedResourceMetadataobjectThe PRM JSON payload if you want to serve it yourself.

Options (highlights):

OptionDefaultPurpose
issuerrequiredAS URL for RFC 8414 discovery.
resourcerequiredJWT aud and PRM resource.
scopes[]Advertised in PRM. Default requiredScopes.
requiredScopes= scopesEnforced by bearerAuth on every request. Pass [] to disable request-layer enforcement.
inboundDPoPundefinedEnable inbound DPoP verification + PRM advertising.
asCredentialsundefinedEnable introspection/revocation and client.exchange().
revocationCheckerundefinedEnable RFC 7662 introspection revocation.
resourceMetadataUrlderivedAdvertise an AS-hosted PRM document in the WWW-Authenticate challenge instead of the one derived from resource.
devModefalseRelax SSRF for local dev.

Per-tool scope enforcement

bearerAuth enforces requiredScopes globally. For per-tool guards use requireScope(scope) from @authplane/mcp inside your tool handler — it reads req.auth and throws if the scope is missing. Details in the full user-guide.

Cleanup

Call await auth.client.close() on shutdown to release JWKS/metadata timers.


@authplane/fastmcp — punkpeye FastMCP adapter

Quickstart

import { FastMCP, requireScopes } from "fastmcp";
import { authplaneFastMcpAuth, type AuthplaneFastMcpSession } from "@authplane/fastmcp";
import { z } from "zod";

const auth = await authplaneFastMcpAuth({
  issuer: "http://localhost:9000",
  resource: "http://localhost:8080/mcp",
  scopes: ["tools/read"],
  requiredScopes: ["tools/read"],
  inboundDPoP: { required: true },
  devMode: true,
});

const server = new FastMCP<AuthplaneFastMcpSession>({
  name: "my-server",
  version: "1.0.0",
  authenticate: auth.authenticate,
  oauth: auth.oauth,
});

server.addTool({
  name: "read",
  description: "Return the query.",
  parameters: z.object({ query: z.string() }),
  canAccess: requireScopes("tools/read"),
  execute: async ({ query }) => ({ content: [{ type: "text", text: `You asked: ${query}` }] }),
});

await server.start({
  transportType: "httpStream",
  httpStream: { port: 8080, endpoint: "/mcp" },
});

authplaneFastMcpAuth(options) — what you get back

FieldPurpose
authenticateNonNullable<ServerOptions["authenticate"]> — verifies the bearer token, enforces requiredScopes, returns a typed session.
oauthNonNullable<ServerOptions["oauth"]> — publishes RFC 9728 PRM via FastMCP’s oauth config.
clientUnderlying AuthplaneClient (wrapped for elicitation).
verifier, tokenVerifier, protectedResourceMetadata, protectedResourceMetadataUrlLower-level access.

AuthplaneFastMcpSession is the session shape published by authenticate — its token field carries the VerifiedClaims (subject at session.token.sub, plus clientId, scopes, expiresAt, and the raw claims).

DPoP + FastMCP authenticate double-invocation

FastMCP calls authenticate twice per HTTP request (once at the request gate, once when building the session payload). RFC 9449 inbound replay verification can only run once per proof — the adapter uses Node’s AsyncLocalStorage scoped to the request’s async context to collapse both invocations onto a single verifyAccessToken promise. This means inboundDPoP: { required: true } works out of the box against fastmcp@^3.35 (verified on 3.35.x and 4.0.1). If a future FastMCP version moves the second invocation to a callsite that loses async-context propagation, you may see spurious DPoPReplayDetected errors — file an issue.

Cleanup

await auth.client.close() on shutdown.


@authplane/hono — Hono adapter

Quickstart

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { authplaneHonoAuth, type HonoAuthVariables } from "@authplane/hono";

const auth = await authplaneHonoAuth({
  issuer: "http://localhost:9000",
  resource: "http://localhost:8090/mcp",
  scopes: ["tools/weather"],
  devMode: true,
});

const app = new Hono<{ Variables: HonoAuthVariables }>();

app.get(auth.protectedResourceMetadataPath, auth.protectedResourceMetadataHandler);
app.use("/mcp", auth.bearerAuth);

app.post("/mcp", (c) => {
  const info = c.get("auth");   // VerifiedClaims
  return c.json({ ok: true, clientId: info.clientId, scopes: info.scopes });
});

serve({ fetch: app.fetch, port: 8090 });

Return shape

FieldPurpose
clientNon-nullable AuthplaneClient (Hono’s factory always creates one).
verifierAuthplaneResource.
bearerAuthMiddlewareHandler<{ Variables: HonoAuthVariables }>.
protectedResourceMetadataPathRoute path for the PRM handler.
protectedResourceMetadataHandlerHono Handler serving the PRM.
protectedResourceMetadataThe PRM JSON payload.

Context shape (c.get("auth"))

After bearerAuth runs, c.get("auth") returns VerifiedClaims — sub, clientId, scopes, issuer, audience, expiresAt, issuedAt, notBefore, jti, kid, and raw (the full claim object). Call info.requireScope(scope) to enforce a scope inline; the adapter also exports a requireScope("scope") middleware for per-route enforcement.

Runtime portability

@authplane/hono is runtime-agnostic — the adapter itself has no Node-only APIs. Deploy to Node, Bun, Deno, Cloudflare Workers, or wherever Hono runs. The only Node-specific piece in the quickstart above is @hono/node-server; swap it for the runtime’s Hono entrypoint on other platforms.

Cleanup

await auth.client.close() on shutdown.


@authplane/nestjs — NestJS adapter

Quickstart

import "reflect-metadata";
import { Body, Controller, Module, Post, UseGuards } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import {
  AuthInfo, AuthplaneAuthGuard, AuthplaneModule,
  RequireScopes, type VerifiedClaims,
} from "@authplane/nestjs";

@Controller("mcp")
@UseGuards(AuthplaneAuthGuard)
class McpController {
  @Post("tools/weather")
  @RequireScopes("tools/weather")
  async weather(@AuthInfo() info: VerifiedClaims, @Body() body: { city: string }) {
    return {
      content: [{ type: "text", text: `${body.city}: sunny (caller=${info.clientId})` }],
    };
  }
}

@Module({
  imports: [
    AuthplaneModule.forRoot({
      issuer: "http://localhost:9000",
      resource: "http://localhost:8090/mcp",
      scopes: ["tools/weather"],
      devMode: true,
    }),
  ],
  controllers: [McpController],
})
class AppModule {}

const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();       // AuthplaneShutdownHook runs client.close()
await app.listen(8090);

AuthplaneModule.forRoot / .forRootAsync

forRoot(options) for the sync case. forRootAsync({ useFactory | useClass | useExisting, inject, imports }) for the DI case — composes naturally with ConfigModule:

AuthplaneModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (cfg: ConfigService) => ({
    issuer: cfg.getOrThrow("AUTHPLANE_ISSUER"),
    resource: cfg.getOrThrow("AUTHPLANE_RESOURCE"),
    scopes: cfg.get<string[]>("AUTHPLANE_SCOPES") ?? [],
  }),
})

What the module provides (DI tokens)

TokenBound to
AUTHPLANE_CLIENTAuthplaneClient — for token exchange / introspection.
AUTHPLANE_RESOURCEAuthplaneResource — per-resource verifier.
AUTHPLANE_TOKEN_VERIFIERSame instance; separate token so tests can override with a mock.
AuthplaneAuthGuardCanActivate guard (used with @UseGuards).
AuthplaneExceptionFilterRFC 6750 §3-compliant filter.
AuthplaneShutdownHookRuns client.close() on OnApplicationShutdown.

@RequireScopes("scope1", "scope2") decorator + @AuthInfo() param decorator give you clean per-route guarding and access to VerifiedClaims.

Express vs. Fastify

Both platforms work unchanged — the adapter uses an internal RequestAdapter anti-corruption layer over Express + Fastify. @nestjs/platform-express is the default; @nestjs/platform-fastify is a drop-in.

Cleanup

app.enableShutdownHooks() wires the built-in AuthplaneShutdownHook to release timers on app exit. Skip if you’re managing lifecycle manually and call await client.close() yourself.


@authplane/sdk — Core primitives

Framework-agnostic. Use it when:

  • You’re writing a custom transport or framework integration.
  • You need to verify tokens outside a request context (batch, worker).
  • You want programmatic PRM generation without adapter wiring.

Build a client + resource

import { AuthplaneClient, IntrospectionRevocation } from "@authplane/sdk/core";

const client = await AuthplaneClient.create({
  issuer: "https://auth.example.com",
  auth: { clientId: "my-tool", clientSecret: "…" },
  devMode: false,
});

const resource = client.resource({
  resource: "https://mcp.example.com/mcp",
  scopes: ["tools/query"],
  revocationChecker: new IntrospectionRevocation(),
});

// Later, at shutdown:
await client.close();

Verify a token

const claims = await resource.verify(accessToken);
// claims.sub, claims.scopes, claims.audience, claims.dpopProof, claims.expiresAt, ...
claims.requireScope("tools/query");   // throws InsufficientScope if missing

Token exchange

const response = await client.exchange({
  subjectToken: inboundJwt,
  resources: ["https://api.example.com/downstream"],
  scope: "tools/write",
});
// response.accessToken, response.tokenType ("Bearer" | "DPoP"), response.expiresIn

Inbound DPoP

Build a DPoPRequestContext from your framework’s request (method, url, DPoP header values) and pass it to resource.verify(token, dpopContext). Full shape in the core user-guide DPoP section.

Error types

Every failure mode is a typed exception exported from @authplane/sdk/core:

  • TokenMissing, TokenExpired, InvalidSignature, InvalidClaims, InsufficientScope, TokenRevoked
  • DPoPBindingMismatch, DPoPReplayDetected, DPoPProofMissing, InvalidDPoPProof
  • ConsentRequiredError (has consentUrl)
  • AccessDeniedError (exchanging client not allowlisted on the target Resource), InvalidTargetError (resource does not match a granted resource)
  • MetadataFetchError, JWKSFetchError

The module also exports httpStatus(error) and wwwAuthenticate(error, {resourceMetadataUrl, scope}) helper functions producing the correct RFC 6750 / RFC 9449 challenge for your response. error_description is a fixed sentence per error code; pass verboseDescription: true to get the exception message back during development.

Cleanup

await client.close(). Idempotent.


Upgrading to 0.5.0

All five @authplane/* packages move together.

error_description is a fixed sentence per error code, in the challenge and in the adapters’ JSON error body; the body also says invalid_dpop_proof where it said invalid_token. The exception message is no longer sent to unauthenticated callers — log error.message server-side, or pass verboseDescription: true to core’s wwwAuthenticate() in development. tokenVerifier.verifyAccessToken() in @authplane/mcp keeps the original error on .cause.

Identifiers are validated at construction. A resource identifier must be an absolute URL with a scheme and host, with no fragment, no userinfo and an RFC 3986 query; the issuer must have no query, fragment, userinfo, whitespace or control character. Both throw TypeError. A trailing newline from an env var is the usual cause.

The derived PRM URL keeps the identifier’s query (RFC 9728 §3). Update any hard-coded expectation of the query-less URL.

VerifiedClaims.mayAct is deprecated; authserver 0.2.0 no longer issues may_act, so it returns undefined. It is removed in the next minor.

New in this release: the resourceMetadataUrl option, AccessDeniedError / InvalidTargetError, a fetchFailureBackoffSeconds retry floor after a failed metadata or JWKS fetch, and the validateResourceIndicator / validateIssuerIdentifier helpers. The full list is in the 0.5.0 changelog.