The inline result carried only code + artifactId; the deep link existed only on the no-apps fallback path. Clients can lose a rendered widget in ways the server never sees (a reopened transcript that skips the ui:// resource re-read shows raw JSON), and then the URL in the tool result is the model's only way to point the user back at the artifact. The text content mentions it too, so the model can relay it without parsing.
Unified · Split
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Artifact tool results now include the web deep link beside the inline widget payload, not just on the no-apps fallback. Clients can lose a rendered widget in ways the server never sees — a reopened transcript that skips the resource re-read shows raw JSON — and the URL in the result is the model's way to point the user back at the artifact. | |
| @@ -353,7 +353,11 @@ describe("MCP host — artifact tool visibility", () => { · 352 unmodified lines | ||
| 353 | 353 | name: "create-artifact", |
| 354 | 354 | arguments: { code: COUNTER_CODE, title: "Active users dashboard" }, |
| 355 | 355 | })) as { structuredContent?: Record<string, unknown> }; |
| 356 | − expect(created.structuredContent).toEqual({ code: COUNTER_CODE, artifactId: "art_1" }); | |
| 356 | + expect(created.structuredContent).toEqual({ | |
| 357 | + code: COUNTER_CODE, | |
| 358 | + artifactId: "art_1", | |
| 359 | + url: "https://executor.test/artifacts/art_1", | |
| 360 | + }); | |
| 357 | 361 | expect(created.structuredContent).not.toHaveProperty("status", "fallback_url"); |
| 358 | 362 | |
| 359 | 363 | // The widget is useless if it cannot call back in, so visibility has to |
| @@ -613,6 +617,7 @@ describe("MCP host — create-artifact", () => { · 253 unmodified lines | ||
| 613 | 617 | expect(structuredOf(result)).toEqual({ |
| 614 | 618 | code: COUNTER_CODE, |
| 615 | 619 | artifactId: "art_1", |
| 620 | + url: "https://executor.test/artifacts/art_1", | |
| 616 | 621 | }); |
| 617 | 622 | expect(result.isError).toBeFalsy(); |
| 618 | 623 | expect(store.calls).toEqual([ |
| @@ -632,6 +637,35 @@ describe("MCP host — create-artifact", () => { · 13 unmodified lines | ||
| 632 | 637 | ); |
| 633 | 638 | }); |
| 634 | 639 | |
| 640 | + it("includes the deep link beside the inline widget payload when the host knows its URL", async () => { | |
| 641 | + // The widget is not the only consumer of an inline result: clients lose | |
| 642 | + // rendered widgets in ways the server never sees (a reopened transcript | |
| 643 | + // that skips the ui:// re-read shows raw JSON), and then the URL in the | |
| 644 | + // result is the model's only way to point the user back at the artifact. | |
| 645 | + const store = makeArtifactStore(); | |
| 646 | + await withClient( | |
| 647 | + makeStubEngine({}), | |
| 648 | + APPS_CAPS, | |
| 649 | + async (client) => { | |
| 650 | + const result = await client.callTool({ | |
| 651 | + name: "create-artifact", | |
| 652 | + arguments: { code: COUNTER_CODE, title: "Active users dashboard" }, | |
| 653 | + }); | |
| 654 | + expect(result.isError).toBeFalsy(); | |
| 655 | + expect(structuredOf(result)).toEqual({ | |
| 656 | + code: COUNTER_CODE, | |
| 657 | + artifactId: "art_1", | |
| 658 | + url: "https://executor.test/artifacts/art_1", | |
| 659 | + }); | |
| 660 | + expect(textOf(result)).toContain("https://executor.test/artifacts/art_1"); | |
| 661 | + }, | |
| 662 | + { | |
| 663 | + artifacts: store.port, | |
| 664 | + artifactUrl: artifactUrlFor("https://executor.test"), | |
| 665 | + }, | |
| 666 | + ); | |
| 667 | + }); | |
| 668 | + | |
| 635 | 669 | it("returns a deep link and still persists when the client cannot render apps", async () => { |
| 636 | 670 | const store = makeArtifactStore(); |
| 637 | 671 | await withClient( |
| @@ -905,14 +905,27 @@ const renderedInAppResult = (input: { · 904 unmodified lines | ||
| 905 | 905 | readonly code: string; |
| 906 | 906 | readonly artifactId: string; |
| 907 | 907 | readonly title: string; |
| 908 | + readonly url?: string | undefined; | |
| 908 | 909 | }): McpToolResult => ({ |
| 909 | 910 | content: [ |
| 910 | 911 | { |
| 911 | 912 | type: "text", |
| 912 | − text: `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`, | |
| 913 | + text: [ | |
| 914 | + `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`, | |
| 915 | + // The link rides along even though the widget rendered: clients lose | |
| 916 | + // rendered widgets in ways the server never sees (a transcript | |
| 917 | + // reopened without re-reading the ui:// resource shows raw JSON), and | |
| 918 | + // when that happens this URL in the conversation is the only path | |
| 919 | + // back to the artifact the model can offer. | |
| 920 | + ...(input.url ? [`It also stays available at ${input.url}`] : []), | |
| 921 | + ].join("\n"), | |
| 913 | 922 | }, |
| 914 | 923 | ], |
| 915 | − structuredContent: { code: input.code, artifactId: input.artifactId }, | |
| 924 | + structuredContent: { | |
| 925 | + code: input.code, | |
| 926 | + artifactId: input.artifactId, | |
| 927 | + ...(input.url ? { url: input.url } : {}), | |
| 928 | + }, | |
| 916 | 929 | }); |
| 917 | 930 | |
| 918 | 931 | const renderedAsLinkResult = (input: { |
| @@ -1629,8 +1642,8 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 710 unmodified lines | ||
| 1629 | 1642 | readonly artifactId: string; |
| 1630 | 1643 | readonly title: string; |
| 1631 | 1644 | }): McpToolResult => { |
| 1632 | − if (appsSupported()) return renderedInAppResult(input); | |
| 1633 | 1645 | const url = config.artifactUrl?.(input.artifactId); |
| 1646 | + if (appsSupported()) return renderedInAppResult({ ...input, url }); | |
| 1634 | 1647 | return url |
| 1635 | 1648 | ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title }) |
| 1636 | 1649 | : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title }); |