Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/server/controllers/mcpApps.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ module.exports = createMCPAppsController({
return resolveMCPAppCspLimits(appConfig?.mcpAppSandbox);
},
ensureConfigServers: (mcpConfig) => getMCPServersRegistry().ensureConfigServers(mcpConfig),
getAllServerConfigs: (userId, configServers, role) =>
getMCPServersRegistry().getAllServerConfigs(userId, configServers, role),
getServerConfig: (serverName, userId, configServers, role) =>
getMCPServersRegistry().getServerConfig(serverName, userId, configServers, role),
recoverServerConfig: (serverName, config, userId) =>
getMCPServersRegistry().recoverServerConfig(serverName, config, userId),
isAppServerConfig: (serverName, config) =>
Expand Down
10 changes: 5 additions & 5 deletions api/server/controllers/mcpApps.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('MCP Apps controller wiring', () => {
it('exports the typed controller handlers and binds server dependencies', async () => {
process.env.MCP_SANDBOX_FRAME_ANCESTORS = 'https://host.example.com';
const ensureConfigServers = jest.fn().mockResolvedValue({ srv: { type: 'sse' } });
const getAllServerConfigs = jest.fn().mockResolvedValue({ srv: { type: 'sse' } });
const getServerConfig = jest.fn().mockResolvedValue({ type: 'sse' });
const recoverServerConfig = jest.fn().mockResolvedValue({ type: 'sse' });
const isAppServerConfig = jest.fn().mockResolvedValue(true);
const resolveCachedAppServerConfig = jest.fn().mockResolvedValue({
Expand All @@ -86,7 +86,7 @@ describe('MCP Apps controller wiring', () => {
getFlowStateManager.mockReturnValue('flow-manager');
getMCPServersRegistry.mockReturnValue({
ensureConfigServers,
getAllServerConfigs,
getServerConfig,
recoverServerConfig,
isAppServerConfig,
resolveCachedAppServerConfig,
Expand Down Expand Up @@ -142,9 +142,9 @@ describe('MCP Apps controller wiring', () => {
});
expect(ensureConfigServers).toHaveBeenCalledWith({ srv: { type: 'sse' } });
await expect(
dependencies.getAllServerConfigs('user-1', { srv: { type: 'sse' } }, 'USER'),
).resolves.toEqual({ srv: { type: 'sse' } });
expect(getAllServerConfigs).toHaveBeenCalledWith('user-1', { srv: { type: 'sse' } }, 'USER');
dependencies.getServerConfig('srv', 'user-1', { srv: { type: 'sse' } }, 'USER'),
).resolves.toEqual({ type: 'sse' });
expect(getServerConfig).toHaveBeenCalledWith('srv', 'user-1', { srv: { type: 'sse' } }, 'USER');
await expect(
dependencies.recoverServerConfig('srv', { type: 'sse', inspectionFailed: true }, 'user-1'),
).resolves.toEqual({ type: 'sse' });
Expand Down
70 changes: 33 additions & 37 deletions client/public/mcp-sandbox.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

const SANDBOX_PREFIX = 'ui/notifications/sandbox-';
const ATTEST_METHOD = 'ui/notifications/sandbox-frame-attest';
const CHALLENGE_METHOD = 'ui/notifications/sandbox-frame-challenge';
const DETACH_METHOD = 'ui/notifications/sandbox-frame-detach';
const RESOURCE_READY_METHOD = 'ui/notifications/sandbox-resource-ready';
const PROXY_READY_METHOD = 'ui/notifications/sandbox-proxy-ready';
Expand All @@ -50,13 +51,11 @@
const ALLOWED_SANDBOX_TOKENS = ['allow-scripts', 'allow-forms', 'allow-pointer-lock'];

let innerFrame = null;
let innerFrameBlobUrl = null;
let innerFrameNavigated = false;
let resourceApplied = false;
let frameLoads = 0;
let readyInterval = null;
let readyRetries = 0;
let attestNonce = null;
let pendingChallenge = null;
let attestedAt = 0;
let pendingToInner = [];
let pendingToParent = [];
Expand Down Expand Up @@ -93,9 +92,7 @@
// bridge; the nonce only excludes a document that never ran our bootstrap. An app that leaks
// its own nonce to a document it navigates to gains nothing it could not already proxy itself.
function frameUsable() {
return (
!!innerFrame && !!innerFrame.contentWindow && !innerFrameNavigated && frameLoads <= 1
);
return !!innerFrame && !!innerFrame.contentWindow;
}

function attestFresh() {
Expand Down Expand Up @@ -140,10 +137,6 @@

function releaseInnerFrame() {
discardPending();
if (innerFrameBlobUrl) {
URL.revokeObjectURL(innerFrameBlobUrl);
innerFrameBlobUrl = null;
}
if (innerFrame) {
innerFrame.remove();
innerFrame = null;
Expand Down Expand Up @@ -201,7 +194,12 @@

const attested = !!attestNonce && !!msg.params && msg.params.nonce === attestNonce;
if (msg.method === ATTEST_METHOD) {
if (attested && frameUsable()) {
if (
attested &&
frameUsable() &&
(pendingChallenge === null || msg.params.challenge === pendingChallenge)
) {
pendingChallenge = null;
attestedAt = Date.now();
flushPending();
}
Expand Down Expand Up @@ -232,6 +230,11 @@
'<script>(function(){var N=' +
JSON.stringify(nonce) +
';var post=function(m){parent.postMessage({jsonrpc:"2.0",method:m,params:{nonce:N}},"*")};' +
'addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.method===' +
JSON.stringify(CHALLENGE_METHOD) +
'){parent.postMessage({jsonrpc:"2.0",method:' +
JSON.stringify(ATTEST_METHOD) +
',params:{nonce:N,challenge:e.data.params.challenge}},"*")}});' +
'post(' +
JSON.stringify(ATTEST_METHOD) +
');setInterval(function(){post(' +
Expand Down Expand Up @@ -262,7 +265,7 @@
return;
}
// One resource per sandbox document. Replacing the app in place would leave the previous
// document's pending messages, blob URL and load events racing the new one.
// document's pending messages and load events racing the new one.
if (resourceApplied || !trustedOrigin) {
return;
}
Expand All @@ -274,19 +277,6 @@
innerFrame = document.createElement('iframe');
const frame = innerFrame;

innerFrame.addEventListener('load', () => {
frameLoads += 1;
// The first load is our blob document. A stale load from an already-released frame must
// not revoke the live blob URL, and only a second load of the live frame means the app
// navigated away from our document.
if (frameLoads <= 1 || frame !== innerFrame) {
return;
}
innerFrameNavigated = true;
attestedAt = 0;
releaseInnerFrame();
});

// The inner View remains opaque even though its outer proxy has a dedicated origin. This
// preserves per-View storage isolation and the heartbeat's navigation freshness guarantee.
// Grant only from an allowlist so a new spec token cannot arrive as a silent capability.
Expand All @@ -312,22 +302,28 @@

// The bootstrap sits ahead of the app's own <meta charset>, so declare the encoding here
// rather than let the parser guess from bytes our prefix now precedes.
const blob = new Blob([injectIntoHead(html, buildViewInjection(attestNonce))], {
type: 'text/html;charset=utf-8',
});
innerFrameBlobUrl = URL.createObjectURL(blob);
innerFrame.src = innerFrameBlobUrl;
// srcdoc gives the opaque View a stable document URL. A blob URL makes some otherwise
// conforming bundled apps fail during startup when their dependencies resolve the current
// location (Blob URL pathnames are not hierarchical URLs).
innerFrame.srcdoc = injectIntoHead(html, buildViewInjection(attestNonce));
innerFrame.style.cssText = 'width:100%;height:100%;border:none;display:block';
// WindowProxy identity survives navigation. Every document load invalidates liveness;
// only the injected bootstrap in the loaded realm can answer this fresh challenge.
// Unlike load counting, this also tolerates an initial about:blank or duplicate load.
frame.addEventListener('load', () => {
if (innerFrame !== frame) return;
attestedAt = 0;
discardPending();
pendingChallenge = mintNonce();
frame.contentWindow.postMessage(
{ jsonrpc: '2.0', method: CHALLENGE_METHOD, params: { challenge: pendingChallenge } },
'*',
);
});

document.body.appendChild(innerFrame);
}

window.addEventListener('pagehide', () => {
if (innerFrameBlobUrl) {
URL.revokeObjectURL(innerFrameBlobUrl);
}
});

function findTagEnd(html, at) {
let quote = '';
for (let i = at + 1; i < html.length; i += 1) {
Expand Down Expand Up @@ -414,7 +410,7 @@
}

function injectIntoHead(html, injection) {
// The bootstrap must run before any app script, and the app's bytes must reach the blob
// The bootstrap must run before any app script, and the app's bytes must reach the View
// verbatim: inject, never rewrap. Prefixing a document that opens with a doctype would push
// the doctype down and flip the app into quirks mode.
const at = findDocumentInjectionPoint(html);
Expand Down
Loading
Loading