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+
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.
packages/hosts/mcp/src/artifacts-tools.test.ts+35−1
@@ -353,7 +353,11 @@ describe("MCP host — artifact tool visibility", () => { · 352 unmodified lines
353353 name: "create-artifact",
354354 arguments: { code: COUNTER_CODE, title: "Active users dashboard" },
355355 })) 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+ });
357361 expect(created.structuredContent).not.toHaveProperty("status", "fallback_url");
358362
359363 // 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
613617 expect(structuredOf(result)).toEqual({
614618 code: COUNTER_CODE,
615619 artifactId: "art_1",
620+ url: "https://executor.test/artifacts/art_1",
616621 });
617622 expect(result.isError).toBeFalsy();
618623 expect(store.calls).toEqual([
@@ -632,6 +637,35 @@ describe("MCP host — create-artifact", () => { · 13 unmodified lines
632637 );
633638 });
634639
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+
635669 it("returns a deep link and still persists when the client cannot render apps", async () => {
636670 const store = makeArtifactStore();
637671 await withClient(
packages/hosts/mcp/src/tool-server.ts+16−3
@@ -905,14 +905,27 @@ const renderedInAppResult = (input: { · 904 unmodified lines
905905 readonly code: string;
906906 readonly artifactId: string;
907907 readonly title: string;
908+ readonly url?: string | undefined;
908909 }): McpToolResult => ({
909910 content: [
910911 {
911912 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"),
913922 },
914923 ],
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+ },
916929 });
917930
918931 const renderedAsLinkResult = (input: {
@@ -1629,8 +1642,8 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 710 unmodified lines
16291642 readonly artifactId: string;
16301643 readonly title: string;
16311644 }): McpToolResult => {
1632− if (appsSupported()) return renderedInAppResult(input);
16331645 const url = config.artifactUrl?.(input.artifactId);
1646+ if (appsSupported()) return renderedInAppResult({ ...input, url });
16341647 return url
16351648 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })
16361649 : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title });