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
1 changed files with 1737 additions and 0 deletions
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";
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue