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
This commit is contained in:
parent
9a37ad7182
commit
fcc4b03bde
13 changed files with 1737 additions and 0 deletions
78
examples/lazy-auth-server/README.md
Normal file
78
examples/lazy-auth-server/README.md
Normal file
|
|
@ -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://<your-tunnel-host> 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://<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.
|
||||
|
||||
## 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/<seconds>/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/<seconds>/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`
|
||||
42
examples/lazy-auth-server/main.ts
Normal file
42
examples/lazy-auth-server/main.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
27
examples/lazy-auth-server/mcp-app.html
Normal file
27
examples/lazy-auth-server/mcp-app.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Lazy Auth Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="main">
|
||||
<h3>Public App</h3>
|
||||
<p>
|
||||
No auth was needed to render this view. Click below to invoke the
|
||||
protected
|
||||
<code>get_secret</code> tool — the host will run the OAuth flow on 401
|
||||
and retry.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button id="auth-btn">Auth me</button>
|
||||
<button id="revoke-btn">Revoke token</button>
|
||||
<button id="fullscreen-btn" title="Toggle fullscreen">⤢</button>
|
||||
</div>
|
||||
<div id="output"></div>
|
||||
</main>
|
||||
<script type="module" src="/src/mcp-app.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
examples/lazy-auth-server/package.json
Normal file
51
examples/lazy-auth-server/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
16
examples/lazy-auth-server/secret-app.html
Normal file
16
examples/lazy-auth-server/secret-app.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Lazy Auth Demo — Secret</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="main">
|
||||
<h3>Protected App</h3>
|
||||
<div id="output">Waiting for secret data…</div>
|
||||
</main>
|
||||
<script type="module" src="/src/secret-app.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1068
examples/lazy-auth-server/server.ts
Normal file
1068
examples/lazy-auth-server/server.ts
Normal file
File diff suppressed because it is too large
Load diff
100
examples/lazy-auth-server/src/global.css
Normal file
100
examples/lazy-auth-server/src/global.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
66
examples/lazy-auth-server/src/mcp-app.css
Normal file
66
examples/lazy-auth-server/src/mcp-app.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
126
examples/lazy-auth-server/src/mcp-app.ts
Normal file
126
examples/lazy-auth-server/src/mcp-app.ts
Normal file
|
|
@ -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";
|
||||
}
|
||||
});
|
||||
57
examples/lazy-auth-server/src/secret-app.ts
Normal file
57
examples/lazy-auth-server/src/secret-app.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
19
examples/lazy-auth-server/tsconfig.json
Normal file
19
examples/lazy-auth-server/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
17
examples/lazy-auth-server/tsconfig.server.json
Normal file
17
examples/lazy-auth-server/tsconfig.server.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
24
examples/lazy-auth-server/vite.config.ts
Normal file
24
examples/lazy-auth-server/vite.config.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue