Skip to content

Commit fcc4b03

Browse files
Add lazy-auth-server example (#679)
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/<seconds>/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
1 parent 9a37ad7 commit fcc4b03

14 files changed

Lines changed: 1737 additions & 0 deletions
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Example: Lazy Auth Server
2+
3+
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.
4+
5+
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.
6+
7+
## Tools
8+
9+
| Tool | Auth | Description |
10+
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------- |
11+
| `show_auth_button` | public | Renders buttons: "Auth me" (calls `get_secret`), "Revoke token" (calls `revoke_auth_token`) |
12+
| `get_secret` | **protected** | Returns secret data (requires Bearer token) |
13+
| `revoke_auth_token` | **protected** | Revokes the caller's **entire auth session** (access + refresh token) → forces full re-auth |
14+
| `elicit_url` | public | URL elicitation via `elicitInput` (blocks until the elicitation completes) |
15+
| `elicit_by_error` | public | URL elicitation via the `-32042` (`UrlElicitationRequired`) error; succeeds on retry after completion |
16+
17+
## Getting Started
18+
19+
```bash
20+
npm install
21+
npm start
22+
# → MCP endpoint at http://localhost:3097/mcp
23+
```
24+
25+
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:
26+
27+
```bash
28+
PUBLIC_URL=https://<your-tunnel-host> npm start
29+
```
30+
31+
This example is HTTP-only (no stdio mode): the lazy-auth flow relies on HTTP status codes and OAuth endpoints.
32+
33+
## Environment Variables
34+
35+
| Var | Required | Description |
36+
| --------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
37+
| `JWT_SECRET` | recommended | 32+ byte secret for HS256 signing (`openssl rand -hex 32`). A dev-only default is used if unset. |
38+
| `PORT` | no | Local port (default 3097) |
39+
| `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 |
40+
| `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) |
41+
| `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 |
42+
| `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 |
43+
44+
### Per-connection token lifetimes
45+
46+
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):
47+
48+
```
49+
https://<host>/ttl/3600/mcp ← tokens for this connection live 1 hour
50+
```
51+
52+
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.
53+
54+
## How It Works
55+
56+
1. **Connect without auth**`initialize`, `tools/list`, and public tool calls succeed with no `Authorization` header.
57+
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"`.
58+
3. **Discovery** — the host follows `resource_metadata` to the protected-resource metadata (RFC 9728), which points at the authorization server metadata (RFC 8414).
59+
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).
60+
5. **Retry** — the host retries the tool call with the Bearer token and the secret renders inline in the app.
61+
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/<seconds>/mcp` endpoint path (see [Per-connection token lifetimes](#per-connection-token-lifetimes)), and `revoke_auth_token` invalidates the whole session immediately.
62+
63+
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`).
64+
65+
## Architecture
66+
67+
- **Stateless auth codes** — grant details are encoded _inside_ the authorization code as a 5-minute JWT, so nothing needs to be stored between requests.
68+
- **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/<seconds>/mcp` endpoint path.
69+
- **HS256** — a single shared secret; no key-pair persistence.
70+
- **Per-request MCP server** — each `/mcp` request gets a fresh `McpServer` + `StreamableHTTPServerTransport` (stateless, no session IDs).
71+
- **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.
72+
73+
## Key Files
74+
75+
- [`server.ts`](server.ts) - OAuth endpoints, discovery metadata, and the MCP server with public + protected tools
76+
- [`main.ts`](main.ts) - HTTP entry point
77+
- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - Public app with the "Auth me" button
78+
- [`secret-app.html`](secret-app.html) / [`src/secret-app.ts`](src/secret-app.ts) - Protected app rendered for `get_secret`

examples/lazy-auth-server/main.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Entry point for running the Lazy Auth demo MCP server.
3+
* Run with: npx mcp-server-lazy-auth
4+
* Or: node dist/index.js
5+
*
6+
* This example is HTTP-only (no stdio mode): the lazy-auth flow it demonstrates
7+
* relies on HTTP status codes (401 + WWW-Authenticate) and OAuth endpoints.
8+
*
9+
* To test with a remote MCP host, expose the server through a public tunnel
10+
* and set PUBLIC_URL to the tunnel URL so OAuth metadata and callback URLs
11+
* use it (see docs/testing-mcp-apps.md in the repository root).
12+
*/
13+
import { createApp, PORT } from "./server.js";
14+
15+
async function main() {
16+
const app = createApp();
17+
18+
const httpServer = app.listen(PORT, (err) => {
19+
if (err) {
20+
console.error("Failed to start server:", err);
21+
process.exit(1);
22+
}
23+
console.log(
24+
`Lazy Auth demo MCP server listening on http://localhost:${PORT}/mcp`,
25+
);
26+
console.log(
27+
` Tools: show_auth_button, get_secret [PROTECTED], revoke_auth_token [PROTECTED], elicit_url, elicit_by_error`,
28+
);
29+
});
30+
31+
const shutdown = () => {
32+
console.log("\nShutting down...");
33+
httpServer.close(() => process.exit(0));
34+
};
35+
process.on("SIGINT", shutdown);
36+
process.on("SIGTERM", shutdown);
37+
}
38+
39+
main().catch((e) => {
40+
console.error(e);
41+
process.exit(1);
42+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<meta name="color-scheme" content="light dark" />
7+
<title>Lazy Auth Demo</title>
8+
</head>
9+
<body>
10+
<main class="main">
11+
<h3>Public App</h3>
12+
<p>
13+
No auth was needed to render this view. Click below to invoke the
14+
protected
15+
<code>get_secret</code> tool — the host will run the OAuth flow on 401
16+
and retry.
17+
</p>
18+
<div class="actions">
19+
<button id="auth-btn">Auth me</button>
20+
<button id="revoke-btn">Revoke token</button>
21+
<button id="fullscreen-btn" title="Toggle fullscreen"></button>
22+
</div>
23+
<div id="output"></div>
24+
</main>
25+
<script type="module" src="/src/mcp-app.ts"></script>
26+
</body>
27+
</html>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@modelcontextprotocol/server-lazy-auth",
3+
"version": "1.7.2",
4+
"type": "module",
5+
"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",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/modelcontextprotocol/ext-apps",
9+
"directory": "examples/lazy-auth-server"
10+
},
11+
"license": "MIT",
12+
"main": "dist/server.js",
13+
"files": [
14+
"dist"
15+
],
16+
"scripts": {
17+
"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\"",
18+
"watch": "concurrently \"cross-env INPUT=mcp-app.html vite build --watch\" \"cross-env INPUT=secret-app.html vite build --watch\"",
19+
"serve": "bun --watch main.ts",
20+
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
21+
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
22+
"prepublishOnly": "npm run build"
23+
},
24+
"dependencies": {
25+
"@modelcontextprotocol/ext-apps": "^1.0.0",
26+
"@modelcontextprotocol/sdk": "^1.29.0",
27+
"cors": "^2.8.5",
28+
"express": "^5.1.0",
29+
"jose": "^6.0.0"
30+
},
31+
"devDependencies": {
32+
"@types/cors": "^2.8.19",
33+
"@types/express": "^5.0.0",
34+
"@types/node": "22.10.0",
35+
"concurrently": "^9.2.1",
36+
"cross-env": "^10.1.0",
37+
"typescript": "^5.9.3",
38+
"vite": "^6.0.0",
39+
"vite-plugin-singlefile": "^2.3.0"
40+
},
41+
"types": "dist/server.d.ts",
42+
"exports": {
43+
".": {
44+
"types": "./dist/server.d.ts",
45+
"default": "./dist/server.js"
46+
}
47+
},
48+
"bin": {
49+
"mcp-server-lazy-auth": "dist/index.js"
50+
}
51+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<meta name="color-scheme" content="light dark" />
7+
<title>Lazy Auth Demo — Secret</title>
8+
</head>
9+
<body>
10+
<main class="main">
11+
<h3>Protected App</h3>
12+
<div id="output">Waiting for secret data…</div>
13+
</main>
14+
<script type="module" src="/src/secret-app.ts"></script>
15+
</body>
16+
</html>

0 commit comments

Comments
 (0)