From c9601f5a2369032a0ae3de7c34e3a651fe3a2ec4 Mon Sep 17 00:00:00 2001 From: Mel Ludowise Date: Sat, 30 May 2026 12:16:45 -0700 Subject: [PATCH] Add lazy-auth-server example Adds an example MCP App server demonstrating lazy (on-demand) OAuth: the server connects and lists tools without authentication, and only prompts for auth when a protected tool is called, by responding 401 with a WWW-Authenticate header pointing at protected-resource metadata. - Public `show_auth_button` app calls the protected `get_secret` tool via callServerTool; the host runs the OAuth flow on 401 and retries - Embedded mock authorization server (authorization-code + PKCE, short-lived HS256 tokens, refresh + session revocation) so the whole flow runs from a single process - TTL-scoped endpoint paths (/ttl//mcp) let slow or automated clients request longer-lived tokens per connection, threaded through OAuth via RFC 8707 resource indicators - `elicit_url` / `elicit_by_error` tools demonstrating URL elicitation --- examples/lazy-auth-server/README.md | 78 ++ examples/lazy-auth-server/main.ts | 42 + examples/lazy-auth-server/mcp-app.html | 27 + examples/lazy-auth-server/package.json | 51 + examples/lazy-auth-server/secret-app.html | 16 + examples/lazy-auth-server/server.ts | 1068 +++++++++++++++++ examples/lazy-auth-server/src/global.css | 100 ++ examples/lazy-auth-server/src/mcp-app.css | 66 + examples/lazy-auth-server/src/mcp-app.ts | 126 ++ examples/lazy-auth-server/src/secret-app.ts | 57 + examples/lazy-auth-server/tsconfig.json | 19 + .../lazy-auth-server/tsconfig.server.json | 17 + examples/lazy-auth-server/vite.config.ts | 24 + package-lock.json | 46 + 14 files changed, 1737 insertions(+) create mode 100644 examples/lazy-auth-server/README.md create mode 100644 examples/lazy-auth-server/main.ts create mode 100644 examples/lazy-auth-server/mcp-app.html create mode 100644 examples/lazy-auth-server/package.json create mode 100644 examples/lazy-auth-server/secret-app.html create mode 100644 examples/lazy-auth-server/server.ts create mode 100644 examples/lazy-auth-server/src/global.css create mode 100644 examples/lazy-auth-server/src/mcp-app.css create mode 100644 examples/lazy-auth-server/src/mcp-app.ts create mode 100644 examples/lazy-auth-server/src/secret-app.ts create mode 100644 examples/lazy-auth-server/tsconfig.json create mode 100644 examples/lazy-auth-server/tsconfig.server.json create mode 100644 examples/lazy-auth-server/vite.config.ts diff --git a/examples/lazy-auth-server/README.md b/examples/lazy-auth-server/README.md new file mode 100644 index 000000000..769ac180f --- /dev/null +++ b/examples/lazy-auth-server/README.md @@ -0,0 +1,78 @@ +# Example: Lazy Auth Server + +An MCP App example demonstrating **lazy (on-demand) auth**: the server connects and lists tools without any authentication, and only asks for OAuth when a _protected_ tool is actually called — by answering `401` with a `WWW-Authenticate` header. A public MCP App renders an "Auth me" button; clicking it calls a protected tool via [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool). The host sees the 401, runs the OAuth flow, retries, and the result renders inline. + +The embedded OAuth authorization server is a deliberately minimal mock (HS256 JWTs, stateless auth codes, auto-approve consent page) so the whole flow runs from a single process with no external dependencies. It is **not** a production authorization server. + +## Tools + +| Tool | Auth | Description | +| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------- | +| `show_auth_button` | public | Renders buttons: "Auth me" (calls `get_secret`), "Revoke token" (calls `revoke_auth_token`) | +| `get_secret` | **protected** | Returns secret data (requires Bearer token) | +| `revoke_auth_token` | **protected** | Revokes the caller's **entire auth session** (access + refresh token) → forces full re-auth | +| `elicit_url` | public | URL elicitation via `elicitInput` (blocks until the elicitation completes) | +| `elicit_by_error` | public | URL elicitation via the `-32042` (`UrlElicitationRequired`) error; succeeds on retry after completion | + +## Getting Started + +```bash +npm install +npm start +# → MCP endpoint at http://localhost:3097/mcp +``` + +To test with a remote MCP host, expose the server through a public tunnel (see [Testing MCP Apps](../../docs/testing-mcp-apps.md)) and set `PUBLIC_URL` to the tunnel URL so OAuth metadata and callback URLs use it: + +```bash +PUBLIC_URL=https:// npm start +``` + +This example is HTTP-only (no stdio mode): the lazy-auth flow relies on HTTP status codes and OAuth endpoints. + +## Environment Variables + +| Var | Required | Description | +| --------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `JWT_SECRET` | recommended | 32+ byte secret for HS256 signing (`openssl rand -hex 32`). A dev-only default is used if unset. | +| `PORT` | no | Local port (default 3097) | +| `PUBLIC_URL` | non-local | Public base URL of the server (e.g. your tunnel URL). Required for non-localhost deployments; client-supplied `Host` headers are only trusted for loopback hosts | +| `ACCESS_TOKEN_TTL_SECONDS` | no | Default access-token lifetime (default **30**, short on purpose so you can watch the host's refresh flow kick in) | +| `REFRESH_TOKEN_TTL_SECONDS` | no | Default refresh-token lifetime (default **300**). Between the access and refresh TTLs, calls succeed via silent refresh; past it, a full re-auth is required | +| `REACTIVE_AUTH_ONLY` | no | Set `1` to remove auth metadata from the root `/.well-known/oauth-*` paths so hosts can't discover auth preemptively — discovery then only happens via the 401 | + +### Per-connection token lifetimes + +The short defaults are great for watching the refresh flow, but slow or automated clients may want tokens that survive a whole session. Any client can request a different access-token lifetime by connecting to a TTL-scoped MCP endpoint path (capped at 24 hours): + +``` +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. + +## How It Works + +1. **Connect without auth** — `initialize`, `tools/list`, and public tool calls succeed with no `Authorization` header. +2. **Protected tool → 401** — when `get_secret` or `revoke_auth_token` is called without a (valid) Bearer token, the server responds `401` with `WWW-Authenticate: Bearer resource_metadata="…/auth/prm"`. +3. **Discovery** — the host follows `resource_metadata` to the protected-resource metadata (RFC 9728), which points at the authorization server metadata (RFC 8414). +4. **OAuth flow** — the host runs the authorization-code + PKCE flow against the mock `/authorize` and `/token` endpoints (a small consent page keeps the popup visible). +5. **Retry** — the host retries the tool call with the Bearer token and the secret renders inline in the app. +6. **Refresh + revocation** — access tokens expire after 30 seconds and refresh tokens after 5 minutes by default, so all three states are easy to observe: direct success (<30s), silent refresh (30s–5min), and full re-auth (>5min). Connections can request different lifetimes via the `/ttl//mcp` endpoint path (see [Per-connection token lifetimes](#per-connection-token-lifetimes)), and `revoke_auth_token` invalidates the whole session immediately. + +The two `elicit_*` tools demonstrate the complementary pattern of [URL elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation), where the server asks the user to open a URL (e.g. to complete sign-in) either by blocking inside the tool call (`elicit_url`) or by failing with the `-32042` error and succeeding on retry (`elicit_by_error`). + +## Architecture + +- **Stateless auth codes** — grant details are encoded _inside_ the authorization code as a 5-minute JWT, so nothing needs to be stored between requests. +- **Short-lived tokens** — access tokens default to a **30 second** TTL and refresh tokens to **5 minutes**: first `get_secret` succeeds → wait >30s → next call 401s → host refreshes → retry succeeds → wait >5min → full re-auth. Per-connection overrides via the `/ttl//mcp` endpoint path. +- **HS256** — a single shared secret; no key-pair persistence. +- **Per-request MCP server** — each `/mcp` request gets a fresh `McpServer` + `StreamableHTTPServerTransport` (stateless, no session IDs). +- **Session revocation** — all tokens from one OAuth session share a `sid` claim; `revoke_auth_token` adds the sid to an in-memory revocation list checked by both token verification and the refresh grant. + +## Key Files + +- [`server.ts`](server.ts) - OAuth endpoints, discovery metadata, and the MCP server with public + protected tools +- [`main.ts`](main.ts) - HTTP entry point +- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - Public app with the "Auth me" button +- [`secret-app.html`](secret-app.html) / [`src/secret-app.ts`](src/secret-app.ts) - Protected app rendered for `get_secret` diff --git a/examples/lazy-auth-server/main.ts b/examples/lazy-auth-server/main.ts new file mode 100644 index 000000000..a96f34c77 --- /dev/null +++ b/examples/lazy-auth-server/main.ts @@ -0,0 +1,42 @@ +/** + * Entry point for running the Lazy Auth demo MCP server. + * Run with: npx mcp-server-lazy-auth + * Or: node dist/index.js + * + * This example is HTTP-only (no stdio mode): the lazy-auth flow it demonstrates + * relies on HTTP status codes (401 + WWW-Authenticate) and OAuth endpoints. + * + * To test with a remote MCP host, expose the server through a public tunnel + * and set PUBLIC_URL to the tunnel URL so OAuth metadata and callback URLs + * use it (see docs/testing-mcp-apps.md in the repository root). + */ +import { createApp, PORT } from "./server.js"; + +async function main() { + const app = createApp(); + + const httpServer = app.listen(PORT, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } + console.log( + `Lazy Auth demo MCP server listening on http://localhost:${PORT}/mcp`, + ); + console.log( + ` Tools: show_auth_button, get_secret [PROTECTED], revoke_auth_token [PROTECTED], elicit_url, elicit_by_error`, + ); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/lazy-auth-server/mcp-app.html b/examples/lazy-auth-server/mcp-app.html new file mode 100644 index 000000000..7040f15f9 --- /dev/null +++ b/examples/lazy-auth-server/mcp-app.html @@ -0,0 +1,27 @@ + + + + + + + Lazy Auth Demo + + +
+

Public App

+

+ No auth was needed to render this view. Click below to invoke the + protected + get_secret tool — the host will run the OAuth flow on 401 + and retry. +

+
+ + + +
+
+
+ + + diff --git a/examples/lazy-auth-server/package.json b/examples/lazy-auth-server/package.json new file mode 100644 index 000000000..74f8b0293 --- /dev/null +++ b/examples/lazy-auth-server/package.json @@ -0,0 +1,51 @@ +{ + "name": "@modelcontextprotocol/server-lazy-auth", + "version": "1.7.2", + "type": "module", + "description": "MCP App example demonstrating lazy (on-demand) OAuth: public tools work unauthenticated, protected tools return 401 + WWW-Authenticate so the host runs the OAuth flow only when needed", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/lazy-auth-server" + }, + "license": "MIT", + "main": "dist/server.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && cross-env INPUT=secret-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", + "watch": "concurrently \"cross-env INPUT=mcp-app.html vite build --watch\" \"cross-env INPUT=secret-app.html vite build --watch\"", + "serve": "bun --watch main.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "jose": "^6.0.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "22.10.0", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + }, + "types": "dist/server.d.ts", + "exports": { + ".": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "bin": { + "mcp-server-lazy-auth": "dist/index.js" + } +} diff --git a/examples/lazy-auth-server/secret-app.html b/examples/lazy-auth-server/secret-app.html new file mode 100644 index 000000000..b2ab00ba1 --- /dev/null +++ b/examples/lazy-auth-server/secret-app.html @@ -0,0 +1,16 @@ + + + + + + + Lazy Auth Demo — Secret + + +
+

Protected App

+
Waiting for secret data…
+
+ + + diff --git a/examples/lazy-auth-server/server.ts b/examples/lazy-auth-server/server.ts new file mode 100644 index 000000000..fa46d6790 --- /dev/null +++ b/examples/lazy-auth-server/server.ts @@ -0,0 +1,1068 @@ +/** + * Lazy Auth demo MCP server. + * + * Demonstrates "lazy" (on-demand) OAuth for MCP servers and Apps: + * + * - The server connects without authentication: `initialize`, `tools/list`, + * and public tool calls all succeed with no Bearer token. + * - Protected tools (`get_secret`, `revoke_auth_token`) return 401 with a + * `WWW-Authenticate` header pointing at protected-resource metadata, so the + * host only runs the OAuth flow when a protected tool is actually called. + * - The `show_auth_button` MCP App is public; clicking its button calls the + * protected `get_secret` tool via `callServerTool`. The host sees the 401, + * runs OAuth, retries, and the result renders inline. + * + * The embedded OAuth authorization server is intentionally minimal and + * stateless (HS256 JWTs, auth codes encoded as short-lived JWTs) so the demo + * can run anywhere with a single env var. It is NOT a production AS. + */ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import cors from "cors"; +import express, { type Express, type Request, type Response } from "express"; +import { SignJWT, jwtVerify } from "jose"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +// 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; + +// ─── Config ────────────────────────────────────────────────────────────────── + +export const PORT = parseInt(process.env.PORT ?? "3097", 10); +const JWT_SECRET = new TextEncoder().encode( + process.env.JWT_SECRET ?? + "dev-insecure-secret-do-not-use-in-production-" + "x".repeat(20), +); +const PROTECTED_TOOLS = new Set(["get_secret", "revoke_auth_token"]); +// Token lifetimes. Defaults are deliberately short so the host's refresh flow +// (30s–5min after auth) and full re-auth (>5min) are easy to observe in a demo. +// Slow or automated clients can request longer-lived tokens per connection by +// connecting to a TTL-scoped endpoint path (/ttl//mcp) — see +// ttlFromResource() and the README. +const ACCESS_TOKEN_TTL_SECONDS = parseInt( + process.env.ACCESS_TOKEN_TTL_SECONDS ?? "30", + 10, +); +const REFRESH_TOKEN_TTL_SECONDS = parseInt( + process.env.REFRESH_TOKEN_TTL_SECONDS ?? "300", + 10, +); +// Cap on per-connection `?token_ttl_s=` requests. +const MAX_TOKEN_TTL_SECONDS = 86400; +// When true: root well-known PRM returns *stub* metadata WITHOUT authorization_servers, +// and root well-known AS is 404. Hosts probing well-known on connect see "resource has +// PRM but no auth configured" → connect without OAuth. Real PRM (with authorization_servers) +// is only at /auth/prm, reachable via WWW-Authenticate on 401. Default false = standard. +const REACTIVE_AUTH_ONLY = process.env.REACTIVE_AUTH_ONLY === "1"; + +/** Hostname is localhost / a loopback address (URL-style, so IPv6 keeps its brackets). */ +function isLoopbackHostname(hostname: string): boolean { + return ( + ["localhost", "127.0.0.1", "[::1]"].includes(hostname) || + hostname.endsWith(".localhost") + ); +} + +/** + * Resolve the public base URL for this deployment. + * + * Precedence: the PUBLIC_URL env var, then the request's Host header (loopback + * hosts only), then http://localhost:PORT. Host / X-Forwarded-* headers naming + * non-loopback hosts are deliberately NOT trusted — a client could spoof them + * to make OAuth metadata and callback URLs point at a host it controls. + * Non-localhost deployments (e.g. behind a public tunnel) must set PUBLIC_URL + * explicitly. + */ +function resolvePublicUrl(req?: Request): URL { + 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}/`); + if (isLoopbackHostname(url.hostname)) return url; + } catch { + // Malformed Host header → fall through to the localhost default. + } + } + return new URL(`http://localhost:${PORT}/`); +} + +/** OAuth issuer. In REACTIVE_AUTH_ONLY mode, uses a /auth subpath so root well-known 404s. + * Otherwise uses the root origin (standard). */ +const ISSUER_SUFFIX = REACTIVE_AUTH_ONLY ? "/auth" : ""; +function resolveIssuer(req?: Request): string { + return resolvePublicUrl(req).origin + ISSUER_SUFFIX; +} + +// ─── Mock OAuth (HS256, stateless codes) ───────────────────────────────────── + +interface CodePayload { + client_id: string; + redirect_uri: string; + code_challenge?: string; + code_challenge_method?: string; + scope?: string; + /** Requested access-token TTL, from `?token_ttl_s=` on the resource indicator. */ + token_ttl_s?: number; +} + +/** Validate + cap a raw TTL value (from a path segment or query param). */ +function parseTtlSeconds(raw: string | undefined): number | undefined { + const ttl = parseInt(raw ?? "", 10); + return Number.isFinite(ttl) && ttl > 0 + ? Math.min(ttl, MAX_TOKEN_TTL_SECONDS) + : undefined; +} + +/** + * Extract the requested access-token TTL from a resource indicator (RFC 8707). + * + * MCP hosts send `resource=` — the URL the client connected + * to — in authorization and token requests. Clients opt into a non-default + * token lifetime by connecting to a TTL-scoped MCP endpoint path: + * + * /ttl//mcp (e.g. /ttl/3600/mcp for 1-hour tokens) + * + * The TTL lives in the PATH because hosts canonicalize resource indicators and + * strip query strings (verified against real hosts), so `?token_ttl_s=` query + * params would never reach this server's OAuth endpoints. The query param is + * still honored as a fallback for clients that preserve it. + */ +function ttlFromResource(resource: string | undefined): number | undefined { + if (!resource) return undefined; + try { + const url = new URL(resource); + const pathMatch = url.pathname.match(/\/ttl\/(\d+)\/mcp$/); + return ( + parseTtlSeconds(pathMatch?.[1]) ?? + parseTtlSeconds(url.searchParams.get("token_ttl_s") ?? undefined) + ); + } catch { + return undefined; + } +} + +interface AuthInfo { + token: string; + sub: string; + sid: string; +} + +// ─── Session revocation ─────────────────────────────────────────────────────── +// +// Each OAuth session (authorize → code → tokens) gets a single `sid`; all access & +// refresh tokens in that session carry it. Revoking the sid invalidates both → host +// must do a full re-OAuth (refresh grant also fails). +// +// Storage is an in-memory Map, which is fine for a single-process demo server. If +// you deploy this to a multi-instance/serverless environment, back it with a +// shared store instead. + +const revokedSids = new Map(); // sid → unix GC time +// Revoked-sid entries need only outlive the longest token TTL; add small clock-skew grace. +const REVOCATION_GC_TTL_SECONDS = + Math.max( + ACCESS_TOKEN_TTL_SECONDS, + REFRESH_TOKEN_TTL_SECONDS, + MAX_TOKEN_TTL_SECONDS, + ) + 10; + +function revokeSession(sid: string): void { + revokedSids.set( + sid, + Math.floor(Date.now() / 1000) + REVOCATION_GC_TTL_SECONDS, + ); + const now = Math.floor(Date.now() / 1000); + for (const [k, gc] of revokedSids) if (gc < now) revokedSids.delete(k); +} + +function isSessionRevoked(sid: string | undefined): boolean { + if (!sid) return false; + return revokedSids.has(sid); +} + +async function signAccessToken( + sub: string, + scope: string, + sid: string, + issuer: string, + audience: string, + ttlSeconds: number = ACCESS_TOKEN_TTL_SECONDS, +): Promise { + return new SignJWT({ sub, scope, sid }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer(issuer) + .setAudience(audience) + .setExpirationTime(`${ttlSeconds}s`) + .sign(JWT_SECRET); +} + +async function signRefreshToken( + sub: string, + scope: string, + sid: string, + issuer: string, + ttlSeconds: number = REFRESH_TOKEN_TTL_SECONDS, + accessTtlSeconds?: number, +): Promise { + // access_ttl_s carries the grant's requested access-token TTL forward so + // refreshed access tokens keep the same lifetime as the original. + return new SignJWT({ + sub, + scope, + sid, + typ: "refresh", + ...(accessTtlSeconds !== undefined + ? { access_ttl_s: accessTtlSeconds } + : {}), + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer(issuer) + .setExpirationTime(`${ttlSeconds}s`) + .sign(JWT_SECRET); +} + +async function verifyRefreshToken( + token: string, + issuer: string, +): Promise< + | { sub: string; scope: string; sid: string; accessTtlSeconds?: number } + | undefined +> { + try { + const { payload } = await jwtVerify(token, JWT_SECRET, { issuer }); + if (payload.typ !== "refresh") return undefined; + const sid = payload.sid as string | undefined; + if (isSessionRevoked(sid)) return undefined; // session revoked → refresh fails + return { + sub: payload.sub ?? "", + scope: (payload.scope as string) ?? "", + sid: sid ?? "", + accessTtlSeconds: + typeof payload.access_ttl_s === "number" + ? payload.access_ttl_s + : undefined, + }; + } catch { + return undefined; + } +} + +/** Encode grant details inside the code itself (5min expiry) — no server storage. */ +async function signAuthCode( + payload: CodePayload, + issuer: string, +): Promise { + return new SignJWT({ ...payload, typ: "code" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer(issuer) + .setExpirationTime("5m") + .sign(JWT_SECRET); +} + +async function verifyAuthCode( + code: string, + issuer: string, +): Promise { + try { + const { payload } = await jwtVerify(code, JWT_SECRET, { issuer }); + if (payload.typ !== "code") return undefined; + return payload as unknown as CodePayload; + } catch { + return undefined; + } +} + +async function verifyAccessToken( + token: string, + issuer: string, + maxAgeSeconds?: number, +): Promise { + try { + const { payload } = await jwtVerify(token, JWT_SECRET, { issuer }); + if (payload.typ === "code" || payload.typ === "refresh") return undefined; // only plain access tokens + const sid = payload.sid as string | undefined; + if (isSessionRevoked(sid)) return undefined; // session revoked + // Per-connection TTL override (?token_ttl_s= on the MCP URL): treat tokens + // older than maxAgeSeconds as expired even though their exp claim is still + // in the future. Lets one deployment serve both long-TTL (default) and + // short-TTL (refresh-flow testing) connections. + if (maxAgeSeconds !== undefined && typeof payload.iat === "number") { + const ageSeconds = Math.floor(Date.now() / 1000) - payload.iat; + if (ageSeconds > maxAgeSeconds) return undefined; + } + return { token, sub: payload.sub ?? "", sid: sid ?? "" }; + } catch { + return undefined; + } +} + +function escapeHtml(s: string) { + return s.replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ]!, + ); +} + +/** + * This demo AS has no client registry, so it can't check redirect URIs against + * pre-registered values. At minimum, only allow https targets (or http on + * loopback for local development hosts) so the consent page never links to, + * or redirects the authorization code to, an unexpected scheme. + */ +function isAllowedRedirectUri(redirectUri: string): boolean { + try { + const url = new URL(redirectUri); + if (url.protocol === "https:") return true; + return url.protocol === "http:" && isLoopbackHostname(url.hostname); + } catch { + return false; + } +} + +async function handleAuthorize(req: Request, res: Response) { + const { + client_id, + redirect_uri, + state, + code_challenge, + code_challenge_method, + scope, + approved, + resource, + } = req.query as Record; + if (!redirect_uri) { + res.status(400).json({ + error: "invalid_request", + error_description: "Missing redirect_uri", + }); + return; + } + if (!isAllowedRedirectUri(redirect_uri)) { + res.status(400).json({ + error: "invalid_request", + error_description: "redirect_uri must use https (or http on localhost)", + }); + return; + } + const issuer = resolveIssuer(req); + + 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"); + for (const [k, v] of Object.entries(req.query)) + if (v) approveUrl.searchParams.set(k, String(v)); + approveUrl.searchParams.set("approved", "1"); + const denyUrl = new URL(redirect_uri); + denyUrl.searchParams.set("error", "access_denied"); + if (state) denyUrl.searchParams.set("state", state); + res.type("text/html").send(/*html*/ ` +Authorize + +
+

🔑 Mock Authorization

+

An application is requesting access:

+
Client
${escapeHtml(client_id ?? "(none)")}
+
Scope
${escapeHtml(scope ?? "(default)")}
+
Redirect
${escapeHtml(redirect_uri)}
+ + +
`); + return; + } + + // Per-grant token TTL, requested via `?token_ttl_s=` on the resource + // indicator (the MCP URL the host is authorizing for). Encoded into the + // auth code so /token can issue tokens with the requested lifetime. + const tokenTtl = ttlFromResource(resource); + const code = await signAuthCode( + { + client_id, + redirect_uri, + code_challenge, + code_challenge_method, + scope, + ...(tokenTtl !== undefined ? { token_ttl_s: tokenTtl } : {}), + }, + issuer, + ); + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set("code", code); + if (state) redirectUrl.searchParams.set("state", state); + console.log( + `[auth] issued code for client_id=${client_id}` + + (tokenTtl !== undefined ? ` (token_ttl_s=${tokenTtl})` : ""), + ); + res.redirect(redirectUrl.href); +} + +async function handleToken(req: Request, res: Response) { + const { grant_type, code, code_verifier, refresh_token, resource } = req.body; + const issuer = resolveIssuer(req); + const audience = resolvePublicUrl(req).href; + + if (grant_type === "refresh_token") { + const refreshClaims = await verifyRefreshToken(refresh_token, issuer); + if (!refreshClaims) { + console.log(`[auth] refresh rejected (invalid/revoked)`); + res.status(400).json({ + error: "invalid_grant", + error_description: "Refresh token invalid or revoked", + }); + return; + } + // Refreshed access tokens keep the grant's requested TTL: prefer the + // resource indicator on this request, else the TTL carried in the refresh + // token, else the default. + const accessTtl = + ttlFromResource(resource) ?? + refreshClaims.accessTtlSeconds ?? + ACCESS_TOKEN_TTL_SECONDS; + const access_token = await signAccessToken( + refreshClaims.sub, + refreshClaims.scope, + refreshClaims.sid, + issuer, + audience, + accessTtl, + ); + console.log( + `[auth] refreshed access token (sid=${refreshClaims.sid}, ttl=${accessTtl}s)`, + ); + res.json({ + access_token, + token_type: "Bearer", + expires_in: accessTtl, + scope: refreshClaims.scope, + refresh_token, + }); + return; + } + if (grant_type !== "authorization_code") { + res.status(400).json({ error: "unsupported_grant_type" }); + return; + } + const stored = await verifyAuthCode(code, issuer); + if (!stored) { + res.status(400).json({ + error: "invalid_grant", + error_description: "Invalid or expired code", + }); + return; + } + if (stored.code_challenge) { + if (!code_verifier) { + res.status(400).json({ + error: "invalid_grant", + error_description: "Missing code_verifier", + }); + return; + } + const hash = crypto + .createHash("sha256") + .update(code_verifier) + .digest("base64url"); + if (hash !== stored.code_challenge) { + res.status(400).json({ + error: "invalid_grant", + error_description: "PKCE verification failed", + }); + return; + } + } + const scope = stored.scope ?? "read:secret"; + const sid = crypto.randomBytes(16).toString("hex"); // new session per authorization_code grant + // Per-grant TTL: prefer the resource indicator on this request (RFC 8707), + // else the TTL encoded in the auth code at /authorize, else the default. + // Refresh tokens must outlive the access tokens they can mint. + const accessTtl = + ttlFromResource(resource) ?? stored.token_ttl_s ?? ACCESS_TOKEN_TTL_SECONDS; + const refreshTtl = Math.max(REFRESH_TOKEN_TTL_SECONDS, accessTtl); + const access_token = await signAccessToken( + "mock-user-123", + scope, + sid, + issuer, + audience, + accessTtl, + ); + const refresh = await signRefreshToken( + "mock-user-123", + scope, + sid, + issuer, + refreshTtl, + accessTtl, + ); + console.log( + `[auth] exchanged code → token (client_id=${stored.client_id}, scope=${scope}, sid=${sid}, access_ttl=${accessTtl}s, refresh_ttl=${refreshTtl}s)`, + ); + res.json({ + access_token, + token_type: "Bearer", + expires_in: accessTtl, + scope, + refresh_token: refresh, + }); +} + +// ─── Elicitation state (module-level — survives across SHTTP requests) ────── +// +// Streamable HTTP creates a fresh McpServer per request, so state must live at +// module scope. This is fine for a single-process demo server; back it with a +// shared store if you deploy multiple instances. + +/** elicitationIds the user has completed via /elicitation-callback */ +const completedElicitations = new Set(); +/** elicit_by_error: remember the last-issued eid so retries can check it */ +let lastIssuedElicitationId: string | undefined; + +// ─── MCP Server ────────────────────────────────────────────────────────────── + +/** + * Creates a new MCP server instance with tools and resources registered. + * + * @param authInfo - Verified Bearer token info, if the request carried one. + * @param req - The inbound HTTP request (used to resolve the public URL). + */ +export function createServer(authInfo?: AuthInfo, req?: Request): McpServer { + const server = new McpServer({ name: "Lazy Auth Demo", version: "1.0.0" }); + + const buttonUri = "ui://lazy-auth/mcp-app.html"; + registerAppTool( + server, + "show_auth_button", + { + title: "Show Auth Button", + description: + "Public tool: shows a button that triggers the protected get_secret tool.", + inputSchema: {}, + _meta: { ui: { resourceUri: buttonUri } }, + }, + async (): Promise => ({ + content: [{ type: "text", text: "ok" }], + }), + ); + registerAppResource( + server, + buttonUri, + buttonUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: buttonUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + const secretUri = "ui://lazy-auth/secret-app.html"; + registerAppTool( + server, + "get_secret", + { + title: "Get Secret", + description: + "Protected tool: returns secret data. Requires authentication.", + inputSchema: {}, + _meta: { ui: { resourceUri: secretUri } }, + }, + async (): Promise => { + if (!authInfo) { + return { + isError: true, + content: [{ type: "text", text: "Authentication required." }], + }; + } + const secret = { + subject: authInfo.sub, + secret: "the-answer-is-42", + issuedAt: new Date().toISOString(), + }; + return { + content: [{ type: "text", text: JSON.stringify(secret, null, 2) }], + }; + }, + ); + registerAppResource( + server, + secretUri, + secretUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "secret-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: secretUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + server.registerTool( + "revoke_auth_token", + { + title: "Revoke Auth Token", + description: + "Protected tool: revokes the caller's entire auth session (access + refresh token).", + inputSchema: {}, + }, + async (): Promise => { + if (!authInfo) { + return { + isError: true, + content: [{ type: "text", text: "Authentication required." }], + }; + } + revokeSession(authInfo.sid); + console.log( + `[auth] revoked session sid=${authInfo.sid} (total revoked sessions: ${revokedSids.size})`, + ); + return { + content: [ + { + type: "text", + text: `Session revoked (sid: ${authInfo.sid}). Access + refresh token invalid → full re-auth required.`, + }, + ], + }; + }, + ); + + // ── Elicitation test tools ──────────────────────────────────────────────── + // Public tools — no auth. Used to exercise a host's URL-elicitation flow + // end-to-end over Streamable HTTP. + + const base = resolvePublicUrl(req); + const callbackUrl = (eid: string) => + `${base.origin}/elicitation-callback?id=${encodeURIComponent(eid)}`; + + server.registerTool( + "elicit_url", + { + title: "Elicit URL (session-callback)", + description: + "URL elicitation via elicitInput. Blocks until the client accepts; sends ElicitCompleteNotification before returning.", + inputSchema: {}, + }, + async (_args, extra): Promise => { + const eid = crypto.randomUUID(); + // Defer notifier creation — checks client capabilities which throws if + // client doesn't advertise elicitation. Create lazily after elicitInput + // succeeds (implies client supports it). + console.log(`[elicit_url] eid=${eid} — blocking on elicitInput`); + const result = await server.server.elicitInput( + { + mode: "url", + url: callbackUrl(eid), + message: "Please open this URL to continue.", + elicitationId: eid, + }, + { relatedRequestId: extra.requestId, timeout: 300_000 }, + ); + // Send completion notification before returning — lands on the same SSE + // stream as this tool response. For Streamable HTTP this is the ONLY + // window where the server can push a notification (stream closes right + // after). The notification is optional per spec — skip if the client + // doesn't support it. + try { + const notifier = server.server.createElicitationCompletionNotifier(eid); + await notifier(); + console.log( + `[elicit_url] eid=${eid} — notified, result=${result.action}`, + ); + } catch (e) { + console.log( + `[elicit_url] eid=${eid} — notify skipped: ${e instanceof Error ? e.message : e}, result=${result.action}`, + ); + } + return { + content: [ + { type: "text", text: `elicit_url: ${JSON.stringify(result)}` }, + ], + }; + }, + ); + + server.registerTool( + "elicit_by_error", + { + title: "Elicit by -32042 Error", + description: + "Throws UrlElicitationRequiredError (-32042). Client must open the URL, then retry. On retry, if the last-issued elicitationId was completed, sends ElicitCompleteNotification and succeeds.", + inputSchema: {}, + }, + async (): Promise => { + // Retry path: check if the user completed the elicitation we last issued. + // Module-level state survives across Streamable HTTP requests in a single + // process. Checking ANY completed eid (not just last-issued) would be more + // forgiving, but last-issued is the stricter real-world pattern hosts must + // handle. + if ( + lastIssuedElicitationId && + completedElicitations.has(lastIssuedElicitationId) + ) { + const eid = lastIssuedElicitationId; + completedElicitations.delete(eid); + lastIssuedElicitationId = undefined; + // Send notification DURING this (retry) tool call — only window where + // the Streamable HTTP stream is open. The notification is optional per + // spec — createElicitationCompletionNotifier throws if the client + // doesn't advertise the capability, but the tool still succeeds. + try { + const notifier = + server.server.createElicitationCompletionNotifier(eid); + await notifier(); + console.log( + `[elicit_by_error] eid=${eid} — completed on retry, notified`, + ); + } catch (e) { + console.log( + `[elicit_by_error] eid=${eid} — completed on retry, notify skipped: ${e instanceof Error ? e.message : e}`, + ); + } + return { + content: [ + { + type: "text", + text: `Auth completed (${eid}). Tool succeeded on retry.`, + }, + ], + }; + } + const eid = crypto.randomUUID(); + lastIssuedElicitationId = eid; + console.log(`[elicit_by_error] eid=${eid} — throwing -32042`); + throw new UrlElicitationRequiredError([ + { + mode: "url", + url: callbackUrl(eid), + message: + "This tool requires authentication. Please open the link to continue.", + elicitationId: eid, + }, + ]); + }, + ); + + return server; +} + +// ─── Express app ───────────────────────────────────────────────────────────── + +/** + * Creates the Express app: OAuth endpoints, discovery metadata, and the /mcp + * endpoint with lazy (401-triggered) auth on protected tools. + */ +export function createApp(): Express { + const app = express(); + app.use(cors({ exposedHeaders: ["WWW-Authenticate"] })); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + // Request tracing: log every inbound request (incl. query params) so we can + // see what hosts probe during connect and what they send through OAuth. + app.use((req, _res, next) => { + const ua = (req.headers["user-agent"] ?? "").slice(0, 80); + const auth = req.headers.authorization ? "[Bearer]" : "[no-auth]"; + const query = Object.keys(req.query).length + ? ` query=${JSON.stringify(req.query)}` + : ""; + console.log(`[req] ${req.method} ${req.path} ${auth}${query} ua=${ua}`); + next(); + }); + + // OAuth endpoints + app.get("/authorize", handleAuthorize); + app.post("/token", handleToken); + + // OAuth discovery metadata. In REACTIVE_AUTH_ONLY mode, PRM + AS are kept off the + // ROOT /.well-known paths so MCP hosts can't proactively probe them and trigger + // preemptive auth during connection — discovery is purely reactive: + // 401 → WWW-Authenticate → PRM → AS → OAuth flow. + const PRM_PATH = "/auth/prm"; + + function buildAsMetadata(req: Request) { + const base = resolvePublicUrl(req); + return { + issuer: resolveIssuer(req), // subpath issuer → well-known at /.well-known/.../auth + authorization_endpoint: `${base.origin}/authorize`, + token_endpoint: `${base.origin}/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["read:secret"], + client_id_metadata_document_supported: true, + }; + } + + // AS metadata: serve at the issuer's well-known location. + // - REACTIVE_AUTH_ONLY: only at /.well-known/.../auth (subpath); root 404s. + // - Default: also at root /.well-known/... (standard; some hosts probe this on connect). + if (ISSUER_SUFFIX) { + // Per RFC 8414 §3: issuer /auth → /.well-known/oauth-authorization-server/auth + app.get( + `/.well-known/oauth-authorization-server${ISSUER_SUFFIX}`, + (req, res) => res.json(buildAsMetadata(req)), + ); + // RFC 8615 style (well-known between host and path): + app.get( + `${ISSUER_SUFFIX}/.well-known/oauth-authorization-server`, + (req, res) => res.json(buildAsMetadata(req)), + ); + } + if (!REACTIVE_AUTH_ONLY) { + // Default mode: full AS metadata at root well-known (standard). + app.get("/.well-known/oauth-authorization-server", (req, res) => + res.json(buildAsMetadata(req)), + ); + } + + // PRM: full version at custom path (referenced via WWW-Authenticate on 401). + function buildPrm(req: Request, includeAuth: boolean, resourcePath = "/mcp") { + const base = resolvePublicUrl(req); + return { + resource: `${base.origin}${resourcePath}`, + ...(includeAuth + ? { + authorization_servers: [resolveIssuer(req)], + scopes_supported: ["read:secret"], + bearer_methods_supported: ["header"], + } + : {}), + }; + } + /** Resource path for a TTL-scoped MCP endpoint, validating the TTL segment. */ + function ttlResourcePath(rawTtl: string): string | undefined { + const ttl = parseTtlSeconds(rawTtl); + return ttl !== undefined ? `/ttl/${ttl}/mcp` : undefined; + } + app.get(PRM_PATH, (req, res) => res.json(buildPrm(req, true))); + app.get(`${PRM_PATH}/ttl/:ttl`, (req, res) => { + const resourcePath = ttlResourcePath(String(req.params.ttl)); + if (!resourcePath) { + res.status(404).json({ error: "invalid_ttl" }); + return; + } + res.json(buildPrm(req, true, resourcePath)); + }); + // Root + path-dependent well-known PRM: in REACTIVE mode these 404 so hosts don't + // interpret their presence as "server has OAuth". In default mode, full PRM. + if (!REACTIVE_AUTH_ONLY) { + app.get("/.well-known/oauth-protected-resource", (req, res) => + res.json(buildPrm(req, true)), + ); + app.get("/.well-known/oauth-protected-resource/mcp", (req, res) => + res.json(buildPrm(req, true)), + ); + // Well-known PRM for TTL-scoped MCP endpoints (RFC 9728 derives this URL + // from the resource path, so each TTL path has its own). + app.get( + "/.well-known/oauth-protected-resource/ttl/:ttl/mcp", + (req, res) => { + const resourcePath = ttlResourcePath(String(req.params.ttl)); + if (!resourcePath) { + res.status(404).json({ error: "invalid_ttl" }); + return; + } + res.json(buildPrm(req, true, resourcePath)); + }, + ); + } + + // /register: some MCP hosts fall through to OAuth dynamic client registration (RFC 7591) + // when all well-known probes 404. We don't support it — return a proper OAuth error so + // the host concludes "auth not available here" and connects without OAuth, instead of + // treating a generic 404 as a connection failure. + app.post("/register", (_req, res) => { + res.status(400).json({ + error: "invalid_request", + error_description: + "Dynamic client registration is not supported. This server uses per-tool auth triggered via WWW-Authenticate on 401.", + }); + }); + + // MCP endpoint handler, shared by the default and TTL-scoped paths. + // pathTtl (from /ttl//mcp) doubles as the acceptance window for + // Bearer tokens: tokens older than it are rejected even if their issued + // lifetime was longer. + async function handleMcp( + req: Request, + res: Response, + pathTtl: number | undefined, + ) { + const base = resolvePublicUrl(req); + const resourceMetadataUrl = + pathTtl !== undefined + ? `${base.origin}${PRM_PATH}/ttl/${pathTtl}` + : `${base.origin}${PRM_PATH}`; + + const body = req.body; + const messages = Array.isArray(body) ? body : body ? [body] : []; + const needsAuth = messages.some( + (msg: { method?: string; params?: { name?: string } }) => + msg?.method === "tools/call" && + PROTECTED_TOOLS.has(msg.params?.name ?? ""), + ); + + // Acceptance window: the path TTL, else the `?token_ttl_s=` query param + // (fallback for clients that preserve query strings). + const tokenMaxAgeSeconds = + pathTtl ?? parseTtlSeconds(String(req.query.token_ttl_s ?? "")); + + let authInfo: AuthInfo | undefined; + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + authInfo = await verifyAccessToken( + authHeader.slice(7), + resolveIssuer(req), + tokenMaxAgeSeconds, + ); + if (!authInfo && needsAuth) { + console.log(`[mcp] 401: invalid token for protected tool`); + res + .status(401) + .set( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="The access token is invalid", resource_metadata="${resourceMetadataUrl}"`, + ) + .json({ + error: "invalid_token", + error_description: "The access token is invalid", + }); + return; + } + } else if (needsAuth) { + console.log(`[mcp] 401: no token for protected tool`); + res + .status(401) + .set( + "WWW-Authenticate", + `Bearer resource_metadata="${resourceMetadataUrl}"`, + ) + .json({ + error: "invalid_token", + error_description: "Authorization required", + }); + return; + } + + const server = createServer(authInfo, req); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("[mcp] Error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + } + + // Default MCP endpoint (default token TTLs). + app.all("/mcp", (req: Request, res: Response) => + handleMcp(req, res, undefined), + ); + // TTL-scoped MCP endpoint: tokens for connections made through this path are + // issued with (and limited to) the requested lifetime, e.g. /ttl/3600/mcp. + app.all("/ttl/:ttl/mcp", (req: Request, res: Response) => { + const pathTtl = parseTtlSeconds(String(req.params.ttl)); + if (pathTtl === undefined) { + res.status(404).json({ error: "invalid_ttl" }); + return; + } + return handleMcp(req, res, pathTtl); + }); + + // Elicitation callback — marks an eid as completed. Module-level state; + // the next elicit_by_error retry reads it. Minimal confirmation page so + // the popup shows something. + app.get("/elicitation-callback", (req, res) => { + const id = String(req.query.id ?? ""); + if (!id) { + res.status(400).type("text/plain").send("missing ?id="); + return; + } + completedElicitations.add(id); + console.log( + `[callback] eid=${id} completed (total: ${completedElicitations.size})`, + ); + res + .type("text/html") + .send( + `Done` + + `` + + `

