Skip to content

feat(den): External MCP Connections — org-level, search/execute-routed, real OAuth#2406

Merged
benjaminshafii merged 8 commits into
devfrom
feat/external-mcp-connections
Jul 3, 2026
Merged

feat(den): External MCP Connections — org-level, search/execute-routed, real OAuth#2406
benjaminshafii merged 8 commits into
devfrom
feat/external-mcp-connections

Conversation

@benjaminshafii

@benjaminshafii benjaminshafii commented Jul 1, 2026

Copy link
Copy Markdown
Member

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_capability surface (/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)

  • 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 — 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) 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 — a different mechanism from generic-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 existing OrgOAuthClientTable, 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 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 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 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.

Test infra (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), 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_capabilities finds the real tool → execute_capability calls 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_capabilities finds the connection's tool and execute_capability really 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-derived redirect_uri resolved to the sandbox's internal 127.0.0.1:8788 instead of the public proxy URL, so the OAuth popup landed on an unreachable internal address. Fixed via DEN_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/mcp URL, 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_capability load/performance testing (each search_capabilities call does a live tools/list per connected external MCP, uncached — fine for this scale, a known follow-up for orgs with many connections).

Test commands run

cd ee/apps/den-api && npx tsc -p tsconfig.json --noEmit   # clean
cd ee/apps/den-web && npx tsc --noEmit                     # clean
pnpm --filter @openwork-ee/den-api build                   # clean
pnpm fraimz --flow mcp-connections-cloud-oauth --cdp-url <local Chrome>   # PASSED
pnpm fraimz --flow mcp-connections-cloud-oauth --cdp-url <local Chrome, pointed at Daytona sandbox URLs>   # PASSED

Review in cubic

…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.
@vercel

vercel Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openwork-app Ready Ready Preview, Comment Jul 3, 2026 9:14pm
openwork-den Ready Ready Preview, Comment Jul 3, 2026 9:14pm
openwork-den-worker-proxy Ready Ready Preview, Comment Jul 3, 2026 9:14pm
openwork-landing Ready Ready Preview, Comment, Open in v0 Jul 3, 2026 9:14pm

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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" }) }],
}
}

Comment thread evals/runner/context.mjs
if (candidate) {
const newClient = await connect(debuggerUrlFor(this.cdpBaseUrl, candidate));
this.tabStack.push(this.client);
this.client = newClient;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread evals/runner/context.mjs
if (!this.cdpBaseUrl) {
throw new EvalError("switchToNewTab requires cdpBaseUrl on the context.");
}
const before = await listTargets(this.cdpBaseUrl);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

View Feedback

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread ee/apps/den-api/src/mcp/agent.ts Outdated
const externalMatches = await searchExternalCapabilities({
organizationId: principal.organizationId,
query,
redirectUriBase: new URL(c.req.raw.url).origin,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
@benjaminshafii

Copy link
Copy Markdown
Member Author

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 51435c2:

Who can USE a connection — new ExternalMcpConnectionAccessGrantTable (member / team / org-wide, explicit grants only, never implicit). The Add dialog asks "Who can use this?" (Everyone / Specific teams / Specific people); search_capabilities and execute_capability both enforce grants against the calling member's identity (userId → membership + teams), not just search-side filtering. scope=manageable listing is admin-only.

WHO the call runs as — new credentialMode per connection:

  • shared: one org credential (service-account style; connect is admin-only).
  • per_member: admin publishes; each employee authorizes their own account from a new member-visible Your Connections page (real OAuth popup, tokens stored per-membership in ConnectedAccountTable from feat(den-api): generic OAuth credential layer for native capability providers #2405, encrypted). The agent then acts as that person, preserving the provider's own ACLs/audit trail. Granted-but-not-connected surfaces as a needs_connection search hint pointing the human at Your Connections, instead of silent absence.

Also fixed: connection deletion now cleans up grants, per-member tokens, and the dynamically-registered OAuth client (previously orphaned).

Proof

New fraimz flow mcp-connections-member-scopedPASSED, two real users in a real browser:

  1. Admin (Alex) publishes a per-member connection through the real dialog (frame 01–02: credential-mode + access pickers, "Per-member accounts" + "Everyone in the org" badges).
  2. Member (Jordan — bootstrapped through the real invitation flow: invite → sign-up → accept) signs in, sees only what he was granted (the team-restricted connection is asserted absent), clicks Connect (frame 03).
  3. A real OAuth popup completes discovery + dynamic client registration + PKCE for his account (frame 04); Den's own polling flips the row to "Connected as you" (frame 05); the encrypted token lands on his membership row while the admin remains unconnected.
  4. Agent surface as Jordan: search finds the real tool, execute echoes exactly using his credential; execute against the team-restricted connection returns forbidden; the admin's own search returns the needs_connection hint.

The pre-existing mcp-connections-cloud-oauth flow still passes back-to-back with the new one.

Known deferrals (unchanged from the PR description): tool-level permissioning within a connection, audit logging, tools/list caching, and the indexing/knowledge layer.

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.
@benjaminshafii

Copy link
Copy Markdown
Member Author

The complete end-to-end POV, now proven: admin publish → member's desktop chat result

Review asked for the full arc "from the moment the admin does that to the moment it works in the app." New flow mcp-connections-desktop-e2e (PASSED, commit eaf0a64) covers exactly that, ending inside the member's real desktop app:

  1. Admin publishes a per-member connection in Den (real API; the browser-driven dialog leg is the other flow).
  2. Member connects their own account (real OAuth round trips — discovery, dynamic client registration, PKCE, token exchange).
  3. Member signs the desktop app into OpenWork Cloud with a real handoff grant; completes real onboarding (org picker → workspace folder).
  4. OpenWork Cloud Control auto-configures with a token minted for the member (asserted + frame).
  5. In a real chat turn the agent calls openwork-cloud_search_capabilities → finds mcp:<connectionId>:mock_echoopenwork-cloud_execute_capabilityDen executes with the member's own credential → the exact text renders in the chat transcript (frame shows the full tool-call sequence in the UI).
  6. The external MCP server's own request log is asserted to contain a fresh POST /mcp timestamped during the chat — the result really traveled desktop → Den → external server.

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 desktop-bootstrap.json (the designed mechanism) — getDenMcpUrl() derives from the bootstrap config, so localStorage overrides alone leave the cloud MCP pointing at production. The flow does it the designed way via the desktop bridge.

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.
@benjaminshafii

Copy link
Copy Markdown
Member Author

Added: force-sync via the Refresh button (65cd8ce)

The Extensions page Refresh button now also force-syncs the OpenWork Cloud Control connection — re-mints the member's token and rewrites the MCP config immediately, bypassing the freshness marker. Previously Refresh only reloaded local statuses/plugins; the cloud sync only ran on settings mount and early-returned whenever the marker looked fresh, so recovering from Den-side drift (revoked token, org changes) meant sign-out/sign-in or waiting for expiry.

No new UI — the button users already reach for when things look stale now does the thorough thing. Desktop-side only, additive (syncCloudControlMcp({ force })), old behavior unchanged everywhere else the sync runs.

Proof (mcp-cloud-force-sync fraimz, PASSED): baseline taken after the on-mount auto-sync fully settles (marker maximally fresh — provably inert without force), then one Refresh click; the sync marker's minted-token expiry advanced from 23:11:22 to 23:12:56 — a fresh mint on demand, attributable to the click alone.

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.
@benjaminshafii

Copy link
Copy Markdown
Member Author

Final addition before merge-ready: SSRF guard (7593a38)

Connection URLs are fetched by Den's servers, and any signed-up user can create an org and register one — so without a guard, hosted Den could be used as a proxy into its own network (localhost ports, private ranges, and the cloud metadata endpoint 169.254.169.254).

  • Resolve-then-check, not string matching (DNS rebinding: a public-looking domain can point at 127.0.0.1) — hostname is resolved and rejected if any address is private/reserved.
  • Checked at create time (clean 400) and per outbound request via the MCP SDK transport's fetch option (the SDK follows discovery to other hosts; DNS can change after creation).
  • Deployment-aware: on by default for hosted prod; DEN_ALLOW_PRIVATE_MCP_URLS=1 for self-hosted deployments whose MCP servers legitimately live on private networks (so disabling in an emergency is a config flip, not a revert); dev/evals exempt via OPENWORK_DEV_MODE=1.

Proof: 38-case unit test (range matrix incl. IPv6 + mapped-IPv4, fail-closed); live negative test on a prod-mode instance (metadata endpoint and localhost-via-DNS both 400 with clear messages); mcp-connections-member-scoped fraimz re-run PASSED with the guard code in place.

With this, the pre-merge risk list from review is addressed. Remaining known deferrals (unchanged, post-merge iterations): tool-level permissions, audit logging, tools/list caching, real third-party provider validation (Notion/Linear/Stripe presets exercise the identical protocol but haven't been clicked against the real services), optional grants backfill for intermediate-commit dev DBs.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 2, 2026

Copy link
Copy Markdown

Deploying openwork with  Cloudflare Pages  Cloudflare Pages

Latest commit: 203af6a
Status: ✅  Deploy successful!
Preview URL: https://f4b6f546.openwork.pages.dev
Branch Preview URL: https://feat-external-mcp-connection.openwork.pages.dev

View logs

benjaminshafii added a commit that referenced this pull request Jul 3, 2026
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.
@benjaminshafii benjaminshafii merged commit 69c3469 into dev Jul 3, 2026
18 of 20 checks passed
@benjaminshafii benjaminshafii deleted the feat/external-mcp-connections branch July 3, 2026 21:16
benjaminshafii added a commit that referenced this pull request Jul 3, 2026
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.
benjaminshafii added a commit that referenced this pull request Jul 3, 2026
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.
benjaminshafii added a commit that referenced this pull request Jul 3, 2026
…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.
benjaminshafii added a commit that referenced this pull request Jul 3, 2026
#2453)

The desktop PR landed as #2451 (successor to #2439, which GitHub
auto-closed when its stacked base branch was deleted). Dry-run now
resolves both squash commits on dev:
  #2451 -> 726304c (desktop + rollout gate + tooling)
  #2406 -> 69c3469 (server External MCP Connections)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant