From 2476540fab1f02aa6f32365d20dcd34ab9010c0b Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 5 Jun 2026 20:15:08 +0100 Subject: [PATCH] Add lazy-auth example server mounted at /lazy-auth Mounts @modelcontextprotocol/server-lazy-auth as an additional example, served at /lazy-auth/mcp alongside the other example MCP App servers. Unlike the other examples (plain createServer() factories behind the stateless handler), lazy-auth's value is its HTTP layer: public tools work unauthenticated, protected tools answer 401 + WWW-Authenticate, and a built-in mock OAuth authorization server completes the flow. So it mounts its full Express app (createApp()) under /lazy-auth. - New src/modules/example-apps/lazy-auth.ts mounts the app before the host middleware and adds the RFC 8414/9728 path-insertion well-known rewrite (/.well-known/oauth-authorization-server/lazy-auth -> into the mount) that SDK clients require for discovery under a base path. - PUBLIC_URL for the example is derived from this server's own BASE_URI, so no extra deploy-time configuration is needed; an explicit PUBLIC_URL still wins (e.g. a tunnel). - Integration test covers the full flow: public initialize/tools/list, 401 on the protected tool, mount-prefixed discovery metadata, and the PKCE -> token -> authed get_secret round trip. --- src/index.ts | 8 + src/modules/example-apps/index.ts | 9 +- .../lazy-auth.integration.test.ts | 174 ++++++++++++++++++ src/modules/example-apps/lazy-auth.ts | 51 +++++ 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 src/modules/example-apps/lazy-auth.integration.test.ts create mode 100644 src/modules/example-apps/lazy-auth.ts diff --git a/src/index.ts b/src/index.ts index d587065..7cbc742 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { config } from './config.js'; import { AuthModule } from './modules/auth/index.js'; import { MCPModule } from './modules/mcp/index.js'; import { ExampleAppsModule, AVAILABLE_EXAMPLES } from './modules/example-apps/index.js'; +import { mountLazyAuthExample } from './modules/example-apps/lazy-auth.js'; import { ExternalTokenValidator, InternalTokenValidator, ITokenValidator } from './interfaces/auth-validator.js'; import { redisClient } from './modules/shared/redis.js'; import { logger } from './modules/shared/logger.js'; @@ -48,6 +49,13 @@ async function main() { // This is required for rate limiting to work correctly with real client IPs app.set('trust proxy', true); + // Lazy-auth MCP App example: a full Express app with its own mock OAuth + // authorization server, mounted at /lazy-auth. Registered before the host + // middleware so its CORS and body handling stay self-contained. + if (config.auth.mode !== 'auth_server') { + mountLazyAuthExample(app, config.baseUri); + } + // Basic middleware // Intentionally permissive CORS for public MCP reference server // This allows any MCP client to test against this reference implementation diff --git a/src/modules/example-apps/index.ts b/src/modules/example-apps/index.ts index b649b0c..d2b3ce5 100644 --- a/src/modules/example-apps/index.ts +++ b/src/modules/example-apps/index.ts @@ -33,6 +33,8 @@ import { createServer as createTranscriptServer } from '@modelcontextprotocol/se import { createServer as createVideoResourceServer } from '@modelcontextprotocol/server-video-resource'; import { createServer as createWikiExplorerServer } from '@modelcontextprotocol/server-wiki-explorer'; +import { LAZY_AUTH_SLUG } from './lazy-auth.js'; + declare module "express-serve-static-core" { interface Request { auth?: AuthInfo; @@ -161,5 +163,8 @@ export class ExampleAppsModule { } } -// Export list of available examples for documentation -export const AVAILABLE_EXAMPLES = Object.keys(EXAMPLE_SERVERS); +// Export list of available examples for documentation. The lazy-auth example +// is served at the same /:slug/mcp path shape, but as a full Express app with +// its own OAuth endpoints rather than through the stateless handler above +// (see ./lazy-auth.ts). +export const AVAILABLE_EXAMPLES = [...Object.keys(EXAMPLE_SERVERS), LAZY_AUTH_SLUG]; diff --git a/src/modules/example-apps/lazy-auth.integration.test.ts b/src/modules/example-apps/lazy-auth.integration.test.ts new file mode 100644 index 0000000..eb0ae1c --- /dev/null +++ b/src/modules/example-apps/lazy-auth.integration.test.ts @@ -0,0 +1,174 @@ +/** + * Integration tests for the lazy-auth example mount. + * + * Verifies that the @modelcontextprotocol/server-lazy-auth app, mounted at + * /lazy-auth, advertises URLs under the mount path, that RFC 8414/9728 + * path-insertion well-known URLs at the host root reach the example, that the + * host's own root OAuth endpoints are untouched, and that the full lazy-auth + * flow (401 -> discovery -> PKCE -> token -> authed tool call) works + * end-to-end over HTTP. + */ +import { createHash } from 'crypto'; +import http from 'http'; +import express from 'express'; +import { AddressInfo } from 'net'; +import { mountLazyAuthExample } from './lazy-auth.js'; + +interface HttpResult { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function request(url: string, options: http.RequestOptions = {}, body?: string): Promise { + return new Promise((resolve, reject) => { + const req = http.request(url, options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data })); + }); + req.on('error', reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} + +function callTool(base: string, name: string, accessToken?: string): Promise { + return request( + `${base}/lazy-auth/mcp`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}), + }, + }, + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: {} } }) + ); +} + +describe('Lazy Auth example mount', () => { + let server: http.Server; + let base: string; + + beforeAll(async () => { + const app = express(); + // Mirror src/index.ts: the example mounts before the host's middleware + // and routes. + mountLazyAuthExample(app); + app.use(express.json()); + // Sentinels for the host's own root OAuth surface, which the example's + // well-known rewrite must not shadow. + app.get('/.well-known/oauth-authorization-server', (_req, res) => { + res.json({ issuer: 'HOST-OWN-AS' }); + }); + app.get('/authorize', (_req, res) => { + res.send('HOST-OWN-AUTHORIZE'); + }); + + server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); + }); + base = `http://localhost:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it('serves public MCP requests without auth', async () => { + const res = await request( + `${base}/lazy-auth/mcp`, + { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + }, + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '0' } }, + }) + ); + expect(res.status).toBe(200); + expect(res.body).toContain('Lazy Auth'); + }); + + it('answers 401 with resource_metadata under the mount path for protected tools', async () => { + const res = await callTool(base, 'get_secret'); + expect(res.status).toBe(401); + expect(res.headers['www-authenticate']).toContain(`resource_metadata="${base}/lazy-auth/auth/prm"`); + }); + + it('advertises mount-prefixed URLs in PRM and AS metadata', async () => { + const prm = JSON.parse((await request(`${base}/lazy-auth/auth/prm`)).body); + expect(prm.resource).toBe(`${base}/lazy-auth/mcp`); + expect(prm.authorization_servers).toEqual([`${base}/lazy-auth`]); + + // RFC 8414 path-insertion form at the host root (the only form MCP SDK + // clients try for an issuer with a path). + const asRes = await request(`${base}/.well-known/oauth-authorization-server/lazy-auth`); + expect(asRes.status).toBe(200); + const as = JSON.parse(asRes.body); + expect(as.issuer).toBe(`${base}/lazy-auth`); + expect(as.authorization_endpoint).toBe(`${base}/lazy-auth/authorize`); + expect(as.token_endpoint).toBe(`${base}/lazy-auth/token`); + }); + + it('serves TTL-scoped PRM through the path-insertion form', async () => { + const res = await request(`${base}/.well-known/oauth-protected-resource/lazy-auth/ttl/3600/mcp`); + expect(res.status).toBe(200); + expect(JSON.parse(res.body).resource).toBe(`${base}/lazy-auth/ttl/3600/mcp`); + }); + + it('leaves the host root OAuth surface untouched', async () => { + const as = await request(`${base}/.well-known/oauth-authorization-server`); + expect(JSON.parse(as.body).issuer).toBe('HOST-OWN-AS'); + const authorize = await request(`${base}/authorize`); + expect(authorize.body).toBe('HOST-OWN-AUTHORIZE'); + }); + + it('completes the full lazy-auth flow: PKCE -> token -> authed tool call', async () => { + const verifier = 'v'.repeat(43); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + + const authorizeUrl = new URL(`${base}/lazy-auth/authorize`); + const params: Record = { + client_id: 'test-client', + redirect_uri: 'http://localhost:1234/callback', + code_challenge: challenge, + code_challenge_method: 'S256', + state: 'test-state', + approved: '1', + resource: `${base}/lazy-auth/mcp`, + }; + for (const [k, v] of Object.entries(params)) authorizeUrl.searchParams.set(k, v); + + const authorizeRes = await request(authorizeUrl.href); + expect(authorizeRes.status).toBe(302); + const redirect = new URL(authorizeRes.headers.location!); + const code = redirect.searchParams.get('code')!; + expect(code).toBeTruthy(); + expect(redirect.searchParams.get('state')).toBe('test-state'); + + const tokenRes = await request( + `${base}/lazy-auth/token`, + { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' } }, + new URLSearchParams({ + grant_type: 'authorization_code', + code, + code_verifier: verifier, + resource: `${base}/lazy-auth/mcp`, + }).toString() + ); + expect(tokenRes.status).toBe(200); + const token = JSON.parse(tokenRes.body); + expect(token.access_token).toBeTruthy(); + expect(token.refresh_token).toBeTruthy(); + + const secretRes = await callTool(base, 'get_secret', token.access_token); + expect(secretRes.status).toBe(200); + expect(secretRes.body).toContain('the-answer-is-42'); + }); +}); diff --git a/src/modules/example-apps/lazy-auth.ts b/src/modules/example-apps/lazy-auth.ts new file mode 100644 index 0000000..b5010ca --- /dev/null +++ b/src/modules/example-apps/lazy-auth.ts @@ -0,0 +1,51 @@ +/** + * Lazy Auth Example - Mounts @modelcontextprotocol/server-lazy-auth at /lazy-auth + * + * Unlike the other example servers (plain createServer() factories in + * ./index.ts), the lazy-auth example's whole point is its HTTP layer: public + * tools work unauthenticated, protected tools answer 401 + WWW-Authenticate, + * and a built-in mock OAuth authorization server completes the flow. So we + * mount its full Express app under /lazy-auth instead of adding it to the + * generic stateless handler. + * + * Call mountLazyAuthExample() BEFORE registering the host's own middleware + * (CORS, body parsing, logging) so the example's responses stay fully + * self-contained, and before any other /.well-known routes. + * + * The example advertises absolute OAuth URLs (issuer, authorize/token + * endpoints, PRM resource, WWW-Authenticate resource_metadata). It resolves + * them from PUBLIC_URL, falling back to the request Host header for loopback + * hosts only. We derive PUBLIC_URL from this server's own public base URI - + * already the source of truth for the host's OAuth issuer - so deployments + * need no separate env var. An explicit PUBLIC_URL still wins (e.g. a tunnel); + * omitting baseUri (e.g. tests on an ephemeral port) keeps the package's + * per-request Host resolution. + */ +import { Express, Request, Response, NextFunction } from 'express'; +import { createApp as createLazyAuthApp } from '@modelcontextprotocol/server-lazy-auth'; + +export const LAZY_AUTH_SLUG = 'lazy-auth'; + +export function mountLazyAuthExample(app: Express, baseUri?: string): void { + if (baseUri && !process.env.PUBLIC_URL) { + process.env.PUBLIC_URL = `${baseUri.replace(/\/+$/, '')}/${LAZY_AUTH_SLUG}`; + } + + // RFC 8414/9728 place well-known discovery documents at the origin root, + // with the resource path inserted after the well-known prefix + // (e.g. /.well-known/oauth-authorization-server/lazy-auth), and MCP SDK + // clients only try that insertion form. Rewrite those root paths into the + // mount - rather than dispatching to the sub-app directly - so req.baseUrl + // (and therefore every URL the example advertises) stays consistent. + app.use((req: Request, _res: Response, next: NextFunction) => { + const wellKnown = req.url.match( + /^\/\.well-known\/(oauth-authorization-server|oauth-protected-resource)\/lazy-auth(\/.*)?$/ + ); + if (wellKnown) { + req.url = `/${LAZY_AUTH_SLUG}/.well-known/${wellKnown[1]}${wellKnown[2] ?? ''}`; + } + next(); + }); + + app.use(`/${LAZY_AUTH_SLUG}`, createLazyAuthApp()); +}