feat(den): External MCP Connections — org-level, search/execute-routed, real OAuth#2406
Conversation
…d, real OAuth Lets an org admin connect any third-party MCP server (Notion, Linear, Stripe, or a custom URL) once, org-wide, from a real Den screen - instead of each device connecting separately. Once connected, the connection's tools are automatically merged into the existing search_capabilities / execute_capability surface (/mcp/agent); the harness never sees a per-connection tool list, only the same two tools it always had. Backend (ee/apps/den-api): - ExternalMcpConnectionTable gains org-level OAuth token columns (access/refresh token, scope, expiry, pending PKCE verifier - encrypted). Unlike ConnectedAccountTable (Google Workspace, per-member), this is deliberately org-shared, like an LLM provider key. - capability-sources/external-mcp-client.ts: a real MCP client (@modelcontextprotocol/sdk) implementing OAuthClientProvider against those columns. Third-party MCP servers don't have a pre-shared client_id, so this drives real RFC 9728 discovery + RFC 7591 dynamic client registration + PKCE (dynamically-registered client info is stored in the existing OrgOAuthClientTable, keyed by the connection's own id) - this is a different mechanism from generic-oauth.ts, which only fits native providers with a fixed, admin-configured OAuth app. - capability-sources/external-mcp-connections.ts: CRUD for the table. - capability-sources/external-mcp-presets.ts: quick-add presets (Notion, Linear, Stripe, Sentry, Context7) - same real servers/URLs the desktop app already offers, now addable org-wide from Den instead. - routes/org/mcp-connections.ts: create/list/delete/disconnect + OAuth connect/start+callback. Mutation and OAuth routes are tagged Authentication (already blocked from the agent-facing MCP surface); only list/presets are agent-visible (Capability Sources), read-only. The callback serves a small static HTML success page - the admin's Den tab polls status in the background, matching the desktop's own OAuth UX, rather than needing to guess den-web's origin to redirect back to. - mcp/external-capabilities.ts + mcp/agent.ts: merges each org-connected external MCP's live tools/list into search_capabilities (namespaced mcp:<connectionId>:<toolName>), and dispatches matching execute_capability calls through the real SDK client instead of invokeMcpOperation. The rich /mcp endpoint and its ~129 individually-registered tools are untouched. Frontend (ee/apps/den-web): new "MCP Connections" dashboard screen (quick-add preset grid + a custom name/URL/auth-type form), following this app's existing conventions (DashboardPageTemplate, DenButton/DenInput, TanStack Query) rather than the desktop app's component library. OAuth connect opens a real browser popup and polls connection status - no redirect-back plumbing needed since the popup is self-contained. Runner (evals/runner): added EvalContext.switchToNewTab()/switchBack(), a small additive capability for flows that need to follow a real window.open() popup (e.g. an OAuth tab) and confirm it, then return focus to the original tab. No existing flow uses it; behavior for all other flows is unchanged. Validated end-to-end, for real, twice: 1. Backend only, via curl/script: create connection -> connect/start (real discovery + dynamic client registration) -> real redirect to a self-controlled stand-in OAuth+MCP server -> real PKCE-verified code exchange -> encrypted token storage -> search_capabilities finds the real tool -> execute_capability calls it and gets the real result back. 2. Through the actual den-web UI end-to-end (evals/flows/ mcp-connections-cloud-oauth.flow.mjs, passing, fraimz.html generated): sign in -> open the new MCP Connections screen -> Add Custom -> a real browser popup opens and completes OAuth for real -> Den's own polling (no test-only code path) shows Connected -> search_capabilities finds the connection's tool and execute_capability really calls it. Also fixed along the way: a state round-trip bug (my own signed correlation token was carried in a custom query param that a spec-compliant server would never echo back - moved it into the standard OAuth state param via a state() override), and a missing-Origin-header gap when driving Den's auth routes from a Node script instead of a browser. Not yet done: Daytona cloud deployment of this same flow (in progress), and no real third-party MCP server (Notion et al.) has been tested - only the stand-in, which speaks the identical protocol for real.
…URL behind a reverse proxy Behind Daytona's port-forwarding proxy, request.url reflects the internal bind address (http://127.0.0.1:8788) rather than the public URL the browser actually called, since the proxy doesn't rewrite the request's own URL. This broke the OAuth popup: it landed on an internal, browser-unreachable callback URL instead of the public one. Added resolvePublicOrigin() (generic-oauth.ts), preferring the existing DEN_API_PUBLIC_URL env var when set, falling back to the request-derived origin for plain local dev where no proxy is involved. Applied to both mcp-connections.ts and oauth-providers.ts (Google Workspace), which had the identical latent bug - previously untested against a real reverse-proxied deployment. Also made the MCP Connections nav-link click in the fraimz flow retry instead of firing once: over real cloud latency, the very first click can land before Next.js finishes hydrating and attaching the link handler.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
13 issues found across 22 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="ee/apps/den-web/app/(den)/_lib/den-org.ts">
<violation number="1" location="ee/apps/den-web/app/(den)/_lib/den-org.ts:459">
P3: Function placement breaks the GitHub integration route grouping. The three `getGithubIntegration*` functions form a logical cluster; inserting `getMcpConnectionsRoute` between them makes the file harder to scan.</violation>
</file>
<file name="evals/runner/context.mjs">
<violation number="1" location="evals/runner/context.mjs:61">
P2: switchToNewTab misses popups opened immediately before it is called. Either document/start the wait before the click, or make the helper accept an action to run while watching for new targets.</violation>
<violation number="2" location="evals/runner/context.mjs:72">
P2: The CDP client opened for the popup is never closed. Close the current popup client before restoring the previous tab, and add cleanup for active/stacked clients on flow teardown.</violation>
</file>
<file name="evals/flows/mcp-connections-cloud-oauth.flow.mjs">
<violation number="1" location="evals/flows/mcp-connections-cloud-oauth.flow.mjs:269">
P2: Search by the unique connection name instead of generic `echo`. Otherwise repeated runs can leave older echo tools in the org and make this flow fail before it ever sees the connection it just created.</violation>
</file>
<file name="ee/apps/den-web/app/(den)/dashboard/_components/mcp-connections-screen.tsx">
<violation number="1" location="ee/apps/den-web/app/(den)/dashboard/_components/mcp-connections-screen.tsx:51">
P2: Make OAuth status polling single-flight and clear any existing poll before starting a new one; otherwise slow refetches or repeated Connect clicks can create overlapping refresh loops and stale timers that clear the current polling state.
(Based on your team's feedback about keeping background refreshes single-flight.) [FEEDBACK_USED]</violation>
<violation number="2" location="ee/apps/den-web/app/(den)/dashboard/_components/mcp-connections-screen.tsx:69">
P2: Open a placeholder popup synchronously from the click handler before awaiting OAuth setup, then navigate it to authorizeUrl. Calling window.open after the async mutation can be blocked, leaving the user polling forever with no authorization window.</violation>
</file>
<file name="ee/apps/den-api/src/capability-sources/external-mcp-client.ts">
<violation number="1" location="ee/apps/den-api/src/capability-sources/external-mcp-client.ts:193">
P2: Persist SDK OAuth discovery state across the browser redirect before exchanging the code. Servers that advertise authorization metadata or scope from the initial 401 can fail callback or exchange against the wrong resource when the callback rebuilds a fresh transport.</violation>
</file>
<file name="ee/apps/den-api/src/mcp/external-capabilities.ts">
<violation number="1" location="ee/apps/den-api/src/mcp/external-capabilities.ts:40">
P2: Disconnected API-key/no-auth MCP connections remain exposed to search_capabilities/execute_capability. Check persisted connection state (or clear apiKey/set connectedAt consistently) instead of treating key presence/no-auth as always connected.</violation>
<violation number="2" location="ee/apps/den-api/src/mcp/external-capabilities.ts:84">
P2: External tool discovery is serialized across connections, so a single slow MCP server can stall the whole capability search. Run per-connection listings concurrently with bounded concurrency/timeouts and keep the existing best-effort behavior.</violation>
</file>
<file name="ee/apps/den-api/src/capability-sources/external-mcp-connections.ts">
<violation number="1" location="ee/apps/den-api/src/capability-sources/external-mcp-connections.ts:79">
P2: Deleting an external MCP connection leaves its dynamically registered OAuth client record behind. Remove the matching OrgOAuthClientTable row when deleting the connection to avoid orphaned credentials/metadata.</violation>
<violation number="2" location="ee/apps/den-api/src/capability-sources/external-mcp-connections.ts:124">
P2: Disconnect does not clear API-key credentials, so API-key MCP connections remain connected after the disconnect endpoint succeeds. Clear `apiKey` with the other credentials.</violation>
</file>
<file name="ee/apps/den-api/src/mcp/agent.ts">
<violation number="1" location="ee/apps/den-api/src/mcp/agent.ts:74">
P2: External MCP search/execute rebuilds OAuth redirect URIs from the internal request origin instead of DEN_API_PUBLIC_URL. Behind the documented proxy setup this can mismatch the registered callback URL and make OAuth-backed external tools disappear or fail to execute.</violation>
<violation number="2" location="ee/apps/den-api/src/mcp/agent.ts:105">
P1: External MCP tools bypass MCP write-scope enforcement. A token with only mcp:read can invoke arbitrary org-connected third-party tools, including mutating tools.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| }, | ||
| async ({ name, path, query, body }) => { | ||
| const external = parseExternalCapabilityName(name) | ||
| if (external) { |
There was a problem hiding this comment.
P1: External MCP tools bypass MCP write-scope enforcement. A token with only mcp:read can invoke arbitrary org-connected third-party tools, including mutating tools.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/apps/den-api/src/mcp/agent.ts, line 105:
<comment>External MCP tools bypass MCP write-scope enforcement. A token with only mcp:read can invoke arbitrary org-connected third-party tools, including mutating tools.</comment>
<file context>
@@ -86,6 +101,34 @@ export function registerAgentMcpRoutes<T extends { Variables: Record<string, unk
},
async ({ name, path, query, body }) => {
+ const external = parseExternalCapabilityName(name)
+ if (external) {
+ const normalizedBody = normalizeToolBody(body)
+ const args = (typeof normalizedBody === "object" && normalizedBody !== null && !Array.isArray(normalizedBody)
</file context>
| if (external) { | |
| if (external) { | |
| if (!principal.scopes.has("mcp:write")) { | |
| return { | |
| isError: true, | |
| content: [{ type: "text" as const, text: JSON.stringify({ error: "insufficient_mcp_scope", requiredScope: "mcp:write" }) }], | |
| } | |
| } |
| if (candidate) { | ||
| const newClient = await connect(debuggerUrlFor(this.cdpBaseUrl, candidate)); | ||
| this.tabStack.push(this.client); | ||
| this.client = newClient; |
There was a problem hiding this comment.
P2: The CDP client opened for the popup is never closed. Close the current popup client before restoring the previous tab, and add cleanup for active/stacked clients on flow teardown.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At evals/runner/context.mjs, line 72:
<comment>The CDP client opened for the popup is never closed. Close the current popup client before restoring the previous tab, and add cleanup for active/stacked clients on flow teardown.</comment>
<file context>
@@ -31,18 +31,59 @@ function slug(value) {
+ if (candidate) {
+ const newClient = await connect(debuggerUrlFor(this.cdpBaseUrl, candidate));
+ this.tabStack.push(this.client);
+ this.client = newClient;
+ this.log(`Switched to new tab: ${candidate.title || candidate.url}`);
+ return candidate;
</file context>
| if (!this.cdpBaseUrl) { | ||
| throw new EvalError("switchToNewTab requires cdpBaseUrl on the context."); | ||
| } | ||
| const before = await listTargets(this.cdpBaseUrl); |
There was a problem hiding this comment.
P2: switchToNewTab misses popups opened immediately before it is called. Either document/start the wait before the click, or make the helper accept an action to run while watching for new targets.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At evals/runner/context.mjs, line 61:
<comment>switchToNewTab misses popups opened immediately before it is called. Either document/start the wait before the click, or make the helper accept an action to run while watching for new targets.</comment>
<file context>
@@ -31,18 +31,59 @@ function slug(value) {
+ if (!this.cdpBaseUrl) {
+ throw new EvalError("switchToNewTab requires cdpBaseUrl on the context.");
+ }
+ const before = await listTargets(this.cdpBaseUrl);
+ const beforeIds = new Set(before.map((entry) => entry.id));
+ const startedAt = Date.now();
</file context>
|
|
||
| const searchResult = await mcpAgentCall("tools/call", { | ||
| name: "search_capabilities", | ||
| arguments: { query: "echo" }, |
There was a problem hiding this comment.
P2: Search by the unique connection name instead of generic echo. Otherwise repeated runs can leave older echo tools in the org and make this flow fail before it ever sees the connection it just created.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At evals/flows/mcp-connections-cloud-oauth.flow.mjs, line 269:
<comment>Search by the unique connection name instead of generic `echo`. Otherwise repeated runs can leave older echo tools in the org and make this flow fail before it ever sees the connection it just created.</comment>
<file context>
@@ -0,0 +1,287 @@
+
+ const searchResult = await mcpAgentCall("tools/call", {
+ name: "search_capabilities",
+ arguments: { query: "echo" },
+ });
+ const matchesText = searchResult.content[0].text;
</file context>
| } | ||
|
|
||
| function pollUntilConnected(connectionId: string) { | ||
| setPollingConnectionId(connectionId); |
There was a problem hiding this comment.
P2: Make OAuth status polling single-flight and clear any existing poll before starting a new one; otherwise slow refetches or repeated Connect clicks can create overlapping refresh loops and stale timers that clear the current polling state.
(Based on your team's feedback about keeping background refreshes single-flight.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/apps/den-web/app/(den)/dashboard/_components/mcp-connections-screen.tsx, line 51:
<comment>Make OAuth status polling single-flight and clear any existing poll before starting a new one; otherwise slow refetches or repeated Connect clicks can create overlapping refresh loops and stale timers that clear the current polling state.
(Based on your team's feedback about keeping background refreshes single-flight.) </comment>
<file context>
@@ -0,0 +1,359 @@
+ }
+
+ function pollUntilConnected(connectionId: string) {
+ setPollingConnectionId(connectionId);
+ const startedAt = Date.now();
+ pollTimer.current = setInterval(async () => {
</file context>
|
|
||
| function isConnected(connection: ExternalMcpConnectionRow): boolean { | ||
| if (connection.authType === "oauth") return Boolean(connection.accessToken) | ||
| if (connection.authType === "apikey") return Boolean(connection.apiKey) |
There was a problem hiding this comment.
P2: Disconnected API-key/no-auth MCP connections remain exposed to search_capabilities/execute_capability. Check persisted connection state (or clear apiKey/set connectedAt consistently) instead of treating key presence/no-auth as always connected.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/apps/den-api/src/mcp/external-capabilities.ts, line 40:
<comment>Disconnected API-key/no-auth MCP connections remain exposed to search_capabilities/execute_capability. Check persisted connection state (or clear apiKey/set connectedAt consistently) instead of treating key presence/no-auth as always connected.</comment>
<file context>
@@ -0,0 +1,150 @@
+
+function isConnected(connection: ExternalMcpConnectionRow): boolean {
+ if (connection.authType === "oauth") return Boolean(connection.accessToken)
+ if (connection.authType === "apikey") return Boolean(connection.apiKey)
+ return true
+}
</file context>
| }): Promise<boolean> { | ||
| const existing = await getExternalMcpConnection(input) | ||
| if (!existing) return false | ||
| await db.delete(ExternalMcpConnectionTable).where(eq(ExternalMcpConnectionTable.id, existing.id)) |
There was a problem hiding this comment.
P2: Deleting an external MCP connection leaves its dynamically registered OAuth client record behind. Remove the matching OrgOAuthClientTable row when deleting the connection to avoid orphaned credentials/metadata.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/apps/den-api/src/capability-sources/external-mcp-connections.ts, line 79:
<comment>Deleting an external MCP connection leaves its dynamically registered OAuth client record behind. Remove the matching OrgOAuthClientTable row when deleting the connection to avoid orphaned credentials/metadata.</comment>
<file context>
@@ -0,0 +1,134 @@
+}): Promise<boolean> {
+ const existing = await getExternalMcpConnection(input)
+ if (!existing) return false
+ await db.delete(ExternalMcpConnectionTable).where(eq(ExternalMcpConnectionTable.id, existing.id))
+ return true
+}
</file context>
| await db | ||
| .update(ExternalMcpConnectionTable) | ||
| .set({ | ||
| accessToken: null, |
There was a problem hiding this comment.
P2: Disconnect does not clear API-key credentials, so API-key MCP connections remain connected after the disconnect endpoint succeeds. Clear apiKey with the other credentials.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/apps/den-api/src/capability-sources/external-mcp-connections.ts, line 124:
<comment>Disconnect does not clear API-key credentials, so API-key MCP connections remain connected after the disconnect endpoint succeeds. Clear `apiKey` with the other credentials.</comment>
<file context>
@@ -0,0 +1,134 @@
+ await db
+ .update(ExternalMcpConnectionTable)
+ .set({
+ accessToken: null,
+ refreshToken: null,
+ tokenType: null,
</file context>
| const externalMatches = await searchExternalCapabilities({ | ||
| organizationId: principal.organizationId, | ||
| query, | ||
| redirectUriBase: new URL(c.req.raw.url).origin, |
There was a problem hiding this comment.
P2: External MCP search/execute rebuilds OAuth redirect URIs from the internal request origin instead of DEN_API_PUBLIC_URL. Behind the documented proxy setup this can mismatch the registered callback URL and make OAuth-backed external tools disappear or fail to execute.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/apps/den-api/src/mcp/agent.ts, line 74:
<comment>External MCP search/execute rebuilds OAuth redirect URIs from the internal request origin instead of DEN_API_PUBLIC_URL. Behind the documented proxy setup this can mismatch the registered callback URL and make OAuth-backed external tools disappear or fail to execute.</comment>
<file context>
@@ -61,7 +62,21 @@ export function registerAgentMcpRoutes<T extends { Variables: Record<string, unk
+ const externalMatches = await searchExternalCapabilities({
+ organizationId: principal.organizationId,
+ query,
+ redirectUriBase: new URL(c.req.raw.url).origin,
+ limit: boundedLimit,
+ })
</file context>
| return `${getIntegrationsRoute(orgSlug)}/github`; | ||
| } | ||
|
|
||
| export function getMcpConnectionsRoute(orgSlug?: string | null): string { |
There was a problem hiding this comment.
P3: Function placement breaks the GitHub integration route grouping. The three getGithubIntegration* functions form a logical cluster; inserting getMcpConnectionsRoute between them makes the file harder to scan.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/apps/den-web/app/(den)/_lib/den-org.ts, line 459:
<comment>Function placement breaks the GitHub integration route grouping. The three `getGithubIntegration*` functions form a logical cluster; inserting `getMcpConnectionsRoute` between them makes the file harder to scan.</comment>
<file context>
@@ -456,6 +456,10 @@ export function getGithubIntegrationRoute(orgSlug?: string | null): string {
return `${getIntegrationsRoute(orgSlug)}/github`;
}
+export function getMcpConnectionsRoute(orgSlug?: string | null): string {
+ return `${getOrgDashboardRoute(orgSlug)}/mcp-connections`;
+}
</file context>
…MCP connections Closes the two gaps flagged in review: connections were usable by every org member the instant they existed, and every call ran as the connecting admin's account. Both are now member-scoped. Access grants (who can USE a connection): - New ExternalMcpConnectionAccessGrantTable: one row grants a member, a team, or the whole org (orgWide). Deliberately naive vs the plugin-arch grant tables - no role column (use = use; managing stays admin-only), hard delete, full-replace update semantics (mirrors LlmProviderAccessTable). Access is never implicit: zero grants = zero non-admin access. - GET /v1/mcp-connections gains scope=usable (default: grant-filtered for the caller, with per-member connection status) | manageable (admin-only: everything + access summaries). PUT /:id/access replaces the access set. - search_capabilities and execute_capability now resolve the MCP principal's member identity (userId -> membership + teams) and enforce grants on both paths - not just search-side filtering. Per-member credentials (WHO the call runs as): - New credentialMode on ExternalMcpConnectionTable: "shared" (one org credential, as before - service-account style) or "per_member" (the connection and its dynamically-registered OAuth client are org-level, but each member authorizes their own account; tokens live in ConnectedAccountTable keyed by (orgMembershipId, providerId=connection id), reusing the per-member credential table from #2405 exactly as designed). - The SDK OAuthClientProvider is now mode-aware: token load/save/PKCE verifier go to the member's row for per_member connections. The signed OAuth state token already carried orgMembershipId, so the public callback routes tokens to the right person with no new mechanism. - connect/start: shared = admin-only (it IS the org integration setup); per_member = any granted member connects their own account. - Granted-but-not-connected surfaces as a needs_connection search match with a human hint (agent tells the user to connect), instead of silence. den-web: - Admin Add dialog gains "Account" (one shared account / each person connects their own) and "Who can use this?" (everyone / teams / people) pickers; rows show per-member + access-summary badges. - New member-visible "Your Connections" page (outside the (admin) route group) where each person sees what they've been granted and connects their own account via the same real OAuth popup + polling flow. Also fixed: deleting a connection now cleans up its grants, every member's connected-account tokens, and the dynamically-registered OAuth client (previously orphaned - no FK cascades on these tables). Proven end-to-end twice, with two real users (seeded admin + a member bootstrapped through the real invitation flow): 1. curl smoke: grant scoping (member sees org-wide only, admin sees team-scoped too, manageable 403s for members), member OAuth putting an encrypted token on HIS membership row while the admin stays unconnected, agent search/execute as the member, forbidden on non-granted connections, needs_connection hint for the unconnected admin. 2. evals/flows/mcp-connections-member-scoped.flow.mjs (fraimz PASSED, 5 validated frames): admin publishes per-member connection through the real dialog -> member signs in, sees only what they were granted, connects their own account through a real OAuth popup -> row flips to "Connected as you" via Den's own polling -> agent search/execute runs as the member with grant enforcement + needs_connection all asserted. The pre-existing mcp-connections-cloud-oauth flow still passes (re-run back-to-back; its sign-in step made identity-aware since the browser may now hold a member session from the other flow).
Update: per-employee access grants + per-member credentials (addressing review)The review flagged that connections were (a) usable by every org member the instant they existed, and (b) always acting as the connecting admin's account. Both are fixed in Who can USE a connection — new WHO the call runs as — new
Also fixed: connection deletion now cleans up grants, per-member tokens, and the dynamically-registered OAuth client (previously orphaned). ProofNew fraimz flow
The pre-existing Known deferrals (unchanged from the PR description): tool-level permissioning within a connection, audit logging, |
Closes the last unproven leg of the story: everything from the admin publishing a connection to the result appearing in the MEMBER'S REAL DESKTOP CHAT, in one flow (mcp-connections-desktop-e2e, fraimz PASSED): 1. Setup (real HTTP): admin publishes a per-member connection; the member connects their own account (browser leg of the same round trips proven in mcp-connections-member-scoped). 2. The member signs the DESKTOP APP into OpenWork Cloud via a real handoff grant against the local Den (desktop-bootstrap.json written through the desktop bridge - localStorage overrides alone are not enough because getDenMcpUrl() derives from the bootstrap config). 3. Onboarding completed for real (org picker -> workspace folder). 4. OpenWork Cloud Control auto-configures with a token minted for the member (sync runs on the settings route, same as a real user visiting settings once; asserted + frame). 5. In a real chat turn (Big Pickle, zero keys), the agent calls openwork-cloud_search_capabilities -> finds the org connection's real tool -> openwork-cloud_execute_capability -> Den executes it with the MEMBER'S OWN stored credential -> the exact text renders in the chat (frame: the full tool-call transcript). 6. The external MCP server's own request log is asserted to contain a fresh POST /mcp during the chat - the result really traveled desktop -> Den -> external server, not cache or simulation.
The complete end-to-end POV, now proven: admin publish → member's desktop chat resultReview asked for the full arc "from the moment the admin does that to the moment it works in the app." New flow
So the demo script's scene 6 ("back in the desktop app... it searches for a capability, finds it, and calls it — as me") is now backed by a validated frame, not just API calls. One real integration detail this surfaced: pointing a desktop at a non-default Den control plane must go through |
The Extensions page Refresh button previously refreshed local MCP server statuses and plugins but never touched syncCloudControlMcp() - the thing that re-mints the member's org token and rewrites the OpenWork Cloud Control config. That sync only ran on settings mount and self-gated on a freshness marker, so when Den-side state changed (token revoked, org switched, config drift) the only real user remedies were sign-out/sign-in or waiting for marker expiry. syncCloudControlMcp gains a force option that bypasses the freshness marker; Refresh now calls it first (then refreshes server statuses), so one click means "make my cloud connection current NOW". Proven by evals/flows/mcp-cloud-force-sync.flow.mjs (fraimz PASSED): with a maximally-fresh marker (post-mount settle, when the auto-sync provably does nothing), one Refresh click rewrote the sync marker with a newly minted token expiry - previously impossible without signing out.
Added: force-sync via the Refresh button (
|
Den fetches connection URLs server-side (discovery, dynamic client registration, tools/list, tool calls). On a hosted multi-tenant deployment, any signed-up user can create an org (becoming its admin) and register a connection URL - so without a guard, anyone could make Den's servers fetch internal targets they can reach but the user can't: localhost service ports, private-network neighbors, and the cloud metadata endpoint (169.254.169.254) that can leak infrastructure credentials. capability-sources/url-guard.ts: - assertPublicUrl(): http(s)-only, resolve-then-check - the hostname is DNS-resolved and rejected if ANY resulting address is private, loopback, link-local, CGNAT, or otherwise reserved (string matching alone is defeated by pointing a legit-looking domain's DNS at 127.0.0.1). - createGuardedFetch(): re-applies the check to EVERY outbound request via the MCP SDK transport's fetch option - the SDK follows discovery documents to other hosts, and DNS answers can change after create-time validation (rebinding), so each request is checked when it's made. - Applied at connection create (clean 400 with a clear message) and threaded into buildTransport for all client traffic. Deployment-aware by design, not just as revert insurance: - Hosted prod: on by default. - Self-hosted (OpenWork is ejectable; their MCP servers legitimately live on private networks): DEN_ALLOW_PRIVATE_MCP_URLS=1 disables it - so 'reverting' in production is a config flip, not a deploy. - Local dev/evals: exempt automatically via OPENWORK_DEV_MODE=1. Proof: - 38-case unit test (bun test test/mcp-url-guard.test.ts) covering the v4/v6 range matrix, mapped-IPv4, fail-closed unparseables, and live assertPublicUrl behavior including the DNS-resolution path. - Live negative test on a prod-mode instance: creating connections against 169.254.169.254 and localhost both return 400 with the guard's message. - mcp-connections-member-scoped fraimz re-run PASSED with the guard code in place (dev-mode exemption keeps the local stand-in server usable). Note for reviewers: during re-verification the local shared dev DB hit an unrelated better-auth 'Failed to decrypt private key' JWKS/secret mismatch (stale jwks row from an older stack run with a different secret) - fixed locally by clearing the jwks table per better-auth's own guidance; not caused by, or related to, this change.
Final addition before merge-ready: SSRF guard (
|
Deploying openwork with
|
| Latest commit: |
203af6a
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f4b6f546.openwork.pages.dev |
| Branch Preview URL: | https://feat-external-mcp-connection.openwork.pages.dev |
Layer 0 — no deploy: scripts/mcp-connections-rollout.ts (den-api) flips the per-org metadata opt-in (status / enable / disable / dark), taking effect on the next desktop poll for every app version in the field. Layer 1 — code: scripts/revert-org-mcp-connections.sh builds a revert branch from the squash commits on dev (#2439, --all adds #2406), newest first, leaving the additive DB tables in place on purpose.
Resolves the drizzle migration collision with dev's 0028_memory_bank: drops this branch's generated 0028_amused_mongu/0029_wandering_medusa and regenerates the same schema delta as 0029_rare_hellfire_club on top of dev's snapshot chain (db:generate verified in-sync). Unions the new typeid prefixes (emg + mem/mctx) and keeps dev's requiresApp eval runner support alongside this branch's cdpBaseUrl context field.
Adds a "From your organization" section to Settings > Extensions > MCP, additive alongside the existing static Quick Connect grid. Sourced live from Den's /v1/mcp-connections?scope=usable (from PR #2406), it's the first piece of desktop-side consumption of that system. - den.ts: DenExternalMcpConnection type + listMcpConnections / startMcpConnectionConnect client methods, mirroring the existing listOrgLlmProviders pattern. - use-org-mcp-connections.ts: fetch-on-mount hook; connect() calls connect/start, opens the returned authorize URL via openDesktopUrl, then polls (mirrors the existing MCP OAuth poll-until-connected pattern) until the member's own connectedForMe flips true. - mcp-view.tsx: new McpOrgConnectionsSection using the existing ExtensionCard component unmodified. A pure resolveOrgMcpConnectionCardState helper (unit tested) decides card state: shared connections are shown as admin-managed and non-actionable; per_member connections show "Connect your account" until the calling member has their own token. - settings-route.tsx: wires the hook in and refreshes it from the existing Refresh button alongside the other cloud-sourced refreshes. Scope, deliberately: creating new org connections from the desktop is NOT included — POST /v1/mcp-connections is admin-only server-side, and den-web's admin dashboard already covers creation/management. This is consumption-only: discover what an admin published, connect your own account if needed. Local-only entries (OpenWork Browser, UI Control, local-command custom MCPs) are untouched since Den cannot spawn processes on a member's machine. Proven end-to-end with evals/flows/desktop-org-mcp-consolidation.flow.mjs (fraimz PASSED, 2 runs): a real click drives a real connect/start call, the OS browser it opens completes a real OAuth round trip against a mock IdP (witnessed via the mock server's own request log), and the SAME card flips from "Connect your account" to "Connected" without a page reload — proving the poll-driven UI update, not a manual refresh. Regression-checked against the pre-existing settings-extensions-mcp flow (still passing) to confirm the static Quick Connect grid is untouched. Depends on PR #2406 (External MCP Connections) for the underlying /v1/mcp-connections API — this branch is stacked on feat/external-mcp-connections.
Layer 0 — no deploy: scripts/mcp-connections-rollout.ts (den-api) flips the per-org metadata opt-in (status / enable / disable / dark), taking effect on the next desktop poll for every app version in the field. Layer 1 — code: scripts/revert-org-mcp-connections.sh builds a revert branch from the squash commits on dev (#2439, --all adds #2406), newest first, leaving the additive DB tables in place on purpose.
…staged rollout + rollback) (#2451) * feat(app): desktop discovers and connects org-level MCP connections Adds a "From your organization" section to Settings > Extensions > MCP, additive alongside the existing static Quick Connect grid. Sourced live from Den's /v1/mcp-connections?scope=usable (from PR #2406), it's the first piece of desktop-side consumption of that system. - den.ts: DenExternalMcpConnection type + listMcpConnections / startMcpConnectionConnect client methods, mirroring the existing listOrgLlmProviders pattern. - use-org-mcp-connections.ts: fetch-on-mount hook; connect() calls connect/start, opens the returned authorize URL via openDesktopUrl, then polls (mirrors the existing MCP OAuth poll-until-connected pattern) until the member's own connectedForMe flips true. - mcp-view.tsx: new McpOrgConnectionsSection using the existing ExtensionCard component unmodified. A pure resolveOrgMcpConnectionCardState helper (unit tested) decides card state: shared connections are shown as admin-managed and non-actionable; per_member connections show "Connect your account" until the calling member has their own token. - settings-route.tsx: wires the hook in and refreshes it from the existing Refresh button alongside the other cloud-sourced refreshes. Scope, deliberately: creating new org connections from the desktop is NOT included — POST /v1/mcp-connections is admin-only server-side, and den-web's admin dashboard already covers creation/management. This is consumption-only: discover what an admin published, connect your own account if needed. Local-only entries (OpenWork Browser, UI Control, local-command custom MCPs) are untouched since Den cannot spawn processes on a member's machine. Proven end-to-end with evals/flows/desktop-org-mcp-consolidation.flow.mjs (fraimz PASSED, 2 runs): a real click drives a real connect/start call, the OS browser it opens completes a real OAuth round trip against a mock IdP (witnessed via the mock server's own request log), and the SAME card flips from "Connect your account" to "Connected" without a page reload — proving the poll-driven UI update, not a manual refresh. Regression-checked against the pre-existing settings-extensions-mcp flow (still passing) to confirm the static Quick Connect grid is untouched. Depends on PR #2406 (External MCP Connections) for the underlying /v1/mcp-connections API — this branch is stacked on feat/external-mcp-connections. * feat(app): org MCP connections join the unified Extensions catalog with docs + passing fraimz - Project org MCP connections as ExtensionItems: per-member grants appear in Marketplace as 'Connect your account', connected/shared ones land in My Extensions — no special org-only section. - Dedupe static Quick Connect suggestions only when an org equivalent renders, never hiding configured direct MCPs. - Den OAuth callback page now deep-links back to the OpenWork app. - Route mounted /workspace/:id/opencode clients' promptAsync/command through the OpenWork wrapper path. - Add tutorial docs (shared-mcp-connections.mdx) with validated screenshots. - Add desktop-org-mcp-demo fraimz flow (Marketplace -> real browser OAuth -> My Extensions -> Cloud Control -> real chat tool execution with external mock-server witness); old consolidation flow delegates to it. * revert(app): drop the shared-path promptAsync/command wrapper change from this PR The 2-line opencode.ts change rerouted prompt/command for every openwork-mounted workspace — the one piece of this PR that changed behavior for users who never touch org MCP connections. It now ships separately as fix/opencode-openwork-wrapper-writes so this PR is purely additive and data-driven. * feat(server): staged-rollout gate for member-facing org MCP connections Hosted deployments can roll the desktop org-MCP experience out per org: with DEN_MCP_CONNECTIONS_GATING_ENABLED=true, scope=usable returns an empty list unless the org opted in via metadata mcpConnectionsEnabled: true — byte-identical to an org with no published connections, on every desktop version in the field (server-enforced, no client flag needed). - Gating is off by default: local dev, evals, and self-hosted keep the feature working out of the box (mirrors DEN_PLAN_GATING_ENABLED). - scope=manageable, create, and access-grant routes stay available so admins can stage connections before the flag flips. - Pilot enable: UPDATE organization SET metadata = JSON_SET( COALESCE(metadata,'{}'), '$.mcpConnectionsEnabled', CAST('true' AS JSON)) WHERE slug = '<org>'; - New-orgs-only default can later hook the same flag in org creation. * fix(server): reword connect/start comment so the route-policy parser can scan this file route-access-policy.test.ts parses route registrations with a scanner that understands strings but not comments; the apostrophe in "that's" opened a phantom string and made every subsequent route in this file unparseable (test failed with 'Unclosed route registration' on this branch while passing on dev). * chore(server): rollback tooling for the org MCP connections rollout Layer 0 — no deploy: scripts/mcp-connections-rollout.ts (den-api) flips the per-org metadata opt-in (status / enable / disable / dark), taking effect on the next desktop poll for every app version in the field. Layer 1 — code: scripts/revert-org-mcp-connections.sh builds a revert branch from the squash commits on dev (#2439, --all adds #2406), newest first, leaving the additive DB tables in place on purpose.
What
Lets an org admin connect any third-party MCP server (Notion, Linear, Stripe, or a custom URL) once, org-wide, from a real Den screen — instead of every device connecting to it separately. Once connected, the connection's tools are automatically merged into the existing
search_capabilities/execute_capabilitysurface (/mcp/agent); the harness never sees a growing per-connection tool list, only the same two tools it always had.This is the follow-up to #2405 (generic OAuth credential layer) and closes out the "search and execute as the main entry point, with the backend actually containing these same MCPs" direction.
Backend (
ee/apps/den-api)ExternalMcpConnectionTablegains org-level OAuth token columns (access/refresh token, scope, expiry, pending PKCE verifier — encrypted). UnlikeConnectedAccountTable(Google Workspace, per-member), this is deliberately org-shared, like an LLM provider key — a Notion/Linear/Stripe connection is a team-wide integration, not one person's personal grant.capability-sources/external-mcp-client.ts— a real MCP client (@modelcontextprotocol/sdk) implementingOAuthClientProvideragainst those columns. Third-party MCP servers don't have a pre-sharedclient_id, so this drives real RFC 9728 discovery + RFC 7591 dynamic client registration + PKCE — a different mechanism fromgeneric-oauth.ts(PR feat(den-api): generic OAuth credential layer for native capability providers #2405), which only fits native providers with a fixed, admin-configured OAuth app (Google Workspace). Dynamically-registered client info is stored in the existingOrgOAuthClientTable, keyed by the connection's own id.capability-sources/external-mcp-connections.ts— CRUD for the table.capability-sources/external-mcp-presets.ts— quick-add presets (Notion, Linear, Stripe, Sentry, Context7) — the same real servers/URLs the desktop app already offers, now addable org-wide from Den.routes/org/mcp-connections.ts— create/list/delete/disconnect + OAuth connect/start+callback. Mutation and OAuth routes are taggedAuthentication(already blocked from the agent-facing MCP surface); only list/presets are agent-visible (Capability Sources, read-only). The callback serves a small static HTML success page — the admin's Den tab polls status in the background (matching the desktop app's own OAuth UX) rather than needing to guess den-web's origin to redirect back to.mcp/external-capabilities.ts+mcp/agent.ts— merges each org-connected external MCP's livetools/listintosearch_capabilities(namespacedmcp:<connectionId>:<toolName>), and dispatches matchingexecute_capabilitycalls through the real SDK client instead ofinvokeMcpOperation. The rich/mcpendpoint and its ~129 individually-registered tools are untouched.Frontend (
ee/apps/den-web)New "MCP Connections" dashboard screen (quick-add preset grid + a custom name/URL/auth-type form), following this app's existing conventions (
DashboardPageTemplate,DenButton/DenInput, TanStack Query) rather than the desktop app's component library. OAuth connect opens a real browser popup and polls connection status — no redirect-back plumbing needed since the popup is self-contained.Test infra (
evals/runner)Added
EvalContext.switchToNewTab()/switchBack()— a small additive capability for flows that need to follow a realwindow.open()popup (e.g. an OAuth tab), confirm it, then return focus to the original tab. No existing flow uses it; behavior for every other flow is unchanged.How I validated this
1. Backend only, via curl/script (real stand-in OAuth+MCP server, not mocked fetch): create connection →
connect/start(real discovery + dynamic client registration) → real redirect → real PKCE-verified code exchange → encrypted token storage →search_capabilitiesfinds the real tool →execute_capabilitycalls it and gets the real result back.2. Through the real den-web UI, locally (
evals/flows/mcp-connections-cloud-oauth.flow.mjs, fraimz passing): sign in → open the new MCP Connections screen → Add Custom → a real browser popup opens and completes OAuth for real → Den's own polling (no test-only code path) shows Connected →search_capabilitiesfinds the connection's tool andexecute_capabilityreally calls it.3. Through the real den-web UI, on a real Daytona cloud sandbox — same flow, same assertions, run against a genuinely remote den-api + den-web + mock OAuth+MCP server (not localhost). Along the way this surfaced and fixed a real reverse-proxy bug:
request.url-derivedredirect_uriresolved to the sandbox's internal127.0.0.1:8788instead of the public proxy URL, so the OAuth popup landed on an unreachable internal address. Fixed viaDEN_API_PUBLIC_URL, applied to both this flow's routes and the pre-existing Google Workspace OAuth routes (PR #2405), which had the identical latent bug, never exercised behind a real proxy before now.All 7 steps pass, both locally and on Daytona:
den-web and mock reachable → sign in → open MCP Connections → add connection → real OAuth popup completes → Den's own polling shows Connected → search_capabilities/execute_capability use it for real.fraimz screenshots (both runs) show the real filled-in form, the real OAuth success page, and the connected row — the cloud run's screenshot shows the connection's real
https://<port>-<sandbox>.daytonaproxy01.net/mcpURL, evidencing it's genuinely cloud-hosted, not local.What this is NOT
No real third-party MCP server (Notion, Linear, Stripe) has been tested — only the stand-in, which speaks the identical protocol (real discovery, real dynamic client registration, real PKCE) for real. No
execute_capabilityload/performance testing (eachsearch_capabilitiescall does a livetools/listper connected external MCP, uncached — fine for this scale, a known follow-up for orgs with many connections).Test commands run