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
48 changes: 43 additions & 5 deletions public/js/dodo-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,12 @@ function renderIntegrations(){
// 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);});
// Track which oauthServers we've rendered as part of a catalog entry so
// the leftover loop below can pick up the orphans. Without this, a
// hub-DO record for an MCP URL that's no longer in the catalog (e.g. a
// failed cf-portal attempt left behind after the catalog entry was
// removed) would never render → user has no UI handle to clear it.
const renderedOAuthServerIds=new Set();
const connected=[],suggestions=[];
const hasGithubToken=secretKeys.includes("github_token");
mcpCatalog.forEach(cat=>{
Expand All @@ -271,6 +277,7 @@ function renderIntegrations(){
for(const h of catHosts){if(oauthByHostname.has(h)){oauthServer=oauthByHostname.get(h);break;}}
if(oauthServer){
connected.push(renderOAuthCard(cat,oauthServer));
renderedOAuthServerIds.add(oauthServer.id);
}else{
suggestions.push(renderOAuthCard(cat,null));
}
Expand Down Expand Up @@ -298,6 +305,16 @@ function renderIntegrations(){
connected.push(renderIntegCard(cfg.name,cfg.url||"Custom integration",null,cfg));
}
});
// Render any orphaned OAuth servers — entries in the per-user hub DO
// whose URL doesn't match any current catalog entry. Usually leftovers
// from a failed OAuth attempt where the catalog entry has since been
// removed, or one-off MCP servers added directly via /api/mcp/start-auth.
// Surfacing them gives the user a "Clear" button instead of letting the
// zombie linger.
oauthServers.forEach(s=>{
if(renderedOAuthServerIds.has(s.id))return;
connected.push(renderOrphanedOAuthCard(s));
});
const cards=[...connected,...suggestions];
$("integrations-list").innerHTML=cards.length?cards.join(""):'<div class="empty">No integrations</div>';
}
Expand Down Expand Up @@ -346,10 +363,12 @@ async function refreshIntegrationToken(id,displayName){
}catch(e){toast("Refresh failed: "+(e.message||e),"error")}
}

