Skip to content
Open
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/artifact-source-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

**Fix: `show-artifact` now returns the saved component source to MCP clients that cannot render Apps.** Agents can read the current source and make targeted edits instead of receiving only a link to the artifact.
7 changes: 6 additions & 1 deletion e2e/scenarios/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ scenario(
const suffix = uniqueSuffix();
const title = `Release Readiness ${suffix}`;
const marker = `artifact-ok-${suffix}`;
const source = artifactSource(marker).trim();

// Tracked so cleanup runs even when an assertion below fails.
let artifactId: ArtifactId | undefined;
Expand Down Expand Up @@ -215,7 +216,7 @@ scenario(
);

const rendered = yield* session.call("create-artifact", {
code: artifactSource(marker),
code: source,
title,
description: "Whether the current release is ready to ship",
});
Expand Down Expand Up @@ -552,6 +553,10 @@ scenario(
String(structuredOf(shown).url ?? shown.text),
"show-artifact delivers the same deep link for a non-Apps client",
).toContain(String(artifactId));
expect(
shown.text,
"show-artifact includes the current source in its text result for a non-Apps client",
).toContain(`Source:\n\`\`\`tsx\n${source}\n\`\`\``);
}).pipe(
Effect.ensuring(
Effect.suspend(() =>
Expand Down
50 changes: 49 additions & 1 deletion packages/hosts/mcp/src/artifacts-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ const structuredOf = (result: Awaited<ReturnType<Client["callTool"]>>): Record<s
const textOf = (result: Awaited<ReturnType<Client["callTool"]>>): string =>
(result.content as Array<{ type: string; text: string }>)[0].text;

/** Assert source is available through both MCP result channels. */
const expectArtifactSource = (result: Awaited<ReturnType<Client["callTool"]>>, code: string) => {
expect(structuredOf(result).code).toBe(code);
expect(textOf(result)).toContain("Source:");
expect(textOf(result)).toContain(code);
};

const toolNames = async (client: Client): Promise<string[]> =>
(await client.listTools()).tools.map((tool) => tool.name);

Expand Down Expand Up @@ -688,8 +695,11 @@ describe("MCP host — create-artifact", () => {
url: "https://executor.test/artifacts/art_1",
artifactId: "art_1",
});
// The model needs to be told to hand the URL over.
// The model needs to be told to hand the URL over. Source is a
// show-artifact read, not part of the create confirmation.
expect(textOf(result)).toContain("https://executor.test/artifacts/art_1");
expect(textOf(result)).not.toContain("Source:");
expect(structuredOf(result)).not.toHaveProperty("code");
// Persistence is what makes the fallback possible at all.
expect(store.calls).toHaveLength(1);
expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE);
Expand Down Expand Up @@ -1285,6 +1295,10 @@ describe("MCP host — artifact retrieval", () => {
code: COUNTER_CODE,
artifactId: "art_1",
});
// Apps-capable hosts still need the source on the text channel: a
// later restore or a client that starts advertising apps must not
// make `show-artifact` unusable for `edit-artifact`.
expectArtifactSource(shown, COUNTER_CODE);
},
{ artifacts: store.port },
);
Expand Down Expand Up @@ -1368,7 +1382,12 @@ describe("MCP host — artifact retrieval", () => {
status: "fallback_url",
url: "https://executor.test/artifacts/art_1",
artifactId: "art_1",
code: COUNTER_CODE,
});
// The URL instruction stays; the source rides after it so a text-only
// host can copy `oldText` for `edit-artifact` from this result.
expect(textOf(shown)).toContain("https://executor.test/artifacts/art_1");
expectArtifactSource(shown, COUNTER_CODE);
},
{
artifacts: store.port,
Expand All @@ -1377,6 +1396,35 @@ describe("MCP host — artifact retrieval", () => {
);
});

it("returns show-artifact source when the client has no apps support and no web UI", async () => {
const store = makeArtifactStore();
await Effect.runPromise(
store.port.save({
title: "Saved earlier",
description: null,
code: COUNTER_CODE,
}),
);
await withClient(
makeStubEngine({}),
NO_APPS_CAPS,
async (client) => {
const shown = await client.callTool({
name: "show-artifact",
arguments: { id: "art_1" },
});
expect(structuredOf(shown)).toEqual({
status: "fallback_unavailable",
reason: "mcp_apps_unsupported",
artifactId: "art_1",
code: COUNTER_CODE,
});
expectArtifactSource(shown, COUNTER_CODE);
},
{ artifacts: store.port },
);
});

it("reports a miss as an error result rather than failing the tool call", async () => {
const store = makeArtifactStore();
await withClient(
Expand Down
35 changes: 29 additions & 6 deletions packages/hosts/mcp/src/tool-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,8 @@ const startMarker = (name: string, attributes: Record<string, unknown>): Effect.
// user as an inline widget when the client renders MCP Apps, and as a link into
// the web app when it doesn't. Both carry `artifactId`, because either way the
// artifact was saved and can be reopened later.
// `show-artifact` returns source on both channels; create/edit only confirm
// saves.

const renderRejectedResult = (reason: string): McpToolResult => ({
content: [{ type: "text", text: `create-artifact rejected: ${reason}` }],
Expand Down Expand Up @@ -983,6 +985,24 @@ const bindingUnresolvedResult = (input: {
isError: true,
});

/** Format the stored source for the text result channel. */
const artifactSourceText = (code: string): string => `Source:\n\`\`\`tsx\n${code}\n\`\`\``;

/** Add source to both MCP result channels. */
const withArtifactSource = (result: McpToolResult, code: string): McpToolResult => {
const source = artifactSourceText(code);
const content = result.content.map((block, index) =>
index === 0 && block.type === "text"
? { type: "text" as const, text: `${block.text}\n\n${source}` }
: block,
);
return {
...result,
content,
structuredContent: { ...result.structuredContent, code },
};
};

const renderedInAppResult = (input: {
readonly code: string;
readonly artifactId: string;
Expand Down Expand Up @@ -2042,11 +2062,14 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
.pipe(Effect.catchCause(() => Effect.succeed(null)));
if (!artifact) return artifactNotFoundResult(id);
yield* notifyArtifactUsage("viewed");
return deliverArtifact({
code: artifact.code,
artifactId: artifact.id,
title: artifact.title,
});
return withArtifactSource(
deliverArtifact({
code: artifact.code,
artifactId: artifact.id,
title: artifact.title,
}),
artifact.code,
);
}).pipe(
Effect.withSpan("mcp.host.tool.show_artifact", {
attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id },
Expand Down Expand Up @@ -2245,7 +2268,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
description: [
"Re-render a saved UI artifact by id.",
"Use `list-artifacts` first to find the id whose title or description matches what the user asked for.",
"Clients that cannot display MCP apps receive a link to the artifact instead.",
"Returns the artifact's current source. Clients that cannot display MCP apps also receive a link to the artifact; pass it to the user.",
].join("\n"),
inputSchema: {
id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."),
Expand Down
Loading