Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions examples/lazy-auth-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ https://<host>/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://<host>/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.
Expand Down
63 changes: 39 additions & 24 deletions examples/lazy-auth-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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) ─────────────────────────────────────
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -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] : [];
Expand Down Expand Up @@ -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/<seconds>/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/<seconds>/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` +
Expand Down
Loading