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
89 changes: 75 additions & 14 deletions src/coding-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6475,7 +6475,10 @@ export class CodingAgent extends Think<Env, DodoConfig> {
// even if the MCP config itself is enabled.
const browserEnabled = this.readMetadata("browser_enabled") === "true";

// Filter to enabled HTTP configs with URLs
// Filter to enabled HTTP configs with URLs. `oauth` (SDK-managed) is
// federated through the per-user hub DO and never connected from
// session DOs directly. `refresh_token` is connected here with a
// bearer header sourced from UserControl, which owns the refresh.
const enabled = configs.filter((c) => {
if (!c.enabled || c.type !== "http" || !c.url) return false;
if (c.auth_type === "oauth") return false;
Expand All @@ -6484,13 +6487,32 @@ export class CodingAgent extends Think<Env, DodoConfig> {
});
if (enabled.length === 0) return;

// Helper: ask UserControl for a current access token. UserControl
// refreshes if expired (serialised by per-user DO single-threading).
const fetchRefreshTokenBearer = async (configId: string): Promise<string | null> => {
const tokenRes = await stub.fetch(
`https://user-control/mcp-configs/${encodeURIComponent(configId)}/access-token`,
{ headers: { "x-owner-email": ownerEmail } },
);
if (!tokenRes.ok) return null;
const { accessToken } = (await tokenRes.json()) as { accessToken?: string };
return accessToken ?? null;
};

// Resolve encrypted headers and connect each gatekeeper
const connected: McpClient[] = [];
for (const config of enabled) {
try {
// Resolve headers via internal secret endpoint
// Resolve auth headers depending on the config's auth_type.
let headers: Record<string, string> | undefined;
if (config.headerKeys?.length) {

if (config.auth_type === "refresh_token") {
const accessToken = await fetchRefreshTokenBearer(config.id);
if (!accessToken) {
throw new Error("No refresh-token access token available; run set_refresh_token_mcp again");
}
headers = { Authorization: `Bearer ${accessToken}` };
} else if (config.headerKeys?.length) {
headers = {};
for (const headerName of config.headerKeys) {
const secretRes = await stub.fetch(
Expand All @@ -6504,21 +6526,60 @@ export class CodingAgent extends Think<Env, DodoConfig> {
}
}

const gk = new HttpMcpClient({
let gk = new HttpMcpClient({
...config,
headers,
}, this.mcpDepth);

await gk.connect();
const tools = await gk.listTools(); // Pre-populate cache for synchronous getTools()
connected.push(gk);
this.mcpStatus.set(config.id, {
name: config.name,
url: config.url,
ok: true,
toolCount: tools.length,
lastCheckedAt: Date.now(),
});
try {
await gk.connect();
// Pre-populate cache for synchronous getTools()
const tools = await gk.listTools();
connected.push(gk);
this.mcpStatus.set(config.id, {
name: config.name,
url: config.url,
ok: true,
toolCount: tools.length,
lastCheckedAt: Date.now(),
});
} catch (innerErr) {
// For refresh-token configs, a 401/auth-style failure is
// recoverable: force a refresh and reconnect once. UserControl
// is the source of truth for the token, so we ask it to
// refresh rather than retry with the same (probably-expired)
// token we just used.
const msg = innerErr instanceof Error ? innerErr.message : String(innerErr);
const looksLikeAuthFail = /401|403|unauthor/i.test(msg);
if (config.auth_type === "refresh_token" && looksLikeAuthFail) {
try { gk.disconnect(); } catch { /* best effort */ }
const refreshRes = await stub.fetch(
`https://user-control/mcp-configs/${encodeURIComponent(config.id)}/access-token?force=1`,
{ headers: { "x-owner-email": ownerEmail } },
);
if (refreshRes.ok) {
const { accessToken } = (await refreshRes.json()) as { accessToken?: string };
if (accessToken) {
gk = new HttpMcpClient({
...config,
headers: { Authorization: `Bearer ${accessToken}` },
}, this.mcpDepth);
await gk.connect();
const tools = await gk.listTools();
connected.push(gk);
this.mcpStatus.set(config.id, {
name: config.name,
url: config.url,
ok: true,
toolCount: tools.length,
lastCheckedAt: Date.now(),
});
continue;
}
}
}
throw innerErr;
}
} catch (error) {
// Log but don't fail — one broken MCP server shouldn't block the session.
// We also record the failure on `mcpStatus` so the UI can surface it
Expand Down
10 changes: 9 additions & 1 deletion src/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,15 @@ export interface McpClientConfig {
id: string;
name: string;
type: "http" | "service-binding";
auth_type: "oauth" | "static_headers";
/**
* - `static_headers` — fixed bearer/API-key headers stored in encrypted_secrets
* - `oauth` — Agents-SDK-managed OAuth (per-user hub DO). Filtered out of
* the static MCP gatekeeper path in coding-agent.ts.
* - `refresh_token` — bearer token that auto-refreshes via OAuth refresh-token
* grant. Use when the OAuth provider only allows loopback redirect URIs
* (e.g. Cloudflare Portal) and DCR was performed by a local helper.
*/
auth_type: "oauth" | "static_headers" | "refresh_token";
url?: string;
headers?: Record<string, string>;
/** Header key names (without values) for display purposes. */
Expand Down
55 changes: 55 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,61 @@ export function createDodoMcpServer(env: Env, userEmail: string, depth = 0): Mcp
jsonFetch(env, "user", "/mcp-configs"),
);

// Push a refresh-token MCP config (from a local helper that already ran
// DCR + browser OAuth for an MCP server whose authorize endpoint only
// accepts loopback redirect URIs — e.g. portal.mcp.cfdata.org). Idempotent
// on `url` so re-running the local helper just rotates the tokens in place.
server.tool(
"set_refresh_token_mcp",
[
"Register an MCP server that authenticates with an OAuth refresh token.",
"Use this when the upstream OAuth provider only accepts loopback",
"redirect URIs (so Dodo can't do the OAuth dance itself) and a local",
"helper has already completed the authorize+exchange flow. Dodo will",
"use the access token directly and refresh it via the OAuth token",
"endpoint as it expires. Idempotent on `url`: re-pushing for the same",
"MCP URL updates the stored tokens in place.",
].join(" "),
{
name: z.string().describe("Integration display name"),
url: z.string().url().describe("MCP server endpoint URL"),
tokenEndpoint: z
.string()
.url()
.describe(
"OAuth token endpoint used to refresh the access token (e.g. https://cf-mcp.cloudflareaccess.com/cdn-cgi/access/oauth/token)",
),
clientId: z
.string()
.min(1)
.describe("OAuth client_id from the local helper's DCR"),
accessToken: z.string().min(1).describe("Current OAuth access token"),
refreshToken: z
.string()
.min(1)
.describe("Current OAuth refresh token. Will rotate on each refresh."),
expiresAt: z
.number()
.int()
.nonnegative()
.optional()
.describe("Absolute expiry of the access token in unix seconds. When omitted, the access token is treated as expired and refreshed on first use."),
},
async (input) => {
const res = await userControlFetch(env, "/refresh-token-mcp", {
body: JSON.stringify(input),
headers: { "content-type": "application/json" },
method: "POST",
});
if (!res.ok) {
const err = await res.json();
return errorResult(err);
}
const payload = (await res.json()) as { id: string; name: string; url: string; updated: boolean };
return textResult(payload);
},
);

server.tool("remove_mcp_config", "Remove an MCP integration by id", {
id: z.string().describe("MCP config id to remove"),
}, async ({ id }) => {
Expand Down
Loading
Loading