Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions public/js/dodo-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:<uuid>:Authorization" entries
Expand All @@ -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=>{
Expand All @@ -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){
Expand Down Expand Up @@ -285,6 +310,83 @@ function renderIntegCard(name,description,catalogUrl,config){
return `<div class="integ-card"><div class="integ-name">${esc(name)}</div><div class="integ-desc">${esc(description)}</div>${statusHtml}<div class="integ-actions">${actionsHtml}</div></div>`;
}

// 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=`<span class="integ-status" style="color:${color}">${esc(stateLabel)}</span>`;
actionsHtml=`<button class="sm" onclick="refreshOAuthServer('${esc(server.id)}')" aria-label="Refresh ${esc(cat.name)} connection">Refresh</button><button class="sm danger" onclick="disconnectOAuthServer('${esc(server.id)}','${esc(cat.name)}')" aria-label="Disconnect ${esc(cat.name)}">Disconnect</button>`;
}else{
statusHtml=`<span class="integ-status" style="color:var(--text-subtle)">Not connected</span>`;
actionsHtml=`<button class="sm primary" onclick="connectOAuthCatalog('${esc(cat.id)}','${esc(cat.url)}')" aria-label="Connect ${esc(cat.name)} with OAuth">Connect with OAuth</button>`;
}
return `<div class="integ-card"><div class="integ-name">${esc(cat.name)}</div><div class="integ-desc">${esc(desc)}</div>${statusHtml}<div class="integ-actions">${actionsHtml}</div></div>`;
}

// 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();
Expand Down
44 changes: 44 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
name?: string;
server_url?: string;
state?: string;
auth_url?: string | null;
error?: string | null;
}>;
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<string, number>();
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) => {
Expand Down
13 changes: 13 additions & 0 deletions src/mcp-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
34 changes: 34 additions & 0 deletions test/dodo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
43 changes: 43 additions & 0 deletions test/mcp-catalog-unit.test.ts
Original file line number Diff line number Diff line change
@@ -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)$/);
}
});
});
Loading