// 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.
// OAuth-MCP catalog entries render with different actions based on the
// server's current state in the per-user hub DO. The Agents SDK transitions
// between authenticating → connecting → discovering → ready (success path)
// or → failed (terminal error). For terminal states the only sensible
// action is to clear the entry; offering "Refresh" on a failed server
// retries against the same broken config and confuses users.
function renderOAuthCard(cat,server){
const desc=cat.description||"";
let statusHtml,actionsHtml;
Expand All @@ -362,14 +381,33 @@ function renderOAuthCard(cat,server){
: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>`;
const isReady=server.state==="ready";
const refreshBtn=isReady?`<button class="sm" onclick="refreshOAuthServer('${esc(server.id)}')" aria-label="Refresh ${esc(cat.name)} connection">Refresh</button>`:"";
const removeLabel=isReady?"Disconnect":"Clear";
actionsHtml=`${refreshBtn}<button class="sm danger" onclick="disconnectOAuthServer('${esc(server.id)}','${esc(cat.name)}')" aria-label="${esc(removeLabel)} ${esc(cat.name)}">${removeLabel}</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>`;
}

// Render an OAuth server that has no matching catalog entry — either a
// custom MCP added via `/api/mcp/start-auth`, or a leftover from a failed
// attempt whose catalog entry has since been removed. Always offer a
// "Clear" action so the user can wipe it; never offer "Refresh" since we
// don't know what the original setup looked like.
function renderOrphanedOAuthCard(server){
const stateLabel=server.state==="ready"?`Connected — ${server.toolCount} tool${server.toolCount!==1?'s':''}`
:server.state==="authenticating"?"Authenticating… (no longer reachable)"
: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)";
const displayName=server.name||"OAuth MCP";
const desc=`${server.url} — orphaned entry, not in catalog`;
return `<div class="integ-card"><div class="integ-name">${esc(displayName)}</div><div class="integ-desc">${esc(desc)}</div><span class="integ-status" style="color:${color}">${esc(stateLabel)}</span><div class="integ-actions"><button class="sm danger" onclick="disconnectOAuthServer('${esc(server.id)}','${esc(displayName)}')" aria-label="Clear ${esc(displayName)}">Clear</button></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
Expand Down
20 changes: 9 additions & 11 deletions src/mcp-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,15 @@ const CORE_MCP_CATALOG: McpCatalogEntry[] = [
auth_type: "static_headers",
knownHosts: [],
},
{
id: "github",
name: "GitHub",
description:
"Structured tools for issues, PRs, actions, and code scanning (beyond basic git operations)",
url: "https://api.githubcopilot.com/mcp/",
setupGuide:
"Use the remote server at https://api.githubcopilot.com/mcp/ with OAuth, or deploy locally with a PAT",
auth_type: "oauth",
knownHosts: ["api.githubcopilot.com"],
},
// NOTE: GitHub Copilot's MCP server at https://api.githubcopilot.com/mcp/
// was tested and is intentionally NOT in this catalog as an OAuth entry.
// Its OAuth provider does NOT support Dynamic Client Registration —
// attempting to connect via the SDK-managed OAuth path errors with
// "Incompatible auth server: does not support dynamic client registration".
// To use GitHub MCP, add a github_token secret (Personal Access Token or
// GitHub App installation token); the existing static-headers path picks
// it up automatically and the UI hides the GitHub catalog entry when one
// is present.
];

const DEFAULT_CLOUDFLARE_REMOTE_MCP_CATALOG: McpCatalogEntry[] = [
Expand Down
11 changes: 7 additions & 4 deletions test/mcp-catalog-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ describe("MCP_CATALOG", () => {
expect(entry?.knownHosts).toContain("browser.mcp.cloudflare.com");
});

it("includes github as an OAuth catalog entry", () => {
it("does NOT include github as an OAuth catalog entry — GitHub's MCP server doesn't support DCR", () => {
// Documented in mcp-catalog.ts. api.githubcopilot.com returns
// "Incompatible auth server: does not support dynamic client
// registration" when the SDK-managed OAuth path attempts DCR. To use
// GitHub MCP, add a github_token secret — the static-headers path
// already handles it and the UI hides the suggestion when present.
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");
expect(entry).toBeUndefined();
});

it("every catalog entry has either auth_type or is the self-MCP", () => {
Expand Down
17 changes: 12 additions & 5 deletions test/mcp-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,22 +182,29 @@ describe("Approved MCP catalog admin operations", () => {
});

describe("MCP Catalog", () => {
it("returns catalog with at least 3 entries", async () => {
it("returns catalog with the expected default entries", async () => {
const res = await fetchJson("/api/mcp-catalog");
expect(res.status).toBe(200);
const catalog = (await res.json()) as Array<{ id: string; name: string; description: string; url: string; setupGuide: string }>;
expect(Array.isArray(catalog)).toBe(true);
// Catalog seed is config-driven (PR #48). Three entries land by default —
// dodo-self, github, browser-rendering — and admins can add more via
// Catalog seed is config-driven. The current defaults are
// dodo-self + browser-rendering. cf-portal is omitted (loopback-only
// OAuth — see mcp-catalog.ts comment) and github is omitted (its MCP
// server doesn't support DCR). Admins can add more via
// /api/admin/approved-mcps.
expect(catalog.length).toBeGreaterThanOrEqual(3);
expect(catalog.length).toBeGreaterThanOrEqual(2);

// Verify known entries exist
const ids = catalog.map((c) => c.id);
expect(ids).toContain("dodo-self");
expect(ids).toContain("github");
expect(ids).toContain("browser-rendering");

// And confirm the catalog entries removed for compatibility reasons
// are indeed gone, so the next person to add them doesn't have to
// re-read all the audit history.
expect(ids).not.toContain("github");
expect(ids).not.toContain("cf-portal");

// Verify each entry has required fields
for (const entry of catalog) {
expect(entry.id).toBeTruthy();
Expand Down
Loading