Skip to content

Commit 219dfd6

Browse files
committed
Return artifact source from show-artifact
1 parent cc0fd8f commit 219dfd6

4 files changed

Lines changed: 89 additions & 8 deletions

File tree

.changeset/artifact-source-text.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**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.

e2e/scenarios/artifacts.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ scenario(
187187
const suffix = uniqueSuffix();
188188
const title = `Release Readiness ${suffix}`;
189189
const marker = `artifact-ok-${suffix}`;
190+
const source = artifactSource(marker).trim();
190191

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

217218
const rendered = yield* session.call("create-artifact", {
218-
code: artifactSource(marker),
219+
code: source,
219220
title,
220221
description: "Whether the current release is ready to ship",
221222
});
@@ -552,6 +553,10 @@ scenario(
552553
String(structuredOf(shown).url ?? shown.text),
553554
"show-artifact delivers the same deep link for a non-Apps client",
554555
).toContain(String(artifactId));
556+
expect(
557+
shown.text,
558+
"show-artifact includes the current source in its text result for a non-Apps client",
559+
).toContain(`Source:\n\`\`\`tsx\n${source}\n\`\`\``);
555560
}).pipe(
556561
Effect.ensuring(
557562
Effect.suspend(() =>

packages/hosts/mcp/src/artifacts-tools.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ const structuredOf = (result: Awaited<ReturnType<Client["callTool"]>>): Record<s
182182
const textOf = (result: Awaited<ReturnType<Client["callTool"]>>): string =>
183183
(result.content as Array<{ type: string; text: string }>)[0].text;
184184

185+
/** Assert source is available through both MCP result channels. */
186+
const expectArtifactSource = (result: Awaited<ReturnType<Client["callTool"]>>, code: string) => {
187+
expect(structuredOf(result).code).toBe(code);
188+
expect(textOf(result)).toContain("Source:");
189+
expect(textOf(result)).toContain(code);
190+
};
191+
185192
const toolNames = async (client: Client): Promise<string[]> =>
186193
(await client.listTools()).tools.map((tool) => tool.name);
187194

@@ -688,8 +695,11 @@ describe("MCP host — create-artifact", () => {
688695
url: "https://executor.test/artifacts/art_1",
689696
artifactId: "art_1",
690697
});
691-
// The model needs to be told to hand the URL over.
698+
// The model needs to be told to hand the URL over. Source is a
699+
// show-artifact read, not part of the create confirmation.
692700
expect(textOf(result)).toContain("https://executor.test/artifacts/art_1");
701+
expect(textOf(result)).not.toContain("Source:");
702+
expect(structuredOf(result)).not.toHaveProperty("code");
693703
// Persistence is what makes the fallback possible at all.
694704
expect(store.calls).toHaveLength(1);
695705
expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE);
@@ -1285,6 +1295,10 @@ describe("MCP host — artifact retrieval", () => {
12851295
code: COUNTER_CODE,
12861296
artifactId: "art_1",
12871297
});
1298+
// Apps-capable hosts still need the source on the text channel: a
1299+
// later restore or a client that starts advertising apps must not
1300+
// make `show-artifact` unusable for `edit-artifact`.
1301+
expectArtifactSource(shown, COUNTER_CODE);
12881302
},
12891303
{ artifacts: store.port },
12901304
);
@@ -1368,7 +1382,12 @@ describe("MCP host — artifact retrieval", () => {
13681382
status: "fallback_url",
13691383
url: "https://executor.test/artifacts/art_1",
13701384
artifactId: "art_1",
1385+
code: COUNTER_CODE,
13711386
});
1387+
// The URL instruction stays; the source rides after it so a text-only
1388+
// host can copy `oldText` for `edit-artifact` from this result.
1389+
expect(textOf(shown)).toContain("https://executor.test/artifacts/art_1");
1390+
expectArtifactSource(shown, COUNTER_CODE);
13721391
},
13731392
{
13741393
artifacts: store.port,
@@ -1377,6 +1396,35 @@ describe("MCP host — artifact retrieval", () => {
13771396
);
13781397
});
13791398

1399+
it("returns show-artifact source when the client has no apps support and no web UI", async () => {
1400+
const store = makeArtifactStore();
1401+
await Effect.runPromise(
1402+
store.port.save({
1403+
title: "Saved earlier",
1404+
description: null,
1405+
code: COUNTER_CODE,
1406+
}),
1407+
);
1408+
await withClient(
1409+
makeStubEngine({}),
1410+
NO_APPS_CAPS,
1411+
async (client) => {
1412+
const shown = await client.callTool({
1413+
name: "show-artifact",
1414+
arguments: { id: "art_1" },
1415+
});
1416+
expect(structuredOf(shown)).toEqual({
1417+
status: "fallback_unavailable",
1418+
reason: "mcp_apps_unsupported",
1419+
artifactId: "art_1",
1420+
code: COUNTER_CODE,
1421+
});
1422+
expectArtifactSource(shown, COUNTER_CODE);
1423+
},
1424+
{ artifacts: store.port },
1425+
);
1426+
});
1427+
13801428
it("reports a miss as an error result rather than failing the tool call", async () => {
13811429
const store = makeArtifactStore();
13821430
await withClient(

packages/hosts/mcp/src/tool-server.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,8 @@ const startMarker = (name: string, attributes: Record<string, unknown>): Effect.
900900
// user as an inline widget when the client renders MCP Apps, and as a link into
901901
// the web app when it doesn't. Both carry `artifactId`, because either way the
902902
// artifact was saved and can be reopened later.
903+
// `show-artifact` returns source on both channels; create/edit only confirm
904+
// saves.
903905

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

988+
/** Format the stored source for the text result channel. */
989+
const artifactSourceText = (code: string): string => `Source:\n\`\`\`tsx\n${code}\n\`\`\``;
990+
991+
/** Add source to both MCP result channels. */
992+
const withArtifactSource = (result: McpToolResult, code: string): McpToolResult => {
993+
const source = artifactSourceText(code);
994+
const content = result.content.map((block, index) =>
995+
index === 0 && block.type === "text"
996+
? { type: "text" as const, text: `${block.text}\n\n${source}` }
997+
: block,
998+
);
999+
return {
1000+
...result,
1001+
content,
1002+
structuredContent: { ...result.structuredContent, code },
1003+
};
1004+
};
1005+
9861006
const renderedInAppResult = (input: {
9871007
readonly code: string;
9881008
readonly artifactId: string;
@@ -2042,11 +2062,14 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
20422062
.pipe(Effect.catchCause(() => Effect.succeed(null)));
20432063
if (!artifact) return artifactNotFoundResult(id);
20442064
yield* notifyArtifactUsage("viewed");
2045-
return deliverArtifact({
2046-
code: artifact.code,
2047-
artifactId: artifact.id,
2048-
title: artifact.title,
2049-
});
2065+
return withArtifactSource(
2066+
deliverArtifact({
2067+
code: artifact.code,
2068+
artifactId: artifact.id,
2069+
title: artifact.title,
2070+
}),
2071+
artifact.code,
2072+
);
20502073
}).pipe(
20512074
Effect.withSpan("mcp.host.tool.show_artifact", {
20522075
attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id },
@@ -2245,7 +2268,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
22452268
description: [
22462269
"Re-render a saved UI artifact by id.",
22472270
"Use `list-artifacts` first to find the id whose title or description matches what the user asked for.",
2248-
"Clients that cannot display MCP apps receive a link to the artifact instead.",
2271+
"Returns the artifact's current source. Clients that cannot display MCP apps also receive a link to the artifact; pass it to the user.",
22492272
].join("\n"),
22502273
inputSchema: {
22512274
id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."),

0 commit comments

Comments
 (0)