From 0b7241949418c0cbf3484d6f6138f734f0ec2acd Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 2 Jun 2026 13:13:34 +0100 Subject: [PATCH 1/2] lazy-auth-server: support mounting under a base path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createApp() previously built every advertised URL (AS metadata endpoints, PRM resource, WWW-Authenticate resource_metadata, consent approve link, elicitation callbacks) from the bare origin, so mounting the app inside another Express app — app.use("/lazy-auth", createApp()) — advertised URLs that pointed at the host's root and collided with the host's own OAuth endpoints. Derive the public base URL from Express's req.baseUrl instead (empty when standalone, so standalone behavior is unchanged), and treat PUBLIC_URL as including any mount path. Document the mounting pattern, including the root well-known rewrite hosts must add for RFC 8414/9728 path-insertion discovery. --- examples/lazy-auth-server/README.md | 27 ++++++++++++++++ examples/lazy-auth-server/server.ts | 50 +++++++++++++++++------------ 2 files changed, 57 insertions(+), 20 deletions(-) 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..04a7c803 100644 --- a/examples/lazy-auth-server/server.ts +++ b/examples/lazy-auth-server/server.ts @@ -88,25 +88,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 +400,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 +705,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 +860,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 +898,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 +969,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 +1090,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` + From 9110c2c98cca70855ce63078f481acb5f7b6e27f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 2 Jun 2026 13:22:40 +0100 Subject: [PATCH 2/2] lazy-auth-server: derive DIST_DIR from import.meta.url import.meta.filename and import.meta.dirname are undefined in some module-VM contexts (e.g. importing this package from jest), which made createApp() throw at import time. import.meta.url is defined everywhere; derive the dist directory from it instead. --- examples/lazy-auth-server/server.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/lazy-auth-server/server.ts b/examples/lazy-auth-server/server.ts index 04a7c803..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 ──────────────────────────────────────────────────────────────────