History

Include the artifact deep link in the inline widget result (#1505)

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.

Rhys Sullivan committed 3 months ago

Commit 9bd4a5b1 parent 0e8b65a3 files · +56 −4Browse files

Unified · Split

3 files
  1. .changeset/olive-poets-wave.md +5 −0
  2. packages/hosts/mcp/src/artifacts-tools.test.ts +35 −1
  3. packages/hosts/mcp/src/tool-server.ts +16 −3
.changeset/olive-poets-wave.md+5−0
@@ -0,0 +1,5 @@
1---
2"executor": patch
3---
4
5Artifact 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.
packages/hosts/mcp/src/artifacts-tools.test.ts+35−1
@@ -353,7 +353,11 @@ describe("MCP host — artifact tool visibility", () => { · 352 unmodified lines
353 name: "create-artifact",353 name: "create-artifact",
354 arguments: { code: COUNTER_CODE, title: "Active users dashboard" },354 arguments: { code: COUNTER_CODE, title: "Active users dashboard" },
355 })) as { structuredContent?: Record<string, unknown> };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 expect(created.structuredContent).not.toHaveProperty("status", "fallback_url");361 expect(created.structuredContent).not.toHaveProperty("status", "fallback_url");
358 362
359 // The widget is useless if it cannot call back in, so visibility has to363 // 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 expect(structuredOf(result)).toEqual({617 expect(structuredOf(result)).toEqual({
614 code: COUNTER_CODE,618 code: COUNTER_CODE,
615 artifactId: "art_1",619 artifactId: "art_1",
620 url: "https://executor.test/artifacts/art_1",
616 });621 });
617 expect(result.isError).toBeFalsy();622 expect(result.isError).toBeFalsy();
618 expect(store.calls).toEqual([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 it("returns a deep link and still persists when the client cannot render apps", async () => {669 it("returns a deep link and still persists when the client cannot render apps", async () => {
636 const store = makeArtifactStore();670 const store = makeArtifactStore();
637 await withClient(671 await withClient(
packages/hosts/mcp/src/tool-server.ts+16−3
@@ -905,14 +905,27 @@ const renderedInAppResult = (input: { · 904 unmodified lines
905 readonly code: string;905 readonly code: string;
906 readonly artifactId: string;906 readonly artifactId: string;
907 readonly title: string;907 readonly title: string;
908 readonly url?: string | undefined;
908}): McpToolResult => ({909}): McpToolResult => ({
909 content: [910 content: [
910 {911 {
911 type: "text",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
918const renderedAsLinkResult = (input: {931const renderedAsLinkResult = (input: {
@@ -1629,8 +1642,8 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 710 unmodified lines
1629 readonly artifactId: string;1642 readonly artifactId: string;
1630 readonly title: string;1643 readonly title: string;
1631 }): McpToolResult => {1644 }): McpToolResult => {
1632 if (appsSupported()) return renderedInAppResult(input);
1633 const url = config.artifactUrl?.(input.artifactId);1645 const url = config.artifactUrl?.(input.artifactId);
1646 if (appsSupported()) return renderedInAppResult({ ...input, url });
1634 return url1647 return url
1635 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })1648 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })
1636 : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title });1649 : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title });