|
#!/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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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, "<") + '</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, "<") + '</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); |
|
} |