diff --git a/examples/lazy-auth-server/README.md b/examples/lazy-auth-server/README.md index 769ac180..f2e75450 100644 --- a/examples/lazy-auth-server/README.md +++ b/examples/lazy-auth-server/README.md @@ -51,6 +51,33 @@ https:///ttl/3600/mcp ← tokens for this connection live 1 hour This works through [RFC 8707 resource indicators](https://www.rfc-editor.org/rfc/rfc8707): MCP hosts send the MCP server URL as the `resource` parameter in OAuth authorization and token requests, and this server issues tokens for that grant with the lifetime encoded in the path (refresh tokens are extended to at least match). The TTL is a _path_ segment rather than a query param because hosts canonicalize resource indicators and strip query strings. Each TTL endpoint also enforces its value as a maximum token age, so connecting to a path with a _lower_ TTL than a token's issued lifetime forces the refresh flow. To exercise the full **re-auth** flow, call the `revoke_auth_token` tool. +## Mounting under a base path + +`createApp()` can also be mounted inside another Express app, so an existing server can host this example at a sub-path of its own origin: + +```ts +import { createApp } from "@modelcontextprotocol/server-lazy-auth"; + +hostApp.use("/lazy-auth", createApp()); +// → MCP endpoint at https:///lazy-auth/mcp +``` + +All advertised URLs (OAuth metadata, `WWW-Authenticate` `resource_metadata`, PRM `resource`, elicitation callbacks) include the mount path automatically, derived from Express's `req.baseUrl`. When `PUBLIC_URL` is set, it must include the mount path (e.g. `https://example.com/lazy-auth`). + +One thing the mounted app cannot do for itself: [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414#section-3) / [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) put well-known discovery documents at the _root_ of the origin with the path inserted after the well-known prefix (`/.well-known/oauth-authorization-server/lazy-auth`), and MCP SDK clients only try that insertion form. The host app must rewrite those root paths into the mount before its other routes: + +```ts +hostApp.use((req, _res, next) => { + const m = req.url.match( + /^\/\.well-known\/(oauth-authorization-server|oauth-protected-resource)\/lazy-auth(\/.*)?$/, + ); + if (m) req.url = `/lazy-auth/.well-known/${m[1]}${m[2] ?? ""}`; + next(); +}); +``` + +Rewriting into the mount (rather than calling the sub-app directly) keeps `req.baseUrl` — and therefore every advertised URL — consistent. + ## How It Works 1. **Connect without auth** — `initialize`, `tools/list`, and public tool calls succeed with no `Authorization` header. diff --git a/examples/lazy-auth-server/server.ts b/examples/lazy-auth-server/server.ts index 1026340f..fe7b1acb 100644 --- a/examples/lazy-auth-server/server.ts +++ b/examples/lazy-auth-server/server.ts @@ -34,11 +34,16 @@ import { SignJWT, jwtVerify } from "jose"; import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath } from "node:url"; -// Works both from source (server.ts) and compiled (dist/server.js) -const DIST_DIR = import.meta.filename.endsWith(".ts") - ? path.join(import.meta.dirname, "dist") - : import.meta.dirname; +// Works both from source (server.ts) and compiled (dist/server.js). Derived +// from import.meta.url rather than import.meta.filename/dirname, which are +// undefined in some module-VM contexts (e.g. importing this package from +// jest). +const SERVER_FILE = fileURLToPath(import.meta.url); +const DIST_DIR = SERVER_FILE.endsWith(".ts") + ? path.join(path.dirname(SERVER_FILE), "dist") + : path.dirname(SERVER_FILE); // ─── Config ────────────────────────────────────────────────────────────────── @@ -88,25 +93,35 @@ function isLoopbackHostname(hostname: string): boolean { * explicitly. */ function resolvePublicUrl(req?: Request): URL { + // Mount path when this app is mounted inside another Express app + // (e.g. app.use("/lazy-auth", createApp())). Empty when standalone. + // PUBLIC_URL, when set, must already include any mount path. + const basePath = req?.baseUrl ?? ""; const envUrl = process.env.PUBLIC_URL; if (envUrl) return new URL(envUrl.endsWith("/") ? envUrl : envUrl + "/"); const host = req?.headers.host; if (host) { try { - const url = new URL(`http://${host}/`); + const url = new URL(`http://${host}${basePath}/`); if (isLoopbackHostname(url.hostname)) return url; } catch { // Malformed Host header → fall through to the localhost default. } } - return new URL(`http://localhost:${PORT}/`); + return new URL(`http://localhost:${PORT}${basePath}/`); +} + +/** Public base URL as a string with no trailing slash (may include a base path). */ +function publicBaseHref(req?: Request): string { + const href = resolvePublicUrl(req).href; + return href.endsWith("/") ? href.slice(0, -1) : href; } /** OAuth issuer. In REACTIVE_AUTH_ONLY mode, uses a /auth subpath so root well-known 404s. - * Otherwise uses the root origin (standard). */ + * Otherwise uses the public base URL (origin + any mount path). */ const ISSUER_SUFFIX = REACTIVE_AUTH_ONLY ? "/auth" : ""; function resolveIssuer(req?: Request): string { - return resolvePublicUrl(req).origin + ISSUER_SUFFIX; + return publicBaseHref(req) + ISSUER_SUFFIX; } // ─── Mock OAuth (HS256, stateless codes) ───────────────────────────────────── @@ -390,7 +405,7 @@ async function handleAuthorize(req: Request, res: Response) { if (approved !== "1") { // Show consent page. Keeps the OAuth popup visible so users can see the flow. - const approveUrl = new URL(resolvePublicUrl(req).origin + "/authorize"); + const approveUrl = new URL(publicBaseHref(req) + "/authorize"); for (const [k, v] of Object.entries(req.query)) if (v) approveUrl.searchParams.set(k, String(v)); approveUrl.searchParams.set("approved", "1"); @@ -695,9 +710,9 @@ export function createServer(authInfo?: AuthInfo, req?: Request): McpServer { // Public tools — no auth. Used to exercise a host's URL-elicitation flow // end-to-end over Streamable HTTP. - const base = resolvePublicUrl(req); + const base = publicBaseHref(req); const callbackUrl = (eid: string) => - `${base.origin}/elicitation-callback?id=${encodeURIComponent(eid)}`; + `${base}/elicitation-callback?id=${encodeURIComponent(eid)}`; server.registerTool( "elicit_url", @@ -850,11 +865,11 @@ export function createApp(): Express { const PRM_PATH = "/auth/prm"; function buildAsMetadata(req: Request) { - const base = resolvePublicUrl(req); + const base = publicBaseHref(req); return { issuer: resolveIssuer(req), // subpath issuer → well-known at /.well-known/.../auth - authorization_endpoint: `${base.origin}/authorize`, - token_endpoint: `${base.origin}/token`, + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/token`, response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], @@ -888,9 +903,9 @@ export function createApp(): Express { // PRM: full version at custom path (referenced via WWW-Authenticate on 401). function buildPrm(req: Request, includeAuth: boolean, resourcePath = "/mcp") { - const base = resolvePublicUrl(req); + const base = publicBaseHref(req); return { - resource: `${base.origin}${resourcePath}`, + resource: `${base}${resourcePath}`, ...(includeAuth ? { authorization_servers: [resolveIssuer(req)], @@ -959,11 +974,11 @@ export function createApp(): Express { res: Response, pathTtl: number | undefined, ) { - const base = resolvePublicUrl(req); + const base = publicBaseHref(req); const resourceMetadataUrl = pathTtl !== undefined - ? `${base.origin}${PRM_PATH}/ttl/${pathTtl}` - : `${base.origin}${PRM_PATH}`; + ? `${base}${PRM_PATH}/ttl/${pathTtl}` + : `${base}${PRM_PATH}`; const body = req.body; const messages = Array.isArray(body) ? body : body ? [body] : []; @@ -1080,15 +1095,15 @@ export function createApp(): Express { // Simple landing page app.get("/", (req, res) => { - const base = resolvePublicUrl(req); + const base = publicBaseHref(req); res .type("text/plain") .send( `Lazy Auth Demo — MCP server\n\n` + - ` MCP endpoint: ${base.origin}/mcp\n` + - ` ${base.origin}/ttl//mcp (custom token TTL, e.g. /ttl/3600/mcp)\n` + - ` AS metadata: ${base.origin}/.well-known/oauth-authorization-server${ISSUER_SUFFIX}\n` + - ` PRM metadata: ${base.origin}${PRM_PATH}\n\n` + + ` MCP endpoint: ${base}/mcp\n` + + ` ${base}/ttl//mcp (custom token TTL, e.g. /ttl/3600/mcp)\n` + + ` AS metadata: ${base}/.well-known/oauth-authorization-server${ISSUER_SUFFIX}\n` + + ` PRM metadata: ${base}${PRM_PATH}\n\n` + `Tools:\n` + ` - show_auth_button (public)\n` + ` - get_secret (protected, requires Bearer token)\n` +