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
5 changes: 5 additions & 0 deletions .changeset/olive-poets-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

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.
36 changes: 35 additions & 1 deletion packages/hosts/mcp/src/artifacts-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,11 @@ describe("MCP host — artifact tool visibility", () => {
name: "create-artifact",
arguments: { code: COUNTER_CODE, title: "Active users dashboard" },
})) as { structuredContent?: Record<string, unknown> };
expect(created.structuredContent).toEqual({ code: COUNTER_CODE, artifactId: "art_1" });
expect(created.structuredContent).toEqual({
code: COUNTER_CODE,
artifactId: "art_1",
url: "https://executor.test/artifacts/art_1",
});
expect(created.structuredContent).not.toHaveProperty("status", "fallback_url");

// The widget is useless if it cannot call back in, so visibility has to
Expand Down Expand Up @@ -613,6 +617,7 @@ describe("MCP host — create-artifact", () => {
expect(structuredOf(result)).toEqual({
code: COUNTER_CODE,
artifactId: "art_1",
url: "https://executor.test/artifacts/art_1",
});
expect(result.isError).toBeFalsy();
expect(store.calls).toEqual([
Expand All @@ -632,6 +637,35 @@ describe("MCP host — create-artifact", () => {
);
});

it("includes the deep link beside the inline widget payload when the host knows its URL", async () => {
// The widget is not the only consumer of an inline result: clients lose
// rendered widgets in ways the server never sees (a reopened transcript
// that skips the ui:// re-read shows raw JSON), and then the URL in the
// result is the model's only way to point the user back at the artifact.
const store = makeArtifactStore();
await withClient(
makeStubEngine({}),
APPS_CAPS,
async (client) => {
const result = await client.callTool({
name: "create-artifact",
arguments: { code: COUNTER_CODE, title: "Active users dashboard" },
});
expect(result.isError).toBeFalsy();
expect(structuredOf(result)).toEqual({
code: COUNTER_CODE,
artifactId: "art_1",
url: "https://executor.test/artifacts/art_1",
});
expect(textOf(result)).toContain("https://executor.test/artifacts/art_1");
},
{
artifacts: store.port,
artifactUrl: artifactUrlFor("https://executor.test"),
},
);
});

it("returns a deep link and still persists when the client cannot render apps", async () => {
const store = makeArtifactStore();
await withClient(
Expand Down
19 changes: 16 additions & 3 deletions packages/hosts/mcp/src/tool-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,14 +905,27 @@ const renderedInAppResult = (input: {
readonly code: string;
readonly artifactId: string;
readonly title: string;
readonly url?: string | undefined;
}): McpToolResult => ({
content: [
{
type: "text",
text: `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`,
text: [
`Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`,
// The link rides along even though the widget rendered: clients lose
// rendered widgets in ways the server never sees (a transcript
// reopened without re-reading the ui:// resource shows raw JSON), and
// when that happens this URL in the conversation is the only path
// back to the artifact the model can offer.
...(input.url ? [`It also stays available at ${input.url}`] : []),
].join("\n"),
},
],
structuredContent: { code: input.code, artifactId: input.artifactId },
structuredContent: {
code: input.code,
artifactId: input.artifactId,
...(input.url ? { url: input.url } : {}),
},
});

const renderedAsLinkResult = (input: {
Expand Down Expand Up @@ -1629,8 +1642,8 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
readonly artifactId: string;
readonly title: string;
}): McpToolResult => {
if (appsSupported()) return renderedInAppResult(input);
const url = config.artifactUrl?.(input.artifactId);
if (appsSupported()) return renderedInAppResult({ ...input, url });
return url
? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })
: renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title });
Expand Down
Loading