History

Resolve MCP-Apps support at render time, not from the initialize hook

The persisted-capability seed alone did not fix the production symptom. On cold restore the agents runtime replays the persisted initialize REQUEST, which does re-establish the server's client capabilities — but it never replays the notifications/initialized NOTIFICATION, and oninitialized (the only hook that re-runs syncToolAvailability) fires solely on that notification. A restored session therefore held full apps capabilities while the cached flag still read its seeded value, and the replay is dispatched un-awaited, so a tool call can also land ahead of it. deliverArtifact now reads the live server's capabilities per render and reconciles the app-only tools when they disagree, so both orderings answer identically. The seeded value remains the fallback for the window before any capabilities exist. Also guard the persist path: a reading taken with no capabilities present must never be written back, or one such replay would make the deep-link downgrade durable for every future restore of the session.

Rhys Sullivan committed 3 months ago

Commit 08cc11b1 parent ed7bf082 files · +148 −18Browse files

Unified · Split

2 files
  1. packages/hosts/mcp/src/artifacts-tools.test.ts +75 −0
  2. packages/hosts/mcp/src/tool-server.ts +73 −18
packages/hosts/mcp/src/artifacts-tools.test.ts+75−0
@@ -292,6 +292,81 @@ describe("MCP host — artifact tool visibility", () => { · 291 unmodified lines
292 await serverTransport.close();292 await serverTransport.close();
293 });293 });
294 294
295 // The restore path the `agents` runtime actually takes, and the one that
296 // reached production. On cold restore it replays the PERSISTED `initialize`
297 // REQUEST (`reinitializeServer`), which does re-establish the server's client
298 // capabilities — but it never replays the `notifications/initialized`
299 // NOTIFICATION, and `oninitialized` (the only hook that re-runs
300 // `syncToolAvailability`) fires solely on that notification. So the rebuilt
301 // server holds full apps capabilities while nothing re-reads them, and the
302 // replay is dispatched un-awaited, so a tool call can also land ahead of it.
303 //
304 // Both orderings are covered here: capabilities present but no hook, and a
305 // tool call dispatched before the replay arrives at all.
306 it("renders inline on a restore that replays initialize without the initialized notification", async () => {
307 const store = makeArtifactStore();
308 const mcpServer = await Effect.runPromise(
309 createExecutorMcpServer({
310 engine: makeStubEngine({}),
311 artifacts: store.port,
312 artifactsEnabled: true,
313 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
314 // Nothing persisted: the capability must come from the replay alone, so
315 // a passing assertion cannot be coming from the seed.
316 restoredAppsEnabled: false,
317 artifactUrl: artifactUrlFor("https://executor.test"),
318 }),
319 );
320 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
321 await mcpServer.connect(serverTransport);
322
323 const responses = new Map<string | number, Record<string, unknown>>();
324 clientTransport.onmessage = (message) => {
325 const envelope = message as { id?: string | number; result?: Record<string, unknown> };
326 if (envelope.id !== undefined && envelope.result) responses.set(envelope.id, envelope.result);
327 };
328 await clientTransport.start();
329
330 // Exactly what `agents/mcp` replays: the initialize request under its
331 // restore sentinel id, and no `notifications/initialized` after it.
332 await clientTransport.send({
333 jsonrpc: "2.0",
334 id: "__worker_transport_restore__",
335 method: "initialize",
336 params: {
337 protocolVersion: "2025-06-18",
338 capabilities: APPS_CAPS,
339 clientInfo: { name: "ClaudeAI", version: "1.0.0" },
340 },
341 } as never);
342 await vi.waitFor(() =>
343 expect(mcpServer.server.getClientCapabilities()).toMatchObject(APPS_CAPS),
344 );
345
346 const call = async (id: number, method: string, params: Record<string, unknown>) => {
347 await clientTransport.send({ jsonrpc: "2.0", id, method, params });
348 await vi.waitFor(() => expect(responses.has(id)).toBe(true));
349 return responses.get(id) ?? {};
350 };
351
352 const created = (await call(1, "tools/call", {
353 name: "create-artifact",
354 arguments: { code: COUNTER_CODE, title: "Active users dashboard" },
355 })) as { structuredContent?: Record<string, unknown> };
356 expect(created.structuredContent).toEqual({ code: COUNTER_CODE, artifactId: "art_1" });
357 expect(created.structuredContent).not.toHaveProperty("status", "fallback_url");
358
359 // The widget is useless if it cannot call back in, so visibility has to
360 // reconcile on the same restore.
361 const listed = (await call(2, "tools/list", {})) as { tools: Array<{ name: string }> };
362 const names = listed.tools.map((tool) => tool.name);
363 expect(names).toContain("execute-action");
364 expect(names).toContain("execute-action-resume");
365
366 await clientTransport.close();
367 await serverTransport.close();
368 });
369
295 it("still falls back to a deep link when the restored session had no apps support", async () => {370 it("still falls back to a deep link when the restored session had no apps support", async () => {
296 const store = makeArtifactStore();371 const store = makeArtifactStore();
297 await withClient(372 await withClient(
packages/hosts/mcp/src/tool-server.ts+73−18
@@ -1525,13 +1525,35 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1524 unmodified lines
1525 1525
1526 // Set from the client's advertised capabilities at `initialize`. Read by1526 // Set from the client's advertised capabilities at `initialize`. Read by
1527 // the render handlers to choose inline widget vs. deep link. Seeded from1527 // the render handlers to choose inline widget vs. deep link. Seeded from
1528 // the host's persisted value so a cold-restored session — rebuilt with no1528 // the host's persisted value so a cold-restored session keeps rendering
1529 // `initialize` to replay — keeps rendering inline for a client that had1529 // inline for a client that had already negotiated apps support.
1530 // already negotiated apps support.1530 //
1531 // This is a cache, not the source of truth: `appsSupported()` below reads
1532 // the live server on every render, because a cold restore re-establishes
1533 // capabilities without ever running the hook that maintains this variable.
1531 let appsEnabled = config.restoredAppsEnabled ?? false;1534 let appsEnabled = config.restoredAppsEnabled ?? false;
1532 let executeActionTool: { enable: () => void; disable: () => void } | undefined;1535 let executeActionTool: { enable: () => void; disable: () => void } | undefined;
1533 let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined;1536 let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined;
1534 1537
1538 /**
1539 * Move the cached flag and the app-only tools together.
1540 *
1541 * `execute-action` is only callable from inside a rendered app, so a client
1542 * that can't render one should never see it. `create-artifact`,
1543 * `list-artifacts` and `show-artifact` stay visible regardless: they still
1544 * persist, and still return something useful (a deep link).
1545 */
1546 const applyAppsEnabled = (next: boolean): void => {
1547 appsEnabled = next;
1548 if (next) {
1549 executeActionTool?.enable();
1550 executeActionResumeTool?.enable();
1551 } else {
1552 executeActionTool?.disable();
1553 executeActionResumeTool?.disable();
1554 }
1555 };
1556
1535 // Best-effort usage observation; a failing observer never affects the tool.1557 // Best-effort usage observation; a failing observer never affects the tool.
1536 const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect<void> =>1558 const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect<void> =>
1537 config.onArtifactUsage1559 config.onArtifactUsage
@@ -1558,19 +1580,56 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 20 unmodified lines
1558 preview: input.preview ?? null,1580 preview: input.preview ?? null,
1559 });1581 });
1560 yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated");1582 yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated");
1583 // Resolve once and report the value actually used, so the span can
1584 // never disagree with what the client received.
1585 const delivered = deliverArtifact({
1586 code: saved.code,
1587 artifactId: saved.id,
1588 title: saved.title,
1589 });
1561 yield* Effect.annotateCurrentSpan({1590 yield* Effect.annotateCurrentSpan({
1562 "mcp.artifact.id": saved.id,1591 "mcp.artifact.id": saved.id,
1563 "mcp.artifact.apps_enabled": appsEnabled,1592 "mcp.artifact.apps_enabled": appsEnabled,
1564 });1593 });
1565 return deliverArtifact({ code: saved.code, artifactId: saved.id, title: saved.title });1594 return delivered;
1566 });1595 });
1567 1596
1597 /**
1598 * Whether the client can render an app, resolved at render time.
1599 *
1600 * `appsEnabled` alone is not enough. On a cold restore the host replays the
1601 * persisted `initialize` *request* — which does set the server's client
1602 * capabilities — but never the `notifications/initialized` notification,
1603 * and `oninitialized` (the only hook that re-runs `syncToolAvailability`)
1604 * fires solely on that notification. So a restored session can hold full
1605 * apps capabilities while `appsEnabled` still reads its seeded value, and
1606 * the replay is dispatched un-awaited, so a tool call can land before it.
1607 *
1608 * Reading the live server here makes both orderings produce the same
1609 * answer, and keeps the seeded value as the fallback for the window before
1610 * any capabilities exist.
1611 */
1612 const appsSupported = (): boolean => {
1613 const live = server.server.getClientCapabilities();
1614 if (!live) return appsEnabled;
1615 const uiCapability = getUiCapability(
1616 live as ClientCapabilities & { extensions?: Record<string, unknown> },
1617 );
1618 const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
1619 // Reconcile the tools too: a restore that re-established capabilities
1620 // without firing `oninitialized` would otherwise render inline while
1621 // `execute-action` — the tool that rendered app calls back into — stayed
1622 // hidden, leaving the widget unable to do anything.
1623 if (supported !== appsEnabled) applyAppsEnabled(supported);
1624 return supported;
1625 };
1626
1568 const deliverArtifact = (input: {1627 const deliverArtifact = (input: {
1569 readonly code: string;1628 readonly code: string;
1570 readonly artifactId: string;1629 readonly artifactId: string;
1571 readonly title: string;1630 readonly title: string;
1572 }): McpToolResult => {1631 }): McpToolResult => {
1573 if (appsEnabled) return renderedInAppResult(input);1632 if (appsSupported()) return renderedInAppResult(input);
1574 const url = config.artifactUrl?.(input.artifactId);1633 const url = config.artifactUrl?.(input.artifactId);
1575 return url1634 return url
1576 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })1635 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })
@@ -1936,6 +1995,11 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 359 unmodified lines
1936 // answered from whatever is registered at that moment — so app-only tool1995 // answered from whatever is registered at that moment — so app-only tool
1937 // visibility has to be re-synced from the `oninitialized` hook rather than1996 // visibility has to be re-synced from the `oninitialized` hook rather than
1938 // decided at construction.1997 // decided at construction.
1998 //
1999 // This hook covers live clients only. It does NOT run on a cold restore:
2000 // the host replays the persisted `initialize` request, but `oninitialized`
2001 // fires on the `notifications/initialized` notification, which is never
2002 // persisted. `appsSupported()` is what makes the restored case correct.
1939 const syncToolAvailability = () => {2003 const syncToolAvailability = () => {
1940 const clientCapabilities = server.server.getClientCapabilities();2004 const clientCapabilities = server.server.getClientCapabilities();
1941 const uiCapability = getUiCapability(2005 const uiCapability = getUiCapability(
@@ -1954,10 +2018,13 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 12 unmodified lines
1954 ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE))2018 ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE))
1955 : appsEnabled;2019 : appsEnabled;
1956 const changed = negotiated !== appsEnabled;2020 const changed = negotiated !== appsEnabled;
1957 appsEnabled = negotiated;2021 applyAppsEnabled(negotiated);
1958 2022
1959 // Persist only a real negotiation that moved the value, so the next cold2023 // Persist only a real negotiation that moved the value, so the next cold
1960 // restore seeds itself. Best-effort: the session must not fail on it.2024 // restore seeds itself. Best-effort: the session must not fail on it.
2025 // The `clientCapabilities` guard matters beyond skipping a no-op write:
2026 // persisting an absent-capability reading would make a downgrade durable
2027 // for every future restore of the session.
1961 const onAppsEnabledChange = config.onAppsEnabledChange;2028 const onAppsEnabledChange = config.onAppsEnabledChange;
1962 if (clientCapabilities && changed && onAppsEnabledChange) {2029 if (clientCapabilities && changed && onAppsEnabledChange) {
1963 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session2030 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session
@@ -1966,18 +2033,6 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 2 unmodified lines
1966 );2033 );
1967 }2034 }
1968 2035
1969 // `execute-action` is only callable from inside a rendered app, so a
1970 // client that can't render one should never see it. `create-artifact`,
1971 // `list-artifacts` and `show-artifact` stay visible regardless: they
1972 // still persist, and still return something useful (a deep link).
1973 if (appsEnabled) {
1974 executeActionTool?.enable();
1975 executeActionResumeTool?.enable();
1976 } else {
1977 executeActionTool?.disable();
1978 executeActionResumeTool?.disable();
1979 }
1980
1981 console.error(2036 console.error(
1982 "[executor] MCP session mode",2037 "[executor] MCP session mode",
1983 JSON.stringify({2038 JSON.stringify({