diff --git a/public/js/dodo-settings.js b/public/js/dodo-settings.js index 8dd6dd6..87fd43d 100644 --- a/public/js/dodo-settings.js +++ b/public/js/dodo-settings.js @@ -225,13 +225,22 @@ async function deleteBrowserConfig(){ } // --- Integrations --- -let mcpCatalog=[],mcpConfigs=[],secretKeys=[]; +let mcpCatalog=[],mcpConfigs=[],oauthServers=[],secretKeys=[]; async function loadIntegrations(){ try{ - const[catalog,configsRes]=await Promise.all([api("/api/mcp-catalog"),api("/api/mcp-configs")]); + // OAuth-MCP servers live in the user's CodingAgent hub DO (keyed by email), + // not in mcp_configs — they're managed by the Agents SDK. Load them in + // parallel so the UI can show OAuth catalog entries as "Connected" when + // the user has already completed the OAuth dance. + const[catalog,configsRes,oauthRes]=await Promise.all([ + api("/api/mcp-catalog"), + api("/api/mcp-configs"), + api("/api/mcp/oauth-servers").catch(()=>({servers:[]})), + ]); mcpCatalog=Array.isArray(catalog)?catalog:[]; mcpConfigs=configsRes.configs||[]; + oauthServers=oauthRes.servers||[]; renderIntegrations(); // Re-render the secrets list now that we know which MCP configs exist — // boot-time render may have shown raw "mcp::Authorization" entries @@ -243,6 +252,9 @@ async function loadIntegrations(){ function renderIntegrations(){ const configMap=new Map(mcpConfigs.map(c=>[c.name.toLowerCase(),c])); const getHostname=url=>{try{return new URL(url).hostname;}catch{return null;}}; + // Match catalog entries to OAuth-connected servers by hostname. + const oauthByHostname=new Map(); + oauthServers.forEach(s=>{const h=getHostname(s.url);if(h)oauthByHostname.set(h,s);}); const connected=[],suggestions=[]; const hasGithubToken=secretKeys.includes("github_token"); mcpCatalog.forEach(cat=>{ @@ -251,6 +263,19 @@ function renderIntegrations(){ const catHostname=cat.url?getHostname(cat.url):null; const catHosts=new Set(cat.knownHosts||[]); if(catHostname)catHosts.add(catHostname); + // OAuth catalog entries: check if the user has a connected OAuth server + // for any of the known hostnames (the OAuth dance may resolve to a + // different effective URL than the catalog hint). + if(cat.auth_type==="oauth"){ + let oauthServer=null; + for(const h of catHosts){if(oauthByHostname.has(h)){oauthServer=oauthByHostname.get(h);break;}} + if(oauthServer){ + connected.push(renderOAuthCard(cat,oauthServer)); + }else{ + suggestions.push(renderOAuthCard(cat,null)); + } + return; + } const configured=configMap.get(cat.name.toLowerCase()) ||[...configMap.values()].find(c=>c.url&&catHosts.has(getHostname(c.url))); if(configured){ @@ -285,6 +310,83 @@ function renderIntegCard(name,description,catalogUrl,config){ return `
${esc(name)}
${esc(description)}
${statusHtml}
${actionsHtml}
`; } +// OAuth-MCP catalog entries render with different actions: "Connect with OAuth" +// when not yet connected (opens the provider in a popup), and "Disconnect" +// when already connected. The actual OAuth state is managed by the Agents SDK +// in the user's hub DO, not in mcp_configs. +function renderOAuthCard(cat,server){ + const desc=cat.description||""; + let statusHtml,actionsHtml; + if(server){ + const stateLabel=server.state==="ready"?`Connected — ${server.toolCount} tool${server.toolCount!==1?'s':''}` + :server.state==="authenticating"?"Authenticating…" + :server.state==="connecting"?"Connecting…" + :server.state==="discovering"?"Discovering…" + :server.state==="failed"?(server.error?`Failed: ${server.error}`:"Failed") + :server.state; + const color=server.state==="ready"?"var(--text-success)":server.state==="failed"?"var(--text-error,#b91c1c)":"var(--text-subtle)"; + statusHtml=`${esc(stateLabel)}`; + actionsHtml=``; + }else{ + statusHtml=`Not connected`; + actionsHtml=``; + } + return `
${esc(cat.name)}
${esc(desc)}
${statusHtml}
${actionsHtml}
`; +} + +// Kick off the OAuth dance for a catalog entry. The server returns either +// {authUrl} (popup needed) or {message:"Connected"} (already authenticated). +// The popup polls /api/mcp/oauth-servers after closing to detect the new +// connection. +async function connectOAuthCatalog(catalogId,mcpUrl){ + try{ + const result=await json("/api/mcp/start-auth",{mcpUrl}); + if(result.authUrl){ + const popup=window.open(result.authUrl,"dodo-oauth","width=560,height=720,menubar=no,toolbar=no"); + if(!popup){ + toast("Popup blocked. Allow popups for this site and try again.","error"); + return; + } + // Poll for popup close + server-side connection — the Agents SDK + // handles the callback at /agents/oauth/callback and updates the + // hub DO's `getMcpServers()` state. We can't postMessage from the + // OAuth provider's domain, so we poll the popup's closed state and + // then refresh the integrations list. + const startedAt=Date.now(); + const poll=setInterval(async()=>{ + try{ + if(popup.closed){clearInterval(poll);await loadIntegrations();return;} + if(Date.now()-startedAt>5*60*1000){ + clearInterval(poll);try{popup.close();}catch{} + toast("OAuth flow timed out","warning"); + await loadIntegrations(); + } + }catch{/* cross-origin while on provider — ignore */} + },800); + }else{ + toast(`Connected to ${catalogId}`,"success"); + await loadIntegrations(); + } + }catch(e){toast("Failed to start OAuth: "+(e.error||e.message||e),"error")} +} + +async function disconnectOAuthServer(mcpId,displayName){ + const ok=await appConfirm(`Disconnect ${displayName}?`); + if(!ok)return; + try{ + await json("/api/mcp/delete-auth",{mcpId}); + toast(`Disconnected ${displayName}`,"success"); + await loadIntegrations(); + }catch(e){toast("Disconnect failed: "+(e.error||e.message||e),"error")} +} + +async function refreshOAuthServer(mcpId){ + try{ + await json("/api/mcp/refresh-state",{mcpId}); + await loadIntegrations(); + }catch(e){toast("Refresh failed: "+(e.error||e.message||e),"error")} +} + async function addIntegration(){ const name=$("integ-name").value.trim(); const url=$("integ-url").value.trim(); diff --git a/src/index.ts b/src/index.ts index 99de9e3..abbab05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1695,6 +1695,50 @@ app.post("/api/mcp/refresh-state", async (c) => { } }); +// List the user's connected OAuth MCP servers (Agents-SDK-managed). The +// hub DO is keyed by the user's email; per-session DOs federate tools via +// `loadOAuthToolsFromHub`, so a single list per user is the authoritative +// source of truth across all of their sessions. +app.get("/api/mcp/oauth-servers", async (c) => { + const userEmail = c.get("userEmail"); + const id = c.env.CODING_AGENT.idFromName(userEmail); + const stub = c.env.CODING_AGENT.get(id) as unknown as { + getMcpServers: () => Promise<{ + servers: Record; + tools: Array<{ serverId: string }>; + }>; + }; + try { + const { servers, tools } = await stub.getMcpServers(); + // Count tools per server so the UI can show "12 tools" next to each + // connected MCP server. + const toolCountByServer = new Map(); + for (const t of tools) { + toolCountByServer.set(t.serverId, (toolCountByServer.get(t.serverId) ?? 0) + 1); + } + const list = Object.entries(servers).map(([sid, s]) => ({ + id: sid, + name: s.name ?? "", + url: s.server_url ?? "", + state: s.state ?? "unknown", + authUrl: s.auth_url ?? null, + error: s.error ?? null, + toolCount: toolCountByServer.get(sid) ?? 0, + })); + return c.json({ servers: list }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log("warn", "/api/mcp/oauth-servers failed", { userEmail, error: msg }); + return c.json({ error: `Failed to list MCP servers: ${msg}` }, 500); + } +}); + // ─── MCP Catalog ─── app.get("/api/mcp-catalog", async (c) => { diff --git a/src/mcp-catalog.ts b/src/mcp-catalog.ts index 988facc..4333840 100644 --- a/src/mcp-catalog.ts +++ b/src/mcp-catalog.ts @@ -50,6 +50,19 @@ const DEFAULT_CLOUDFLARE_REMOTE_MCP_CATALOG: McpCatalogEntry[] = [ auth_type: "oauth", knownHosts: ["browser.mcp.cloudflare.com"], }, + { + id: "cf-portal", + name: "Cloudflare Portal", + description: + "Cloudflare internal MCP portal — Backstage catalog, Jira, GitLab, Sentry, Elasticsearch, Wiki, Prometheus, and more. Requires Cloudflare SSO.", + url: "https://portal.mcp.cfdata.org/mcp", + setupGuide: + "Connect with OAuth via Cloudflare Access SSO. Per-user Dynamic Client Registration; access token refreshes automatically.", + auth_type: "oauth", + // cf-mcp.cloudflareaccess.com is the OAuth dance host + // (authorization_endpoint / token_endpoint / registration_endpoint). + knownHosts: ["portal.mcp.cfdata.org", "cf-mcp.cloudflareaccess.com"], + }, ]; export const DEPLOY_MCP_CATALOG_CONFIG: McpCatalogConfig = { diff --git a/test/dodo.test.ts b/test/dodo.test.ts index ecaf4a2..f8cdf8a 100644 --- a/test/dodo.test.ts +++ b/test/dodo.test.ts @@ -166,6 +166,40 @@ describe("Dodo foundation", () => { expect(response.status).not.toBe(200); }); + it("GET /api/mcp/oauth-servers returns the user's connected OAuth MCP servers", async () => { + // In dev mode the test user has no connected OAuth MCPs, but the + // endpoint should still return a well-formed { servers: [] } payload + // (not 500/404/etc.). This guards against the most likely regression: + // the endpoint hitting an exception in `getMcpServers()` proxying. + const res = await fetchJson("/api/mcp/oauth-servers", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { servers: Array<{ id: string; name: string; url: string; state: string; toolCount: number }> }; + expect(Array.isArray(body.servers)).toBe(true); + // Each server (if any) carries the expected shape. + for (const s of body.servers) { + expect(typeof s.id).toBe("string"); + expect(typeof s.name).toBe("string"); + expect(typeof s.url).toBe("string"); + expect(typeof s.state).toBe("string"); + expect(typeof s.toolCount).toBe("number"); + } + }); + + it("GET /api/mcp/oauth-servers requires authentication", async () => { + // With ALLOW_UNAUTHENTICATED_DEV unset and CF Access env vars unset, + // verifyAccess() throws a 500 — the protected boundary refuses to + // serve the endpoint without an identity source. Same shape as the + // other "not publicly accessible" tests above. + const ctx = createExecutionContext(); + const response = await worker.fetch( + new Request(`${BASE_URL}/api/mcp/oauth-servers`, { method: "GET" }), + { ...(env as Env), ALLOW_UNAUTHENTICATED_DEV: "" } as Env, + ctx, + ); + await waitOnExecutionContext(ctx); + expect(response.status).not.toBe(200); + }); + it("POST /api/mcp/start-auth rejects invalid URLs and disallowed hosts", async () => { // Missing mcpUrl const r1 = await fetchJson("/api/mcp/start-auth", { diff --git a/test/mcp-catalog-unit.test.ts b/test/mcp-catalog-unit.test.ts new file mode 100644 index 0000000..71585f3 --- /dev/null +++ b/test/mcp-catalog-unit.test.ts @@ -0,0 +1,43 @@ +/** + * Unit tests for the MCP catalog presets. Confirms that catalog entries + * intended for OAuth flow (cf-portal, browser-rendering, github) carry the + * right `auth_type` and `knownHosts` — the knownHosts list is what allows + * the OAuth dance through `isHostAllowed()` without an admin allowlist add. + */ +import { describe, expect, it } from "vitest"; +import { MCP_CATALOG } from "../src/mcp-catalog"; + +describe("MCP_CATALOG", () => { + it("includes cf-portal as an OAuth catalog entry", () => { + const entry = MCP_CATALOG.find((e) => e.id === "cf-portal"); + expect(entry).toBeDefined(); + expect(entry?.url).toBe("https://portal.mcp.cfdata.org/mcp"); + expect(entry?.auth_type).toBe("oauth"); + // Both the MCP host AND the Cloudflare Access OAuth dance host must be + // in knownHosts — otherwise `isHostAllowed()` rejects the start-auth + // call and the token endpoint round-trip during the dance. + expect(entry?.knownHosts).toContain("portal.mcp.cfdata.org"); + expect(entry?.knownHosts).toContain("cf-mcp.cloudflareaccess.com"); + }); + + it("includes browser-rendering as an OAuth catalog entry", () => { + const entry = MCP_CATALOG.find((e) => e.id === "browser-rendering"); + expect(entry).toBeDefined(); + expect(entry?.auth_type).toBe("oauth"); + expect(entry?.knownHosts).toContain("browser.mcp.cloudflare.com"); + }); + + it("includes github as an OAuth catalog entry", () => { + const entry = MCP_CATALOG.find((e) => e.id === "github"); + expect(entry).toBeDefined(); + expect(entry?.auth_type).toBe("oauth"); + expect(entry?.knownHosts).toContain("api.githubcopilot.com"); + }); + + it("every catalog entry has either auth_type or is the self-MCP", () => { + for (const e of MCP_CATALOG) { + if (e.id === "dodo-self") continue; + expect(e.auth_type).toMatch(/^(oauth|static_headers)$/); + } + }); +});