✓ Elicitation completed

` + + `

${escapeHtml(id)}

` + + `

You can close this tab. The tool will succeed on the next retry.

`, + ); + }); + + // Simple landing page + app.get("/", (req, res) => { + const base = resolvePublicUrl(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` + + `Tools:\n` + + ` - show_auth_button (public)\n` + + ` - get_secret (protected, requires Bearer token)\n` + + ` - revoke_auth_token (protected, revokes caller's auth session)\n` + + ` - elicit_url (public, URL elicitation via elicitInput)\n` + + ` - elicit_by_error (public, URL elicitation via -32042 error)\n`, + ); + }); + + return app; +} diff --git a/examples/lazy-auth-server/src/global.css b/examples/lazy-auth-server/src/global.css new file mode 100644 index 000000000..54a986089 --- /dev/null +++ b/examples/lazy-auth-server/src/global.css @@ -0,0 +1,100 @@ +:root { + color-scheme: light dark; + + /* + * Fallbacks for host style variables used by this app. + * The host may provide these (and many more) via the host context. + */ + --color-text-primary: light-dark(#1f2937, #f3f4f6); + --color-text-inverse: light-dark(#f3f4f6, #1f2937); + --color-text-info: light-dark(#1d4ed8, #60a5fa); + --color-background-primary: light-dark(#ffffff, #1a1a1a); + --color-background-inverse: light-dark(#1a1a1a, #ffffff); + --color-background-info: light-dark(#eff6ff, #1e3a5f); + --color-ring-primary: light-dark(#3b82f6, #60a5fa); + --border-radius-md: 6px; + --border-width-regular: 1px; + --font-sans: + ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --font-weight-normal: 400; + --font-weight-bold: 700; + --font-text-md-size: 1rem; + --font-text-md-line-height: 1.5; + --font-heading-3xl-size: 2.25rem; + --font-heading-3xl-line-height: 1.1; + --font-heading-2xl-size: 1.875rem; + --font-heading-2xl-line-height: 1.2; + --font-heading-xl-size: 1.5rem; + --font-heading-xl-line-height: 1.25; + --font-heading-lg-size: 1.25rem; + --font-heading-lg-line-height: 1.3; + --font-heading-md-size: 1rem; + --font-heading-md-line-height: 1.4; + --font-heading-sm-size: 0.875rem; + --font-heading-sm-line-height: 1.4; + + /* Spacing derived from host typography */ + --spacing-unit: var(--font-text-md-size); + --spacing-xs: calc(var(--spacing-unit) * 0.25); + --spacing-sm: calc(var(--spacing-unit) * 0.5); + --spacing-md: var(--spacing-unit); + --spacing-lg: calc(var(--spacing-unit) * 1.5); + + /* App accent color (customize for your brand) */ + --color-accent: #2563eb; + --color-text-on-accent: #ffffff; +} + +* { + box-sizing: border-box; +} + +html, +body { + font-family: var(--font-sans); + font-size: var(--font-text-md-size); + font-weight: var(--font-weight-normal); + line-height: var(--font-text-md-line-height); + color: var(--color-text-primary); +} + +h1 { + font-size: var(--font-heading-3xl-size); + line-height: var(--font-heading-3xl-line-height); +} +h2 { + font-size: var(--font-heading-2xl-size); + line-height: var(--font-heading-2xl-line-height); +} +h3 { + font-size: var(--font-heading-xl-size); + line-height: var(--font-heading-xl-line-height); +} +h4 { + font-size: var(--font-heading-lg-size); + line-height: var(--font-heading-lg-line-height); +} +h5 { + font-size: var(--font-heading-md-size); + line-height: var(--font-heading-md-line-height); +} +h6 { + font-size: var(--font-heading-sm-size); + line-height: var(--font-heading-sm-line-height); +} + +code, +pre, +kbd { + font-family: var(--font-mono); + font-size: 1em; +} + +b, +strong { + font-weight: var(--font-weight-bold); +} diff --git a/examples/lazy-auth-server/src/mcp-app.css b/examples/lazy-auth-server/src/mcp-app.css new file mode 100644 index 000000000..ba4f323d3 --- /dev/null +++ b/examples/lazy-auth-server/src/mcp-app.css @@ -0,0 +1,66 @@ +.main { + width: 100%; + max-width: 480px; + box-sizing: border-box; + + > * { + margin-top: 0; + margin-bottom: 0; + } + + > * + * { + margin-top: var(--spacing-md); + } +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +button { + padding: var(--spacing-sm) var(--spacing-md); + border: none; + border-radius: var(--border-radius-md); + color: var(--color-text-on-accent); + font-weight: var(--font-weight-bold); + background-color: var(--color-accent); + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + background-color: color-mix( + in srgb, + var(--color-accent) 85%, + var(--color-background-inverse) + ); + } + + &:focus-visible { + outline: calc(var(--border-width-regular) * 2) solid + var(--color-ring-primary); + outline-offset: var(--border-width-regular); + } +} + +.result { + padding: var(--spacing-sm) var(--spacing-md); + border: var(--border-width-regular) solid + color-mix(in srgb, var(--color-text-primary) 25%, transparent); + border-radius: var(--border-radius-md); + font-family: var(--font-mono); + font-size: 0.875em; + white-space: pre-wrap; + word-break: break-word; +} + +.error { + color: var(--color-text-danger, light-dark(#b91c1c, #f87171)); + white-space: pre-wrap; + word-break: break-word; +} diff --git a/examples/lazy-auth-server/src/mcp-app.ts b/examples/lazy-auth-server/src/mcp-app.ts new file mode 100644 index 000000000..f959d7111 --- /dev/null +++ b/examples/lazy-auth-server/src/mcp-app.ts @@ -0,0 +1,126 @@ +/** + * @file Public app view: buttons that call the protected tools. + * + * The view itself renders without auth. Clicking "Auth me" calls the protected + * `get_secret` tool via `callServerTool`; the host receives a 401 from the + * server, runs the OAuth flow, retries the call, and the result renders here. + */ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import "./global.css"; +import "./mcp-app.css"; + +const mainEl = document.querySelector(".main") as HTMLElement; +const authBtn = document.getElementById("auth-btn") as HTMLButtonElement; +const revokeBtn = document.getElementById("revoke-btn") as HTMLButtonElement; +const fullscreenBtn = document.getElementById( + "fullscreen-btn", +) as HTMLButtonElement; +const outputEl = document.getElementById("output")!; + +function handleHostContextChanged(ctx: McpUiHostContext) { + if (ctx.theme) { + applyDocumentTheme(ctx.theme); + } + if (ctx.styles?.variables) { + applyHostStyleVariables(ctx.styles.variables); + } + if (ctx.styles?.css?.fonts) { + applyHostFonts(ctx.styles.css.fonts); + } + if (ctx.safeAreaInsets) { + mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; + mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; + mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; + mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; + } +} + +const app = new App( + { name: "Lazy Auth Demo", version: "1.0.0" }, + { availableDisplayModes: ["inline", "fullscreen"] }, +); + +// The host echoes the result of the originating `show_auth_button` call; nothing to render. +app.ontoolresult = () => {}; +app.onerror = console.error; +app.onhostcontextchanged = handleHostContextChanged; + +function renderOutput(text: string, isError: boolean) { + const div = document.createElement("div"); + div.className = isError ? "error" : "result"; + div.textContent = text; + outputEl.replaceChildren(div); +} + +/** + * Calls a (protected) server tool. The interesting part happens on the host: + * the server answers 401 + WWW-Authenticate, the host runs the OAuth flow, + * then retries the call and returns the result here. + * + * The default MCP request timeout is 60s; the OAuth flow involves a human + * approving a consent page, which can easily take longer. Use a generous + * timeout so the pending call survives the sign-in round trip. + */ +const AUTH_FLOW_TIMEOUT_MS = 5 * 60_000; + +async function callTool(name: string) { + outputEl.replaceChildren(); + try { + console.info(`Calling ${name} tool...`); + const result = await app.callServerTool( + { name, arguments: {} }, + { timeout: AUTH_FLOW_TIMEOUT_MS }, + ); + const text = + result.content?.find((c) => c.type === "text")?.text ?? "(no content)"; + renderOutput(text, result.isError === true); + } catch (e) { + renderOutput(String(e instanceof Error ? e.message : e), true); + } +} + +authBtn.addEventListener("click", async () => { + authBtn.disabled = true; + try { + await callTool("get_secret"); + } finally { + authBtn.disabled = false; + } +}); + +revokeBtn.addEventListener("click", async () => { + revokeBtn.disabled = true; + try { + await callTool("revoke_auth_token"); + } finally { + revokeBtn.disabled = false; + } +}); + +let displayMode = "inline"; +fullscreenBtn.addEventListener("click", async () => { + fullscreenBtn.disabled = true; + try { + const next = displayMode === "fullscreen" ? "inline" : "fullscreen"; + const { mode } = await app.requestDisplayMode({ mode: next }); + displayMode = mode; + } catch (e) { + console.warn("Display mode not supported:", e); + } finally { + fullscreenBtn.disabled = false; + } +}); + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx) { + handleHostContextChanged(ctx); + displayMode = ctx.displayMode ?? "inline"; + } +}); diff --git a/examples/lazy-auth-server/src/secret-app.ts b/examples/lazy-auth-server/src/secret-app.ts new file mode 100644 index 000000000..aeb685728 --- /dev/null +++ b/examples/lazy-auth-server/src/secret-app.ts @@ -0,0 +1,57 @@ +/** + * @file Protected app view: renders the result of the `get_secret` tool. + * + * This view is only reached after the host has completed the OAuth flow + * (the tool call that produces it requires a Bearer token). + */ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import "./global.css"; +import "./mcp-app.css"; + +const mainEl = document.querySelector(".main") as HTMLElement; +const outputEl = document.getElementById("output")!; + +function handleHostContextChanged(ctx: McpUiHostContext) { + if (ctx.theme) { + applyDocumentTheme(ctx.theme); + } + if (ctx.styles?.variables) { + applyHostStyleVariables(ctx.styles.variables); + } + if (ctx.styles?.css?.fonts) { + applyHostFonts(ctx.styles.css.fonts); + } + if (ctx.safeAreaInsets) { + mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; + mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; + mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; + mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; + } +} + +const app = new App({ name: "Lazy Auth Demo (Secret)", version: "1.0.0" }); + +app.onerror = console.error; +app.onhostcontextchanged = handleHostContextChanged; + +app.ontoolresult = (result) => { + const text = + result.content?.find((c) => c.type === "text")?.text ?? "(no content)"; + const div = document.createElement("div"); + div.className = result.isError ? "error" : "result"; + div.textContent = text; + outputEl.replaceChildren(div); +}; + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx) { + handleHostContextChanged(ctx); + } +}); diff --git a/examples/lazy-auth-server/tsconfig.json b/examples/lazy-auth-server/tsconfig.json new file mode 100644 index 000000000..6c553b5d7 --- /dev/null +++ b/examples/lazy-auth-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts", "main.ts"] +} diff --git a/examples/lazy-auth-server/tsconfig.server.json b/examples/lazy-auth-server/tsconfig.server.json new file mode 100644 index 000000000..05ddd8ec4 --- /dev/null +++ b/examples/lazy-auth-server/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["server.ts"] +} diff --git a/examples/lazy-auth-server/vite.config.ts b/examples/lazy-auth-server/vite.config.ts new file mode 100644 index 000000000..6ff6d9979 --- /dev/null +++ b/examples/lazy-auth-server/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/package-lock.json b/package-lock.json index 54af40d9e..be26ff88a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -591,6 +591,48 @@ "dev": true, "license": "MIT" }, + "examples/lazy-auth-server": { + "name": "@modelcontextprotocol/server-lazy-auth", + "version": "1.7.2", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "jose": "^6.0.0" + }, + "bin": { + "mcp-server-lazy-auth": "dist/index.js" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "22.10.0", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, + "examples/lazy-auth-server/node_modules/@types/node": { + "version": "22.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", + "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "examples/lazy-auth-server/node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "examples/map-server": { "name": "@modelcontextprotocol/server-map", "version": "1.7.2", @@ -2678,6 +2720,10 @@ "resolved": "examples/debug-server", "link": true }, + "node_modules/@modelcontextprotocol/server-lazy-auth": { + "resolved": "examples/lazy-auth-server", + "link": true + }, "node_modules/@modelcontextprotocol/server-map": { "resolved": "examples/map-server", "link": true