Skip to content

Instantly share code, notes, and snippets.

@ochafik
Last active March 11, 2026 16:54
Show Gist options
  • Select an option

  • Save ochafik/5f7b3621e100fc594d9671b7d0e8468a to your computer and use it in GitHub Desktop.

Select an option

Save ochafik/5f7b3621e100fc594d9671b7d0e8468a to your computer and use it in GitHub Desktop.
MCP Auth Button Demo — minimal MCP server showing cross-app auth via callServerTool. Vercel-deployable, stateless (HS256 JWTs, JWT-encoded auth codes).
# Required: 32+ byte secret for HS256 JWT signing (generate with: openssl rand -hex 32)
JWT_SECRET=
# Optional: public URL of this deployment. Auto-detected from VERCEL_URL or request Host if unset.
# PUBLIC_URL=https://my-app.vercel.app
# Optional: access token lifetime in seconds (default 30, short to test refresh flows)
# ACCESS_TOKEN_TTL_SECONDS=30
# Optional: local dev port (ignored on Vercel)
# PORT=3097
# Optional: set TUNNEL=1 to spawn cloudflared tunnel for local dev (requires cloudflared in PATH)
# TUNNEL=1
# Optional: Upstash Redis REST credentials for durable cross-instance revocation on serverless.
# Without these, revocation uses an in-memory Map (works locally; best-effort on Vercel).
# Get free credentials at https://console.upstash.com/
# UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
# UPSTASH_REDIS_REST_TOKEN=xxx
# Optional: set REACTIVE_AUTH_ONLY=1 to 404 root /.well-known/oauth-* paths so hosts
# can't preemptively discover auth during connection. Uses subpath issuer (<origin>/auth)
# per RFC 8414 §3. NOTE: hosts that don't support subpath-issuer resolution will fail
# to connect in this mode. Leave unset (default) for reliable compatibility.
# REACTIVE_AUTH_ONLY=1
registry=https://registry.npmjs.org/

MCP Auth Button Demo

Minimal MCP server demonstrating cross-app auth: a public MCP App renders an "Auth me" button; clicking it calls a protected tool via callServerTool. The MCP host sees the 401, runs the OAuth flow, retries, and the result renders inline.

Serverless-friendly (Vercel): stateless auth codes (JWT-encoded), HS256 signing, single function.

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

Quick start (npx)

npx https://gist.github.com/ochafik/5f7b3621e100fc594d9671b7d0e8468a
# → http://localhost:3097/mcp

Run locally

git clone https://gist.github.com/5f7b3621e100fc594d9671b7d0e8468a.git auth-button-demo
cd auth-button-demo
npm install   # runs `prepare` → compiles index.ts → dist/index.js
npm start     # or: bun index.ts

With a public cloudflared tunnel (requires cloudflared in $PATH):

TUNNEL=1 bun index.ts

Deploy to Vercel

npm install
npx vercel link
printf "%s" "$(openssl rand -hex 32)" | npx vercel env add JWT_SECRET production
# ⚠️ Use printf (NOT echo) — echo adds a trailing \n which becomes part of the value
npx vercel deploy --prod

Optionally set PUBLIC_URL to your stable alias:

echo "https://<your-app>.vercel.app" | npx vercel env add PUBLIC_URL production

Your MCP endpoint will be https://<your-app>.vercel.app/mcp.

Env vars

Var Required Description
JWT_SECRET yes (prod) 32+ byte secret for HS256 signing (openssl rand -hex 32)
PUBLIC_URL no Override auto-detected public URL (recommended: set to stable alias)
ACCESS_TOKEN_TTL_SECONDS no Token lifetime for both access + refresh tokens (default 30). Uniform TTL means everything naturally expires together — even if serverless revocation is best-effort, no token survives past this window.
PORT no Local dev port (default 3097)
TUNNEL no Set 1 to spawn cloudflared tunnel locally
UPSTASH_REDIS_REST_URL / _TOKEN no Durable revocation store for serverless. Without these, revocation is in-memory (fine locally; best-effort across Vercel instances). Free tier: https://console.upstash.com/

Architecture

  • Stateless auth codes: grant details encoded inside the code as a 5-min JWT — no storage across serverless invocations
  • Short-lived access tokens: default 30 second TTL so you can watch the host's refresh flow kick in: first get_secret succeeds → wait >30s → second call → 401 → host uses refresh_token → new access token → retry succeeds. Override via ACCESS_TOKEN_TTL_SECONDS.
  • HS256: single shared secret; no key-pair persistence problem
  • Per-request MCP server: each /mcp request gets a fresh McpServer + StreamableHTTPServerTransport (stateless, no session IDs)
  • CSP: MCP App HTML imports the ext-apps client bundle from unpkg.com; declared in the resource content item's _meta.ui.csp.resourceDomains
  • Session revocation: all tokens in one OAuth session share a sid claim. revoke_auth_token adds the sid to a revocation list; both verifyAccessToken and the refresh grant check it → refresh fails with invalid_grant → host does full re-auth.
#!/usr/bin/env node
/**
* Minimal mock-auth MCP server demonstrating cross-app auth via callServerTool.
*
* Two MCP Apps:
* - show_auth_button (public): single "Auth me" button. Clicking it triggers
* app.callServerTool("get_secret"). The host sees a 401 on the protected
* tool, runs the OAuth flow, retries, and the result renders inline.
* - get_secret (protected): returns secret data, requires Bearer token.
*
* Serverless-friendly:
* - HS256 JWTs (single JWT_SECRET env var, no key-pair persistence)
* - Stateless auth codes (grant data encoded inside the code as a short JWT)
* - Single Express app serving /mcp + OAuth endpoints
* - Public URL auto-detected from PUBLIC_URL, VERCEL_URL, or request Host
* - Optional cloudflared tunnel for local dev (TUNNEL=1)
*
* Run locally: bun index.ts
* Run with tunnel: TUNNEL=1 bun index.ts
* Deploy: vercel deploy --prod
*/
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import { SignJWT, jwtVerify } from "jose";
import cors from "cors";
import express, { type Request, type Response } from "express";
import crypto from "node:crypto";
// CDN URL for the ext-apps client bundle. Hosts allow this via CSP resourceDomains.
const EXT_APPS_CDN = "https://unpkg.com/@modelcontextprotocol/ext-apps@1.2.1/dist/src/app-with-deps.js";
const APP_CSP = { resourceDomains: ["https://unpkg.com"] };
// ─── Config ──────────────────────────────────────────────────────────────────
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"]);
const IS_VERCEL = !!process.env.VERCEL;
const ACCESS_TOKEN_TTL_SECONDS = parseInt(process.env.ACCESS_TOKEN_TTL_SECONDS ?? "30", 10);
// 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";
/** Resolve the public base URL for this deployment. */
function resolvePublicUrl(req?: Request): URL {
const envUrl = process.env.PUBLIC_URL
?? (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined);
if (envUrl) return new URL(envUrl.endsWith("/") ? envUrl : envUrl + "/");
if (req) {
const proto = (req.headers["x-forwarded-proto"] as string) ?? req.protocol ?? "http";
const host = (req.headers["x-forwarded-host"] as string) ?? req.headers.host ?? `localhost:${PORT}`;
return new URL(`${proto}://${host}/`);
}
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;
}
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: in-memory Map by default (works locally / single-instance). On serverless,
// each request may hit a different instance → in-memory revocation is best-effort.
// Set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN for durable cross-instance
// revocation via Upstash's REST API (no client library needed).
const revokedSids = new Map<string, number>(); // sid → unix GC time (in-memory fallback)
// Revoked-sid entries need only outlive the longest token TTL; add small clock-skew grace.
const REVOCATION_GC_TTL_SECONDS = ACCESS_TOKEN_TTL_SECONDS + 10;
const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL;
const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN;
async function upstash(cmd: string[]): Promise<unknown> {
if (!UPSTASH_URL || !UPSTASH_TOKEN) return undefined;
const res = await fetch(`${UPSTASH_URL}/${cmd.map(encodeURIComponent).join("/")}`, {
headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` },
signal: AbortSignal.timeout(2000),
}).catch(() => undefined);
if (!res?.ok) return undefined;
return (await res.json())?.result;
}
async function revokeSession(sid: string): Promise<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);
await upstash(["SET", `revoked:${sid}`, "1", "EX", String(REVOCATION_GC_TTL_SECONDS)]);
}
async function isSessionRevoked(sid: string | undefined): Promise<boolean> {
if (!sid) return false;
if (revokedSids.has(sid)) return true;
return (await upstash(["GET", `revoked:${sid}`])) === "1";
}
async function signAccessToken(sub: string, scope: string, sid: string, issuer: string, audience: string): Promise<string> {
return new SignJWT({ sub, scope, sid })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer(issuer)
.setAudience(audience)
.setExpirationTime(`${ACCESS_TOKEN_TTL_SECONDS}s`)
.sign(JWT_SECRET);
}
async function signRefreshToken(sub: string, scope: string, sid: string, issuer: string): Promise<string> {
return new SignJWT({ sub, scope, sid, typ: "refresh" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer(issuer)
.setExpirationTime(`${ACCESS_TOKEN_TTL_SECONDS}s`)
.sign(JWT_SECRET);
}
async function verifyRefreshToken(token: string, issuer: string): Promise<{ sub: string; scope: string; sid: string } | undefined> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET, { issuer });
if (payload.typ !== "refresh") return undefined;
const sid = payload.sid as string | undefined;
if (await isSessionRevoked(sid)) return undefined; // session revoked → refresh fails
return { sub: (payload.sub as string) ?? "", scope: (payload.scope as string) ?? "", sid: sid ?? "" };
} catch {
return undefined;
}
}
/** Encode grant details inside the code itself (5min expiry) — no server storage. */
async function signAuthCode(payload: CodePayload, issuer: string): Promise<string> {
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<CodePayload | undefined> {
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): Promise<AuthInfo | undefined> {
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 (await isSessionRevoked(sid)) return undefined; // session revoked
return { token, sub: (payload.sub as string) ?? "", sid: sid ?? "" };
} catch {
return undefined;
}
}
function escapeHtml(s: string) { return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!)); }
async function handleAuthorize(req: Request, res: Response) {
const { client_id, redirect_uri, state, code_challenge, code_challenge_method, scope, approved } = req.query as Record<string, string>;
if (!redirect_uri) {
res.status(400).json({ error: "invalid_request", error_description: "Missing redirect_uri" });
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*/ `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Authorize</title>
<style>body{font-family:system-ui,sans-serif;max-width:420px;margin:40px auto;padding:0 16px;color-scheme:light dark}
.box{border:1px solid #ccc;border-radius:8px;padding:20px}dl{display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:14px;margin:16px 0}
dt{color:#888}dd{margin:0;word-break:break-all}
button{padding:10px 20px;margin:4px;font-size:15px;font-weight:bold;border:none;border-radius:6px;cursor:pointer}
.approve{background:#1a7a3e;color:#fff}.deny{background:#b91c1c;color:#fff}</style></head>
<body><div class="box">
<h2>🔑 Mock Authorization</h2>
<p>An application is requesting access:</p>
<dl><dt>Client</dt><dd>${escapeHtml(client_id ?? "(none)")}</dd>
<dt>Scope</dt><dd>${escapeHtml(scope ?? "(default)")}</dd>
<dt>Redirect</dt><dd>${escapeHtml(redirect_uri)}</dd></dl>
<a href="${escapeHtml(approveUrl.href)}"><button class="approve">Approve</button></a>
<a href="${escapeHtml(denyUrl.href)}"><button class="deny">Deny</button></a>
</div></body></html>`);
return;
}
const code = await signAuthCode({ client_id, redirect_uri, code_challenge, code_challenge_method, scope }, 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}`);
res.redirect(redirectUrl.href);
}
async function handleToken(req: Request, res: Response) {
const { grant_type, code, code_verifier, refresh_token } = 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;
}
const access_token = await signAccessToken(refreshClaims.sub, refreshClaims.scope, refreshClaims.sid, issuer, audience);
console.log(`[auth] refreshed access token (sid=${refreshClaims.sid})`);
res.json({ access_token, token_type: "Bearer", expires_in: ACCESS_TOKEN_TTL_SECONDS, 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
const access_token = await signAccessToken("mock-user-123", scope, sid, issuer, audience);
const refresh = await signRefreshToken("mock-user-123", scope, sid, issuer);
console.log(`[auth] exchanged code → token (client_id=${stored.client_id}, scope=${scope}, sid=${sid})`);
res.json({ access_token, token_type: "Bearer", expires_in: ACCESS_TOKEN_TTL_SECONDS, scope, refresh_token: refresh });
}
// ─── Inline MCP App HTML ─────────────────────────────────────────────────────
const baseCss = /*css*/ `
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; color-scheme: light dark; }
button {
background: #1a5276; color: #fff; border: none; border-radius: 6px;
padding: 10px 20px; font-size: 15px; font-weight: bold; cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #b91c1c; margin-top: 12px; }
.result { margin-top: 12px; padding: 12px; border: 1px solid #ccc; border-radius: 6px;
font-family: ui-monospace, monospace; white-space: pre-wrap; font-size: 13px; }
`;
const AUTH_BUTTON_HTML = /*html*/ `<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>${baseCss}</style></head>
<body>
<h3>Public App</h3>
<p>No auth needed to view this. Click below to invoke the protected <code>get_secret</code> tool —
the host will run the OAuth flow on 401 and retry.</p>
<button id="authBtn">Auth me</button>
<button id="revokeBtn">Revoke token</button>
<button id="fsBtn" title="Toggle fullscreen">⤢</button>
<div id="out"></div>
<script type="module">
import { App } from "${EXT_APPS_CDN}";
const app = new App({ name: "AuthButton", version: "1.0.0" }, {
availableDisplayModes: ["inline", "fullscreen"],
});
app.ontoolresult = () => {};
await app.connect();
const btn = document.getElementById("authBtn");
const out = document.getElementById("out");
async function callTool(name) {
out.innerHTML = "";
try {
const result = await app.callServerTool({ name, arguments: {} });
const text = result.content?.find(c => c.type === "text")?.text ?? "(no content)";
if (result.isError) {
out.innerHTML = '<div class="error">' + text + '</div>';
} else {
out.innerHTML = '<div class="result">' + text.replace(/</g, "&lt;") + '</div>';
}
} catch (e) {
out.innerHTML = '<div class="error">' + String(e?.message ?? e) + '</div>';
}
}
btn.onclick = async () => { btn.disabled = true; try { await callTool("get_secret"); } finally { btn.disabled = false; } };
const revokeBtn = document.getElementById("revokeBtn");
revokeBtn.onclick = async () => { revokeBtn.disabled = true; try { await callTool("revoke_auth_token"); } finally { revokeBtn.disabled = false; } };
const fsBtn = document.getElementById("fsBtn");
let fsMode = app.getHostContext()?.displayMode ?? "inline";
fsBtn.onclick = async () => {
fsBtn.disabled = true;
try {
const next = fsMode === "fullscreen" ? "inline" : "fullscreen";
const { mode } = await app.requestDisplayMode({ mode: next });
fsMode = mode;
} catch (e) {
console.warn("displayMode not supported:", e);
} finally {
fsBtn.disabled = false;
}
};
</script></body></html>`;
const SECRET_HTML = /*html*/ `<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>${baseCss}</style></head>
<body>
<h3>Protected App</h3>
<div id="out">Waiting for secret data...</div>
<script type="module">
import { App } from "${EXT_APPS_CDN}";
const app = new App({ name: "Secret", version: "1.0.0" }, {});
const out = document.getElementById("out");
app.ontoolresult = (params) => {
const text = params.content?.find(c => c.type === "text")?.text ?? "(no content)";
out.innerHTML = '<div class="result">' + text.replace(/</g, "&lt;") + '</div>';
};
await app.connect();
</script></body></html>`;
// ─── MCP Server ──────────────────────────────────────────────────────────────
function createMcpServer(authInfo?: AuthInfo): McpServer {
const server = new McpServer({ name: "Auth Button Demo", version: "1.0.0" });
const buttonUri = "ui://auth-button/index.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<CallToolResult> => ({ content: [{ type: "text", text: "ok" }] }));
registerAppResource(server, buttonUri, buttonUri,
{ mimeType: RESOURCE_MIME_TYPE, _meta: { ui: { csp: APP_CSP } } },
async (): Promise<ReadResourceResult> => ({
contents: [{ uri: buttonUri, mimeType: RESOURCE_MIME_TYPE, text: AUTH_BUTTON_HTML, _meta: { ui: { csp: APP_CSP } } }],
}));
const secretUri = "ui://secret/index.html";
registerAppTool(server, "get_secret", {
title: "Get Secret",
description: "Protected tool: returns secret data. Requires authentication.",
inputSchema: {},
_meta: { ui: { resourceUri: secretUri } },
}, async (): Promise<CallToolResult> => {
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, _meta: { ui: { csp: APP_CSP } } },
async (): Promise<ReadResourceResult> => ({
contents: [{ uri: secretUri, mimeType: RESOURCE_MIME_TYPE, text: SECRET_HTML, _meta: { ui: { csp: APP_CSP } } }],
}));
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<CallToolResult> => {
if (!authInfo) {
return { isError: true, content: [{ type: "text", text: "Authentication required." }] };
}
await revokeSession(authInfo.sid);
const durable = UPSTASH_URL ? " (durable via Upstash)" : " (in-memory; best-effort on serverless)";
console.log(`[auth] revoked session sid=${authInfo.sid} (total revoked sessions: ${revokedSids.size})`);
return { content: [{ type: "text", text: `Session revoked (sid: ${authInfo.sid})${durable}. Access + refresh token invalid → full re-auth required.` }] };
});
return server;
}
// ─── Express app ─────────────────────────────────────────────────────────────
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 so we can see what hosts probe during connect.
app.use((req, _res, next) => {
const ua = (req.headers["user-agent"] ?? "").slice(0, 80);
const auth = req.headers.authorization ? "[Bearer]" : "[no-auth]";
console.log(`[req] ${req.method} ${req.path} ${auth} ua=${ua}`);
next();
});
// OAuth endpoints
app.get("/authorize", handleAuthorize);
app.post("/token", handleToken);
// OAuth discovery metadata — PRM + AS 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 <host>/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) {
const base = resolvePublicUrl(req);
return {
resource: `${base.origin}/mcp`,
...(includeAuth ? {
authorization_servers: [resolveIssuer(req)],
scopes_supported: ["read:secret"],
bearer_methods_supported: ["header"],
} : {}),
};
}
app.get(PRM_PATH, (req, res) => res.json(buildPrm(req, true)));
// 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)));
}
// /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
app.all("/mcp", async (req: Request, res: Response) => {
const base = resolvePublicUrl(req);
const resourceMetadataUrl = `${base.origin}${PRM_PATH}`;
const body = req.body;
const messages = Array.isArray(body) ? body : body ? [body] : [];
const needsAuth = messages.some((msg: any) =>
msg?.method === "tools/call" && PROTECTED_TOOLS.has(msg.params?.name)
);
let authInfo: AuthInfo | undefined;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
authInfo = await verifyAccessToken(authHeader.slice(7), resolveIssuer(req));
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 = createMcpServer(authInfo);
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 });
}
}
});
// Simple landing page
app.get("/", (req, res) => {
const base = resolvePublicUrl(req);
res.type("text/plain").send(
`MCP Auth Button Demo\n\n` +
` MCP endpoint: ${base.origin}/mcp\n` +
` AS metadata: ${base.origin}/.well-known/oauth-authorization-server${ISSUER_SUFFIX}\n` +
` PRM metadata: ${base.origin}${PRM_PATH} (only via WWW-Authenticate on 401)\n\n` +
`Tools:\n` +
` - show_auth_button (public)\n` +
` - get_secret (protected, requires Bearer token)\n` +
` - revoke_auth_token (protected, revokes caller's current token)\n`
);
});
export default app;
// ─── Local dev / tunnel ──────────────────────────────────────────────────────
if (!IS_VERCEL) {
const server = app.listen(PORT, () => {
const base = resolvePublicUrl();
console.log(`[mcp] listening on :${PORT}`);
console.log(` Local URL: ${base.origin}/mcp`);
console.log(` Tools: show_auth_button, get_secret [PROTECTED], revoke_auth_token [PROTECTED]`);
});
server.on("error", (err) => { console.error(`[mcp] bind :${PORT} — ${err.message}`); process.exit(1); });
if (process.env.TUNNEL === "1") {
const { spawn } = await import("node:child_process");
const readline = await import("node:readline");
const proc = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${PORT}`], { stdio: ["ignore", "pipe", "pipe"] });
process.once("exit", () => proc.kill("SIGTERM"));
const rl = readline.createInterface({ input: proc.stderr! });
rl.on("line", (line) => {
const m = line.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (m) {
process.env.PUBLIC_URL = m[0];
console.log(`[tunnel] public URL: ${m[0]}/mcp`);
rl.close();
proc.stderr!.resume();
proc.stdout!.resume();
}
});
proc.once("error", (err) => console.error(`[tunnel] failed to spawn cloudflared: ${err.message}`));
}
const shutdown = () => { console.log("\nShutting down..."); server.close(() => process.exit(0)); };
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
{
"name": "mcp-auth-button-demo",
"version": "1.0.0",
"type": "module",
"description": "Minimal MCP App demo: public tool triggers cross-app auth to call a protected tool",
"bin": {
"mcp-auth-button-demo": "./dist/index.js"
},
"files": ["dist", "index.ts", "tsconfig.json", "vercel.json", "README.md"],
"scripts": {
"prepare": "tsc",
"dev": "bun index.ts",
"dev:tunnel": "TUNNEL=1 bun index.ts",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"deploy": "vercel deploy",
"deploy:prod": "vercel deploy --prod"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.27.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",
"typescript": "^5.9.3"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "dist",
"types": ["node"]
},
"include": ["index.ts"]
}
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"version": 2,
"builds": [
{ "src": "index.ts", "use": "@vercel/node", "config": { "maxDuration": 60 } }
],
"routes": [
{ "src": "/(.*)", "dest": "/index.ts" }
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment