From 1a70dce24b2ceff8dfe37ca107e7b3035e3ba5ac Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:37:39 -0700 Subject: [PATCH] fix(mcp): advertise apps per-request for user connections and tear down navigated sandbox frames User connections now advertise the io.modelcontextprotocol/ui capability using the per-request appsEnabled resolved from resolveAllowlists rather than the static base flag, so a tenant/role/user override of mcpSettings.apps is honored at capability negotiation. The sandbox proxy treats inner-frame navigation as a teardown signal: after the initial blob load, any further load means the allow-scripts app navigated its own frame, so the proxy marks it navigated, revokes the blob, removes the frame, and gates both forwarding paths on that flag. This stops proxied host responses from reaching a navigated page and stops a navigated page from relaying messages to the host. --- client/public/mcp-sandbox.html | 24 +++++++++++++++++-- packages/api/src/mcp/UserConnectionManager.ts | 4 ++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/client/public/mcp-sandbox.html b/client/public/mcp-sandbox.html index ab384b1639..27e7bb24bc 100644 --- a/client/public/mcp-sandbox.html +++ b/client/public/mcp-sandbox.html @@ -31,6 +31,7 @@ let innerFrame = null; let innerFrameBlobUrl = null; + let innerFrameNavigated = false; let readyInterval = null; const SANDBOX_PREFIX = 'ui/notifications/sandbox-'; @@ -84,13 +85,13 @@ createInnerFrame(msg.params); return; } - if (innerFrame && innerFrame.contentWindow) { + if (innerFrame && innerFrame.contentWindow && !innerFrameNavigated) { innerFrame.contentWindow.postMessage(msg, '*'); } return; } - if (innerFrame && event.source === innerFrame.contentWindow) { + if (innerFrame && !innerFrameNavigated && event.source === innerFrame.contentWindow) { if (msg.method && msg.method.startsWith(SANDBOX_PREFIX)) { return; } @@ -111,6 +112,25 @@ innerFrame = document.createElement('iframe'); + // The first load is the blob document we created. A `allow-scripts` app can navigate its own + // frame; any further load means it left our document, so stop proxying and tear it down + // rather than forward host responses (proxied MCP data) to the navigated page. + innerFrameNavigated = false; + let frameLoads = 0; + innerFrame.addEventListener('load', () => { + frameLoads += 1; + if (frameLoads <= 1) { + return; + } + innerFrameNavigated = true; + if (innerFrameBlobUrl) { + URL.revokeObjectURL(innerFrameBlobUrl); + innerFrameBlobUrl = null; + } + innerFrame.remove(); + innerFrame = null; + }); + // Strip allow-same-origin from the inner frame regardless of what the host requested. // Full cross-origin isolation requires the sandbox proxy to be served from a different // origin than the host; stripping allow-same-origin provides partial mitigation when diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index 785dfb6755..e4a8ec0173 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -453,7 +453,7 @@ export abstract class UserConnectionManager { graphTokenResolver, }); const registry = MCPServersRegistry.getInstance(); - const { allowedDomains, allowedAddresses, useSSRFProtection } = + const { allowedDomains, allowedAddresses, useSSRFProtection, appsEnabled } = await registry.resolveAllowlists({ userId: user?.id, role: user?.role }); await this.assertResolvedRuntimeConfigAllowed({ config: runtimeConfig, @@ -472,7 +472,7 @@ export abstract class UserConnectionManager { useSSRFProtection, allowedDomains, allowedAddresses, - enableApps: registry.getAppsEnabled(), + enableApps: appsEnabled, ephemeralConnection, };