/** * `/mcp/connect` — frictionless external-agent connection. The legacy * `/_agent-native/mcp/connect` alias is mounted by the core route plugin. * * A logged-in user on a deployed agent-native app (e.g. mail.agent-native.com) * mints a per-user, scoped, revocable MCP bearer token WITHOUT ever copying a * shared deployment secret. Two surfaces: * * 1. Browser — `GET /mcp/connect` renders a minimal in-app page (same inline * HTML approach as the auth pages). The Authorize button POSTs to * `/connect/token`, then shows the ready-to-paste `.mcp.json` entry, the * `agent-native connect ` one-liner, and the user's existing * tokens with Revoke buttons. * 2. CLI — an OAuth-2.0-device-authorization-style flow: * POST /mcp/connect/device/start (unauth) → device_code + user_code * GET /mcp/connect?user_code=… (browser) → user signs in & approves * POST /mcp/connect/device/authorize (session) → binds user to the code * POST /mcp/connect/device/poll (unauth) → mints + returns the token * * When A2A_SECRET exists, the minted token reuses the existing A2A signer * (`signA2AToken`) and adds a random `jti` + `scope: "mcp-connect"` claim so * it can be revoked. Deployments without A2A_SECRET mint the same standard MCP * OAuth access-token format used by remote MCP OAuth, signed with the auth * secret fallback and bound to the exact MCP resource URL. * * Node-only (crypto + the A2A signer), bundled alongside the other framework * routes. Dialect-agnostic SQL lives in `connect-store.ts`. */ import { randomUUID } from "node:crypto"; import type { H3Event } from "h3"; import { getMethod, getHeader } from "h3"; import { signA2AToken } from "../a2a/client.js"; import { getOrgDomain } from "../org/context.js"; import { getSession, getConfiguredLoginHtml, isLoopbackRequest, } from "../server/auth.js"; import { readBody } from "../server/h3-helpers.js"; import { MCP_CONNECT_GUIDES, MCP_CONNECT_MCP_URL_TEMPLATE, MCP_STATIC_TOKEN_FALLBACK, interpolateMcpConnectTemplate, } from "../shared/mcp-connect-content.js"; import { recordMintedToken, listTokens, revokeToken, normalizeServiceName, serviceIdentityEmail, createDeviceCode, getDeviceCode, approveDeviceCode, consumeDeviceCode, claimDeviceCodeForMint, finishDeviceCodeMint, releaseDeviceCodeMint, expireDeviceCode, MCP_CONNECT_OAUTH_CLIENT_ID, MCP_CONNECT_SCOPE, DEFAULT_TOKEN_TTL_DAYS, MIN_TOKEN_TTL_DAYS, MAX_TOKEN_TTL_DAYS, DEVICE_CODE_TTL_MS, } from "./connect-store.js"; import { MCP_OAUTH_DEFAULT_SCOPE, signMcpOAuthAccessToken, } from "./oauth-token.js"; import { MCP_PUBLIC_ROUTE_PREFIX } from "./route-paths.js"; /** Device-flow poll interval hint (seconds). */ const DEVICE_POLL_INTERVAL_S = 3; // Human-typable user code: 8 base32 chars, dashed XXXX-XXXX. const USER_CODE_RE = /^[A-Z2-7]{4}-[A-Z2-7]{4}$/; export interface McpConnectRouteOptions { /** App id (directory under apps/, e.g. `mail`). Used for the server name. */ appId?: string; /** Human app name shown on the connect page. */ appName?: string; /** Explicit MCP server id to return in copyable config/device-flow grants. */ serverName?: string; } function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } function html(body: string, status = 200): Response { return new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } /** Derive the running app's origin from request headers (same logic mountMCP * uses) — `https` in prod / for non-loopback hosts, `http` for localhost. */ function deriveOrigin(event: H3Event): string { const forwardedProto = getHeader(event, "x-forwarded-proto"); const host = getHeader(event, "x-forwarded-host") || getHeader(event, "host"); const proto = forwardedProto?.split(",")[0]?.trim() || (host && /^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https"); return host ? `${proto}://${host}` : ""; } function isLoopbackOrigin(origin: string): boolean { try { const hostname = new URL(origin).hostname; return ( hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname.startsWith("127.") ); } catch { return false; } } function normalizeBasePath(raw: string | undefined): string { const trimmed = (raw ?? "").trim(); if (!trimmed || trimmed === "/") return ""; const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; return withSlash.replace(/\/+$/, ""); } function configuredBasePath(): string { return normalizeBasePath( process.env.APP_BASE_PATH || process.env.VITE_APP_BASE_PATH, ); } function joinAppPath(basePath: string, path: string): string { if (!basePath) return path; if (path === "/") return basePath; return `${basePath}${path.startsWith("/") ? path : `/${path}`}`; } function appLabel(origin: string, options: McpConnectRouteOptions): string { if (options.appId) return options.appId; try { const h = new URL(origin).hostname; return h.split(".")[0] || h; } catch { return options.appName || "app"; } } function serverName(origin: string, options: McpConnectRouteOptions): string { const explicit = options.serverName?.trim(); if (explicit) return explicit; return `agent-native-${appLabel(origin, options)}`; } function canUseDevOpenConnect(event: H3Event): boolean { // Loopback determined from the real socket peer (isLoopbackRequest → // getRequestIP without xForwardedFor), NOT a parsed `Host` header — the // header is client-controlled, and it also handles IPv6 `::1`. A // misconfigured public deploy with no secret thus can't unlock dev-open // by spoofing `Host: localhost`. return ( isLoopbackRequest(event) && isLoopbackOrigin(deriveOrigin(event)) && !process.env.A2A_SECRET?.trim() && !process.env.ACCESS_TOKEN?.trim() && !process.env.ACCESS_TOKENS?.trim() ); } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** * Resolve the org domain for a session. Used as the JWT `org_domain` claim so * the receiving MCP endpoint can map it back to an org id (same as A2A). Best * effort — a missing org just yields a user-scoped (no-org) token. */ async function resolveOrgDomain( orgId: string | undefined, ): Promise { if (!orgId) return undefined; try { return (await getOrgDomain(orgId)) ?? undefined; } catch { return undefined; } } function clampTtlDays(input: unknown): number { const n = Number(input); if (!Number.isFinite(n)) return DEFAULT_TOKEN_TTL_DAYS; return Math.min( MAX_TOKEN_TTL_DAYS, Math.max(MIN_TOKEN_TTL_DAYS, Math.floor(n)), ); } /** * Mint a connect-scoped JWT and record it. The token value is returned to the * caller exactly once and never persisted; only the random `jti` is stored for * revocation. */ async function mintConnectToken(params: { email: string; orgId: string | undefined; label: string | null; ttlDays: number; appUrl: string; /** When `"full"`, embed `catalog_scope: "full"` in the JWT so this token * bypasses the compact/connector-catalog tier (active by default whenever a * `connectorCatalog` is declared) and gets the complete action surface. */ catalogScope?: "full"; }): Promise<{ token: string; jti: string }> { const orgDomain = await resolveOrgDomain(params.orgId); const jti = randomUUID(); const token = await signConnectToken({ ownerEmail: params.email, orgId: params.orgId, orgDomain, appUrl: params.appUrl, expiresIn: `${params.ttlDays}d`, jti, ...(params.catalogScope === "full" ? { catalogScope: "full" } : {}), }); await recordMintedToken({ jti, ownerEmail: params.email, orgId: params.orgId ?? null, label: params.label, }); return { token, jti }; } async function signConnectToken(params: { ownerEmail: string; orgId: string | null | undefined; orgDomain: string | undefined; appUrl: string; expiresIn: string; jti: string; /** * When true, embed the org id directly as an `org_id` claim on the * A2A-signed path (the OAuth-signed path already carries `params.orgId`). * Used for org SERVICE tokens, whose synthetic identity must resolve to the * org even when the org has no domain mapping. Personal tokens keep the * original domain-based resolution — behavior unchanged. */ includeOrgIdClaim?: boolean; /** * When `"full"`, embed a `catalog_scope: "full"` claim so this token * bypasses the compact/connector-catalog tier filter (active by default * whenever a `connectorCatalog` is declared) and gets the complete action * surface. Minted when the user connects with `agent-native connect --full-catalog`. */ catalogScope?: "full"; }): Promise { if (process.env.A2A_SECRET?.trim()) { return signA2AToken(params.ownerEmail, params.orgDomain, undefined, { preferGlobalSecret: true, expiresIn: params.expiresIn, extraClaims: { jti: params.jti, scope: MCP_CONNECT_SCOPE, ...(params.includeOrgIdClaim && params.orgId ? { org_id: params.orgId } : {}), ...(params.catalogScope === "full" ? { catalog_scope: "full" } : {}), }, }); } return signMcpOAuthAccessToken({ ownerEmail: params.ownerEmail, orgId: params.orgId ?? null, orgDomain: params.orgDomain ?? null, clientId: MCP_CONNECT_OAUTH_CLIENT_ID, scope: MCP_OAUTH_DEFAULT_SCOPE, resource: mcpResourceUrl(params.appUrl), issuer: params.appUrl, jti: params.jti, expiresIn: params.expiresIn, ...(params.catalogScope === "full" ? { catalogScope: "full" } : {}), }); } /** * Mint an ORG SERVICE token: a connect-scoped, revocable bearer whose subject * is the synthetic service identity `svc-@service.` instead of a * person. Built for CI (e.g. the `PLAN_RECAP_TOKEN` GitHub secret) so the * credential survives any individual leaving or revoking their personal * tokens, and so rows created by CI are org-scoped (visible to org members) * rather than owned by one person. * * The token value is returned exactly once and never persisted — only the * random `jti` is stored, so the standard revocation path * (`isJtiRevoked` in `verifyAuth`) applies to service tokens identically. * * Authorization is the CALLER'S responsibility: this function does not check * org membership/role. The `create-org-service-token` action gates on org * owner/admin before calling it. */ export async function mintOrgServiceToken(params: { /** Human-readable service principal name, e.g. "ci" or "pr-recap". */ serviceName: string; /** Org the service token acts for; becomes the resolved session orgId. */ orgId: string; /** The human minting the token — stored for audit, never used as identity. */ createdBy: string; /** 1–365 days; clamped. Defaults to DEFAULT_TOKEN_TTL_DAYS. */ ttlDays?: number; /** App origin used for OAuth-signed tokens (resource/issuer binding). */ appUrl: string; }): Promise<{ token: string; jti: string; id: string; serviceName: string; serviceEmail: string; ttlDays: number; }> { const serviceName = normalizeServiceName(params.serviceName); const serviceEmail = serviceIdentityEmail(serviceName, params.orgId); const orgDomain = await resolveOrgDomain(params.orgId); const ttlDays = clampTtlDays(params.ttlDays ?? DEFAULT_TOKEN_TTL_DAYS); const jti = randomUUID(); const token = await signConnectToken({ ownerEmail: serviceEmail, orgId: params.orgId, orgDomain, appUrl: params.appUrl, expiresIn: `${ttlDays}d`, jti, includeOrgIdClaim: true, }); const id = await recordMintedToken({ jti, ownerEmail: serviceEmail, orgId: params.orgId, label: `Service token: ${serviceName}`, kind: "service", serviceName, createdBy: params.createdBy, }); return { token, jti, id, serviceName, serviceEmail, ttlDays }; } function mcpResultPayload( appUrl: string, options: McpConnectRouteOptions, auth: { token?: string; ownerEmail?: string }, ) { const mcpUrl = mcpResourceUrl(appUrl); const name = serverName(appUrl, options); const headers: Record = {}; if (auth.token) headers.Authorization = `Bearer ${auth.token}`; if (!auth.token && auth.ownerEmail) { headers["X-Agent-Native-Owner-Email"] = auth.ownerEmail; } // Intentionally do NOT inject the full-catalog header here. Every connector // used to receive it, which silently forced the ~105-tool full catalog on // every client. Full-catalog intent now lives durably in the token itself // (`catalog_scope: "full"`, minted only by `connect --full-catalog`), so a // normal connection defaults to the compact/connector catalog + tool-search. return { token: auth.token ?? "", mcpUrl, serverName: name, mcpServerEntry: { type: "http" as const, url: mcpUrl, ...(Object.keys(headers).length ? { headers } : {}), }, cli: `npx @agent-native/core@latest connect ${appUrl}`, }; } function mcpResourceUrl(appUrl: string): string { return `${appUrl}${MCP_PUBLIC_ROUTE_PREFIX}`; } // --------------------------------------------------------------------------- // Connect page (server-rendered HTML string) // --------------------------------------------------------------------------- function agentNativeMarkSvg(className: string, gradientId: string): string { return ``; } function renderConnectGuide( guide: (typeof MCP_CONNECT_GUIDES)[number], values: Parameters[1], ): string { const guideId = escapeHtml(guide.id); const content = [ guide.steps?.length ? `
    ${guide.steps .map( (step) => `
  1. ${escapeHtml(interpolateMcpConnectTemplate(step, values))}
  2. `, ) .join("")}
` : "", guide.intro ? `

${escapeHtml(interpolateMcpConnectTemplate(guide.intro, values))}

` : "", guide.commandTemplate ? `
${escapeHtml(interpolateMcpConnectTemplate(guide.commandTemplate, values))}
` : "", guide.configTemplate ? `
${escapeHtml(interpolateMcpConnectTemplate(guide.configTemplate, values))}
` : "", guide.action?.kind === "link" && guide.action.href ? `${escapeHtml(guide.action.label)}` : "", guide.note ? `

${escapeHtml(interpolateMcpConnectTemplate(guide.note, values))}

` : "", ].join("\n"); return `
${content}
`; } function renderConnectPage(params: { connectBasePath: string; email: string; appName: string; appUrl: string; serverId: string; userCode: string | null; }): string { const { connectBasePath, email, appName, appUrl, serverId, userCode } = params; const safeEmail = escapeHtml(email); const safeApp = escapeHtml(appName); const mcpUrl = interpolateMcpConnectTemplate(MCP_CONNECT_MCP_URL_TEMPLATE, { appName, appUrl, mcpUrl: "", serverId, }); const safeMcpUrl = escapeHtml(mcpUrl); const connectTemplateValues = { appName, appUrl, mcpUrl, serverId }; const flowMarkSvg = agentNativeMarkSvg( "flow-mark", "agent-native-connect-flow-gradient", ); const safeUserCode = userCode && USER_CODE_RE.test(userCode) ? escapeHtml(userCode) : ""; const guideTabsHtml = MCP_CONNECT_GUIDES.map( (guide) => ``, ).join("\n"); const guidePanelsHtml = MCP_CONNECT_GUIDES.map((guide) => renderConnectGuide(guide, connectTemplateValues), ).join("\n"); const setupHtml = safeUserCode ? "" : `
${safeMcpUrl}
Assistant setup MCP URL guides
${guideTabsHtml}
${guidePanelsHtml}
`; const tokenAdvancedOptionsHtml = safeUserCode ? "" : `
Advanced options
`; return ` Connect ${safeApp}

${safeUserCode ? `Authorize ${safeApp} from your terminal?` : `Use ${safeApp} from your AI assistant`}

Signed in as ${safeEmail}

Device code ${safeUserCode}
${setupHtml}
${safeUserCode ? "Authorize this device" : MCP_STATIC_TOKEN_FALLBACK.title}
${tokenAdvancedOptionsHtml}
Existing connections
Checking connections...
`; } // --------------------------------------------------------------------------- // Handler — single entry point; core-routes-plugin dispatches the subpath. // --------------------------------------------------------------------------- /** * Handle a `/mcp/connect[...]` request. The legacy * `/_agent-native/mcp/connect` alias is mounted too. `subpath` is the part * after `/connect` (empty string = the page itself, otherwise e.g. `/token`, * `/device/start`). The core-routes-plugin computes it from the stripped event * path so this module stays mount-agnostic. */ export async function handleMcpConnect( event: H3Event, subpath: string, options: McpConnectRouteOptions = {}, ): Promise { const method = getMethod(event); const origin = deriveOrigin(event); const basePath = configuredBasePath(); const appUrl = `${origin}${basePath}`; const sub = ("/" + subpath.replace(/^\/+/, "").replace(/\/+$/, "")).replace( /^\/$/, "", ); // ---- The connect page (GET) ------------------------------------------ if (sub === "") { if (method !== "GET" && method !== "HEAD") { return json({ error: "Method not allowed" }, 405); } const session = await getSession(event); if (!session?.email) { // Serve the SAME login form the guard would, at this same URL — the // login form reloads window.location so we re-enter here authed. const loginHtml = getConfiguredLoginHtml(event); if (loginHtml) return html(loginHtml, 200); // Fully-open app (no auth guard): nothing to scope a mint to. return html( renderConnectPage({ connectBasePath: basePath, email: "(no auth configured)", appName: options.appName || appLabel(appUrl, options), appUrl, serverId: serverName(appUrl, options), userCode: null, }), ); } let userCode: string | null = null; try { const u = new URL( event.node?.req?.url ?? event.path ?? "/", "http://an.invalid", ); const raw = u.searchParams.get("user_code"); if (raw && USER_CODE_RE.test(raw)) userCode = raw; } catch { userCode = null; } return html( renderConnectPage({ connectBasePath: basePath, email: session.email, appName: options.appName || appLabel(appUrl, options), appUrl, serverId: serverName(appUrl, options), userCode, }), ); } // ---- POST /token (session-required) --------------------------------- if (sub === "/token") { if (method !== "POST") return json({ error: "Method not allowed" }, 405); const session = await getSession(event); if (!session?.email) return json({ error: "Unauthorized" }, 401); if (!process.env.A2A_SECRET?.trim() && canUseDevOpenConnect(event)) { return json( mcpResultPayload(appUrl, options, { ownerEmail: session.email }), ); } const body = ((await readBody(event).catch(() => ({}))) ?? {}) as { label?: unknown; ttlDays?: unknown; fullCatalog?: unknown; }; const label = typeof body.label === "string" && body.label.trim() ? body.label.trim().slice(0, 120) : null; const ttlDays = clampTtlDays(body.ttlDays); const catalogScope: "full" | undefined = body.fullCatalog === true || body.fullCatalog === "true" ? "full" : undefined; try { const { token } = await mintConnectToken({ email: session.email, orgId: session.orgId, label, ttlDays, appUrl, ...(catalogScope ? { catalogScope } : {}), }); return json(mcpResultPayload(appUrl, options, { token })); } catch { return json({ error: "Failed to mint token." }, 500); } } // ---- POST /device/start (UNAUTH) ------------------------------------ if (sub === "/device/start") { if (method !== "POST") return json({ error: "Method not allowed" }, 405); try { const row = await createDeviceCode(); const verificationUri = `${appUrl}${MCP_PUBLIC_ROUTE_PREFIX}/connect`; return json({ device_code: row.deviceCode, user_code: row.userCode, verification_uri: verificationUri, verification_uri_complete: `${verificationUri}?user_code=${row.userCode}`, interval: DEVICE_POLL_INTERVAL_S, expires_in: Math.floor(DEVICE_CODE_TTL_MS / 1000), }); } catch (err: any) { if (err?.message === "RATE_LIMITED") { return json({ error: "Rate limited. Try again shortly." }, 429); } return json({ error: "Could not start device flow." }, 500); } } // ---- POST /device/authorize (session-required) ---------------------- if (sub === "/device/authorize") { if (method !== "POST") return json({ error: "Method not allowed" }, 405); const session = await getSession(event); if (!session?.email) return json({ error: "Unauthorized" }, 401); const body = ((await readBody(event).catch(() => ({}))) ?? {}) as { user_code?: unknown; }; const userCode = typeof body.user_code === "string" ? body.user_code.trim() : ""; if (!USER_CODE_RE.test(userCode)) { return json({ error: "Invalid user code." }, 400); } const orgId = typeof session.orgId === "string" && session.orgId.trim() ? session.orgId.trim() : null; const result = await approveDeviceCode(userCode, session.email, orgId); if (result === "not_found") { return json({ error: "Unknown device code." }, 404); } if (result === "expired") { return json({ error: "This device code has expired." }, 410); } if (result === "already") { return json({ error: "This device code was already used." }, 409); } return json({ status: "approved" }); } // ---- POST /device/poll (UNAUTH) ------------------------------------- if (sub === "/device/poll") { if (method !== "POST") return json({ error: "Method not allowed" }, 405); const body = ((await readBody(event).catch(() => ({}))) ?? {}) as { device_code?: unknown; }; const deviceCode = typeof body.device_code === "string" ? body.device_code : ""; if (!deviceCode) return json({ error: "device_code required" }, 400); const row = await getDeviceCode(deviceCode); if (!row) return json({ status: "not_found" }, 404); if (row.status === "consumed") return json({ status: "consumed" }); if ( row.status === "expired" || (row.expiresAt != null && row.expiresAt < Date.now()) ) { if (row.status !== "expired") void expireDeviceCode(deviceCode); return json({ status: "expired" }); } if ( row.status === "pending" || row.status === "minting" || !row.ownerEmail ) { return json({ status: "pending" }); } // status === "approved" && ownerEmail bound → mint exactly once. if (!process.env.A2A_SECRET?.trim() && canUseDevOpenConnect(event)) { const consumed = await consumeDeviceCode( deviceCode, `dev-open-${randomUUID()}`, ); if (!consumed) { const fresh = await getDeviceCode(deviceCode); if (fresh?.status === "consumed") return json({ status: "consumed" }); return json({ status: "pending" }); } return json({ status: "approved", ...mcpResultPayload(appUrl, options, { ownerEmail: row.ownerEmail, }), }); } try { const jti = randomUUID(); // Claim a retryable minting state first. If signing or recording fails, // release the row back to approved so the CLI can poll again. const claimed = await claimDeviceCodeForMint(deviceCode, jti); if (!claimed) { const fresh = await getDeviceCode(deviceCode); if (fresh?.status === "consumed") return json({ status: "consumed" }); return json({ status: "pending" }); } let token: string; try { const orgDomain = await resolveOrgDomain(claimed.orgId ?? undefined); token = await signConnectToken({ ownerEmail: claimed.ownerEmail!, orgId: claimed.orgId, orgDomain, appUrl, expiresIn: `${DEFAULT_TOKEN_TTL_DAYS}d`, jti, }); await recordMintedToken({ jti, ownerEmail: claimed.ownerEmail!, orgId: claimed.orgId, label: "Device connection", }); if (!(await finishDeviceCodeMint(deviceCode, jti))) { return json({ status: "pending" }); } } catch (err) { await releaseDeviceCodeMint(deviceCode, jti); throw err; } return json({ status: "approved", ...mcpResultPayload(appUrl, options, { token }), }); } catch { return json({ status: "error", error: "Failed to mint token." }, 500); } } // ---- GET /tokens (session-required) --------------------------------- if (sub === "/tokens") { if (method !== "GET") return json({ error: "Method not allowed" }, 405); const session = await getSession(event); if (!session?.email) return json({ error: "Unauthorized" }, 401); const rows = await listTokens(session.email); return json({ tokens: rows.map((r) => ({ id: r.id, label: r.label, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt, revokedAt: r.revokedAt, })), }); } // ---- POST /tokens/revoke (session-required) ------------------------- if (sub === "/tokens/revoke") { if (method !== "POST") return json({ error: "Method not allowed" }, 405); const session = await getSession(event); if (!session?.email) return json({ error: "Unauthorized" }, 401); const body = ((await readBody(event).catch(() => ({}))) ?? {}) as { id?: unknown; }; const id = typeof body.id === "string" ? body.id : ""; if (!id) return json({ error: "id required" }, 400); const revoked = await revokeToken(session.email, id); return json({ ok: revoked }); } return json({ error: "Not found" }, 404); }