From e8b4dadd8e5ca27e36adc42c9d6ced69f548fdbc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:53:51 -0700 Subject: [PATCH] Add edit-artifact: patch-based artifact updates --- e2e/scenarios/artifacts.test.ts | 8 +- packages/core/execution/src/skills.ts | 34 +- .../hosts/mcp/src/artifacts-tools.test.ts | 294 +++++++++++++++++- packages/hosts/mcp/src/create-artifact.ts | 82 +++++ packages/hosts/mcp/src/tool-server.ts | 249 ++++++++++++--- 5 files changed, 605 insertions(+), 62 deletions(-) diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts index 921eb4b65..63b0cbba8 100644 --- a/e2e/scenarios/artifacts.test.ts +++ b/e2e/scenarios/artifacts.test.ts @@ -791,6 +791,7 @@ scenario( const ARTIFACT_TOOLS = [ "create-artifact", + "edit-artifact", "list-artifacts", "show-artifact", "execute-action", @@ -818,7 +819,12 @@ scenario( // URL, gets everything by default. Without this the assertions above would // also pass on a server that had simply lost artifacts. const defaultTools = yield* defaultSession.listTools(); - for (const tool of ["create-artifact", "list-artifacts", "show-artifact"] as const) { + for (const tool of [ + "create-artifact", + "edit-artifact", + "list-artifacts", + "show-artifact", + ] as const) { expect(defaultTools, `${tool} is present on a default session`).toContain(tool); } const defaultSkills = yield* defaultSession.call("skills", {}); diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index 9a78e8a3d..473e18ca0 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -113,22 +113,32 @@ const CREATE_ARTIFACT_SKILL_BODY = [ "UPDATING that artifact, not making another one. A second artifact for a revision", "leaves the user with two near-identical entries and a stale link to the old one.", "", - "1. `show-artifact({ id })` to get the current source back. Do not rewrite from memory.", - "2. Edit that source.", - "3. `create-artifact` with the same `artifactId` and the complete edited component.", + "For a TWEAK — a new column, a fixed label, a restyled section — use `edit-artifact`:", + "exact find-and-replace patches against the stored source, so you send only the", + "changed lines instead of re-emitting the whole component.", "", "```", - "code: ''", 'artifactId: "art_..."', + "edits: [{ oldText: '', newText: '' }]", "```", "", - "`code` fully replaces what was stored — send the whole component, never a fragment", - "or a diff. `title` and `description` are optional on an update: leave them out to", - "keep the current ones, pass them to change them. Connection bindings are", - "re-resolved from the new code, so a call to a new integration binds on the update", - "the same way it would on a create (and pass `connections` if it is ambiguous).", - "The artifact keeps its id and its URL, so anyone holding the link sees the new", - "version.", + "Each `oldText` must match the current source verbatim (whitespace included) and", + "exactly once — include surrounding lines to disambiguate, or set", + "`replaceAll: true` to change every occurrence. Edits apply in order, each seeing", + "the previous one's result, and the batch is atomic: on any miss nothing is saved", + "and the error returns the current source in `structuredContent.code`, so rebuild", + "the retry from that rather than calling `show-artifact` again. If you don't have", + "the source at all (the artifact came from `list-artifacts`), `show-artifact` once", + "and edit from what it returns — never from memory.", + "", + "For a REWRITE, where most of the code changes, edits would be longer than the code:", + "call `create-artifact` with the same `artifactId` and the complete new component.", + "`code` then fully replaces what was stored — the whole component, never a fragment.", + "", + "Both paths validate and smoke-render the result identically, keep the artifact's id", + "and URL, and re-resolve connection bindings from the resulting code (pass", + "`connections` if it is ambiguous). `title` and `description` are optional on both:", + "leave them out to keep the current ones.", "", "Only omit `artifactId` when the user genuinely wants a NEW, separate UI.", "", @@ -331,7 +341,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [ "", "- `list-artifacts` returns every saved artifact's id, title, description and last-updated time.", "- `show-artifact({ id })` re-renders one and returns its source. Match the user's phrasing against the titles and descriptions from `list-artifacts` rather than guessing an id.", - '- To change one, edit that source and call `create-artifact` with its `artifactId` — see "Changing An Artifact That Already Exists" above.', + '- To change one, patch it with `edit-artifact` (or rewrite it with `create-artifact` + `artifactId`) — see "Changing An Artifact That Already Exists" above.', "- Clients that cannot display MCP apps get a link to the artifact in the web app instead; pass that URL on to the user verbatim.", ].join("\n"); diff --git a/packages/hosts/mcp/src/artifacts-tools.test.ts b/packages/hosts/mcp/src/artifacts-tools.test.ts index c25c0e65f..a8a3b794b 100644 --- a/packages/hosts/mcp/src/artifacts-tools.test.ts +++ b/packages/hosts/mcp/src/artifacts-tools.test.ts @@ -208,9 +208,10 @@ describe("MCP host — artifact tool visibility", () => { NO_APPS_CAPS, async (client) => { const names = await toolNames(client); - // The model-facing three are always advertised — they degrade to a + // The model-facing tools are always advertised — they degrade to a // deep link rather than disappearing. expect(names).toContain("create-artifact"); + expect(names).toContain("edit-artifact"); expect(names).toContain("list-artifacts"); expect(names).toContain("show-artifact"); // `execute-action` is only callable from inside a rendered app. @@ -491,6 +492,7 @@ describe("MCP host — artifact tool visibility", () => { async (client) => { const names = await toolNames(client); expect(names).not.toContain("create-artifact"); + expect(names).not.toContain("edit-artifact"); expect(names).not.toContain("list-artifacts"); expect(names).not.toContain("show-artifact"); expect(names).not.toContain("execute-action"); @@ -554,6 +556,7 @@ describe("MCP host — artifact tool visibility", () => { async (client) => { const names = await toolNames(client); expect(names).toContain("create-artifact"); + expect(names).toContain("edit-artifact"); expect(names).toContain("list-artifacts"); expect(names).toContain("show-artifact"); expect(names).toContain("execute-action"); @@ -1683,6 +1686,295 @@ describe("MCP host — create-artifact update in place", () => { }); }); +// --------------------------------------------------------------------------- +// edit-artifact — patch-based updates +// --------------------------------------------------------------------------- +// +// The contract: an edit is exact find-and-replace against the stored source, +// applied in order, all-or-nothing, and the result goes through the same +// validate → smoke-render → bind → save pipeline as a full create. A failed +// batch changes nothing and hands the current source back so the retry needs +// no show-artifact round trip. + +describe("MCP host — edit-artifact", () => { + const seed = async (client: Client, code: string = COUNTER_CODE) => + client.callTool({ + name: "create-artifact", + arguments: { code, title: "Draft", description: "First pass" }, + }); + + it("patches the stored source and delivers the edited component", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await seed(client); + const edited = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [{ oldText: "
hi
", newText: "
hi again
" }], + }, + }); + expect(edited.isError).toBeFalsy(); + expect(structuredOf(edited)).toEqual({ code: UPDATED_CODE, artifactId: "art_1" }); + expect(store.rows.get("art_1")).toMatchObject({ + code: UPDATED_CODE, + // An edit that says nothing about title/description keeps them. + title: "Draft", + description: "First pass", + }); + // One artifact, not two. + expect(store.rows.size).toBe(1); + }, + { artifacts: store.port }, + ); + }); + + it("applies edits in order, each against the previous result", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await seed(client); + const edited = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [ + { oldText: "
hi
", newText: "
hello
" }, + // Matches only the first edit's output, proving sequencing. + { oldText: "
hello
", newText: "
hello there
" }, + ], + }, + }); + expect(edited.isError).toBeFalsy(); + expect(store.rows.get("art_1")?.code).toBe( + "function App() { return
hello there
; }", + ); + }, + { artifacts: store.port }, + ); + }); + + it("replaces every occurrence only under replaceAll, and refuses ambiguity otherwise", async () => { + const store = makeArtifactStore(); + const REPEATED = "function App() { return
xx
; }"; + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await seed(client, REPEATED); + + const ambiguous = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [{ oldText: "x", newText: "y" }], + }, + }); + expect(ambiguous.isError).toBe(true); + expect(textOf(ambiguous)).toContain("appears 2 times"); + expect(textOf(ambiguous)).toContain("replaceAll"); + expect(store.rows.get("art_1")?.code).toBe(REPEATED); + + const replaced = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [{ oldText: "x", newText: "y", replaceAll: true }], + }, + }); + expect(replaced.isError).toBeFalsy(); + expect(store.rows.get("art_1")?.code).toBe( + "function App() { return
yy
; }", + ); + }, + { artifacts: store.port }, + ); + }); + + it("rejects the whole batch on a miss, returns the current source, and saves nothing", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await seed(client); + const missed = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [ + { oldText: "
hi
", newText: "
hello
" }, + { oldText: "not in the source", newText: "anything" }, + ], + }, + }); + expect(missed.isError).toBe(true); + expect(textOf(missed)).toContain("Edit 2 of 2"); + expect(textOf(missed)).toContain("matched nothing"); + // The retry material: the stored source rides along on the error. + expect(structuredOf(missed)).toMatchObject({ code: COUNTER_CODE }); + // Atomic: the first (valid) edit was not half-applied. + expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); + expect(store.calls.length).toBe(1); + }, + { artifacts: store.port }, + ); + }); + + it("validates the edited result like a create, and leaves the stored row untouched", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await seed(client); + const rejected = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [ + { + oldText: "return
hi
;", + newText: "const Card = 1; return
hi
;", + }, + ], + }, + }); + expect(rejected.isError).toBe(true); + expect(textOf(rejected)).toContain("cannot be redeclared"); + expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); + expect(store.calls.length).toBe(1); + }, + { artifacts: store.port }, + ); + }); + + it("smoke-renders the edited result, so a patch cannot break a working artifact", async () => { + const store = makeArtifactStore(); + const smokeRenderArtifact = vi.fn((code: string) => + Promise.resolve( + code.includes("boom") + ? { status: "failed" as const, message: "boom is not defined" } + : { status: "ok" as const }, + ), + ); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await seed(client); + const broken = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [{ oldText: "return", newText: "boom(); return" }], + }, + }); + expect(broken.isError).toBe(true); + expect(textOf(broken)).toContain("boom is not defined"); + expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); + // The candidate it rendered was the EDITED source, not the stored one. + expect(smokeRenderArtifact).toHaveBeenLastCalledWith( + expect.stringContaining("boom(); return"), + ); + }, + { artifacts: store.port, smokeRenderArtifact }, + ); + }); + + it("refuses an artifact id that is not the caller's, without saying whether it exists", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const result = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_someone_else", + edits: [{ oldText: "a", newText: "b" }], + }, + }); + expect(result.isError).toBe(true); + // The probe-proof answer, with no stored source riding along. + expect(structuredOf(result)).toMatchObject({ error: "artifact_unavailable" }); + expect(structuredOf(result)).not.toHaveProperty("code"); + expect(store.calls).toEqual([]); + }, + { artifacts: store.port }, + ); + }); + + it("re-resolves bindings when an edit introduces an integration", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await seed(client); + expect(store.calls[0]?.bindings).toEqual({}); + const edited = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [ + { + oldText: "return
hi
;", + newText: + "const q = useQuery(tools.vercel.domains.getDomains.queryOptions({})); return
hi
;", + }, + ], + }, + }); + expect(edited.isError).toBeFalsy(); + expect(store.calls[1]?.bindings).toEqual({ + vercel: { + integration: "vercel", + owner: "user", + connection: "personalVercel", + }, + }); + }, + { + artifacts: store.port, + connections: connectionsPort([conn("vercel", "user", "personalVercel")]), + }, + ); + }); + + it("updates the title and description when asked, keeping the patched code", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await seed(client); + const edited = await client.callTool({ + name: "edit-artifact", + arguments: { + artifactId: "art_1", + edits: [{ oldText: "hi", newText: "hi again" }], + title: "Active users dashboard", + description: "Second pass", + }, + }); + expect(edited.isError).toBeFalsy(); + expect(store.rows.get("art_1")).toMatchObject({ + title: "Active users dashboard", + description: "Second pass", + code: UPDATED_CODE, + }); + }, + { artifacts: store.port }, + ); + }); +}); + // --------------------------------------------------------------------------- // create-artifact — connection binding // --------------------------------------------------------------------------- diff --git a/packages/hosts/mcp/src/create-artifact.ts b/packages/hosts/mcp/src/create-artifact.ts index c1454e9dc..64960bb4d 100644 --- a/packages/hosts/mcp/src/create-artifact.ts +++ b/packages/hosts/mcp/src/create-artifact.ts @@ -198,6 +198,88 @@ const hookCalledInLoop = (code: string): string | undefined => { /** Hooks whose in-loop use is almost always hand-rolled cursor pagination. */ const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]); +// --------------------------------------------------------------------------- +// Edits +// --------------------------------------------------------------------------- +// +// `edit-artifact`'s pure half. An edit is an exact find-and-replace against the +// stored source, so a model changing one column of a 300-line dashboard sends +// the changed lines rather than re-emitting the whole component — the tool +// call's size scales with the edit, not the artifact. +// +// The semantics are the ones every model already knows from its own file-edit +// tool: `oldText` must match verbatim (whitespace included) and exactly once, +// unless `replaceAll` opts into every occurrence. Edits apply in order, each +// against the result of the previous, and the whole batch is atomic — any +// miss rejects the call and nothing is saved. Ambiguity is refused rather than +// resolved by picking the first hit, because a wrong pick would save silently +// and the user would see the wrong line change. + +export type ArtifactEdit = { + /** Exact text to find in the current source. */ + readonly oldText: string; + /** What to put in its place. */ + readonly newText: string; + /** Replace every occurrence instead of requiring `oldText` to be unique. */ + readonly replaceAll?: boolean | undefined; +}; + +export type AppliedArtifactEdits = + | { readonly ok: true; readonly code: string } + | { readonly ok: false; readonly message: string }; + +const countOccurrences = (haystack: string, needle: string): number => + haystack.split(needle).length - 1; + +/** The edited source, or the first edit that could not be applied. Messages + * name the edit by position and say what to do, because the model retries + * from the message alone. */ +export const applyArtifactEdits = ( + code: string, + edits: readonly ArtifactEdit[], +): AppliedArtifactEdits => { + let current = code; + for (const [index, edit] of edits.entries()) { + const position = `Edit ${index + 1} of ${edits.length}`; + if (edit.oldText === "") { + return { ok: false, message: `${position} has an empty oldText, which matches nothing.` }; + } + if (edit.oldText === edit.newText) { + return { + ok: false, + message: `${position} is a no-op: oldText and newText are identical.`, + }; + } + const occurrences = countOccurrences(current, edit.oldText); + if (occurrences === 0) { + return { + ok: false, + message: [ + `${position} matched nothing: its oldText does not appear in the current source.`, + "Copy oldText verbatim from the source, whitespace included", + index > 0 ? "— and remember each edit sees the result of the earlier ones." : ".", + ].join(" "), + }; + } + if (occurrences > 1 && edit.replaceAll !== true) { + return { + ok: false, + message: [ + `${position} is ambiguous: its oldText appears ${occurrences} times in the current source.`, + "Include enough surrounding text to make it unique, or set replaceAll: true to change every occurrence.", + ].join(" "), + }; + } + // split/join for both arms: `String.replace` gives `$&` and friends in the + // replacement special meaning, and generated JSX is full of `$`. + current = + edit.replaceAll === true + ? current.split(edit.oldText).join(edit.newText) + : current.replace(edit.oldText, () => edit.newText); + } + return { ok: true, code: current }; +}; + // --------------------------------------------------------------------------- // Create-time smoke render // --------------------------------------------------------------------------- diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index f357fb639..7a44dcfd8 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -53,8 +53,10 @@ import { } from "@executor-js/execution"; import { MCP_APPS_SHELL_RESOURCE_URI, + applyArtifactEdits, smokeRenderRejection, validateArtifactCode, + type ArtifactEdit, type ArtifactSmokeRenderResult, } from "./create-artifact"; import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; @@ -846,6 +848,22 @@ const renderRejectedResult = (reason: string): McpToolResult => ({ isError: true, }); +/** An edit batch that could not be applied. Carries the current stored source + * so the model can rebuild its edits without a `show-artifact` round trip. */ +const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `edit-artifact rejected: ${reason}`, + "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.", + ].join("\n"), + }, + ], + structuredContent: { status: "error", error: reason, code: currentCode }, + isError: true, +}); + /** `execute-action` was handed something other than a single proxy-shaped tool * call. Names the contract rather than just refusing, since the reader is * either a confused iframe or someone probing the app channel by hand. */ @@ -1655,53 +1673,21 @@ export const createExecutorMcpServer = ( }; /** - * Bind the integration roles an artifact's code uses, at create time. - * - * Binding happens HERE rather than at render time because this is the only - * moment the author, the code and their connections are all in hand — and - * because a create that can't bind is a create that would have saved a - * broken artifact. The model finds out now, with the candidate list, rather - * than the user finding out later through a query error inside the UI. - * - * `artifactId` turns the same call into an update in place. A model tweaking - * a dashboard has the whole component in hand already — it fetched it with - * `show-artifact`, edited it, and is calling back with the result — so a - * separate `update-artifact` tool would take the same four arguments and - * differ only in whether a row is minted. One tool keeps the catalog small - * and makes the wrong thing (a copy per tweak) the thing the model has to - * ask for rather than the thing it gets by default. - * - * An update replaces the code outright — v1 keeps no version history — and - * re-extracts and re-resolves the bindings from the NEW source, because the - * roles the new code uses are not necessarily the ones the old code did. - * `title` and `description` are optional on an update and absent means keep - * what is stored, so a pure code tweak doesn't have to restate them. + * The shared back half of `create-artifact` and `edit-artifact`: everything + * that happens once the full candidate source is in hand. Static checks, + * the smoke render, binding and the save are identical whether the code + * arrived whole or was assembled from stored source plus edits — sharing + * the pipeline is what guarantees an edit cannot save anything a create + * would have refused. */ - const createArtifact = (input: { + const validateRenderAndSave = (input: { readonly code: string; - readonly title?: string; - readonly description?: string; - readonly connections?: Readonly>; - readonly artifactId?: string; + readonly title: string; + readonly description?: string | undefined; + readonly connections?: Readonly> | undefined; + readonly existing: Artifact | null; }): Effect.Effect => Effect.gen(function* () { - // An update reads the existing row FIRST, both to carry its title and - // description forward and to refuse a foreign id before any work. The - // refusal is `artifact_unavailable` — the same answer `execute-action` - // gives — so create-artifact cannot be used to probe which ids exist. - const existing = - input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId); - if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult(); - - const title = input.title ?? existing?.title; - if (title === undefined) { - return renderRejectedResult( - "title is required when creating an artifact. Give it a short human-readable name.", - ); - } - // Only an update inherits; a create with no description stores none. - const description = input.description ?? existing?.description ?? undefined; - const rejection = validateArtifactCode(input.code); if (rejection) return renderRejectedResult(rejection); @@ -1744,10 +1730,10 @@ export const createExecutorMcpServer = ( const saveInput = { code: input.code, - title, + title: input.title, preview, - ...(description === undefined ? {} : { description }), - ...(existing === null ? {} : { existingId: existing.id }), + ...(input.description === undefined ? {} : { description: input.description }), + ...(input.existing === null ? {} : { existingId: input.existing.id }), }; const roles = extractArtifactRoles(input.code); @@ -1775,6 +1761,61 @@ export const createExecutorMcpServer = ( "mcp.artifact.role_count": roles.length, }); return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings }); + }); + + /** + * Bind the integration roles an artifact's code uses, at create time. + * + * Binding happens HERE rather than at render time because this is the only + * moment the author, the code and their connections are all in hand — and + * because a create that can't bind is a create that would have saved a + * broken artifact. The model finds out now, with the candidate list, rather + * than the user finding out later through a query error inside the UI. + * + * `artifactId` turns the same call into an update in place — for a REWRITE, + * where the new source shares little with the old and edits would be longer + * than the code. A tweak belongs on `edit-artifact`, which patches the + * stored source instead of replacing it. Either way one row is kept: a copy + * per revision is the thing the model has to ask for, never the default. + * + * An update replaces the code outright — v1 keeps no version history — and + * re-extracts and re-resolves the bindings from the NEW source, because the + * roles the new code uses are not necessarily the ones the old code did. + * `title` and `description` are optional on an update and absent means keep + * what is stored, so a pure code tweak doesn't have to restate them. + */ + const createArtifact = (input: { + readonly code: string; + readonly title?: string; + readonly description?: string; + readonly connections?: Readonly>; + readonly artifactId?: string; + }): Effect.Effect => + Effect.gen(function* () { + // An update reads the existing row FIRST, both to carry its title and + // description forward and to refuse a foreign id before any work. The + // refusal is `artifact_unavailable` — the same answer `execute-action` + // gives — so create-artifact cannot be used to probe which ids exist. + const existing = + input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId); + if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult(); + + const title = input.title ?? existing?.title; + if (title === undefined) { + return renderRejectedResult( + "title is required when creating an artifact. Give it a short human-readable name.", + ); + } + // Only an update inherits; a create with no description stores none. + const description = input.description ?? existing?.description ?? undefined; + + return yield* validateRenderAndSave({ + code: input.code, + title, + description, + connections: input.connections, + existing, + }); }).pipe( Effect.withSpan("mcp.host.tool.create_artifact", { attributes: { @@ -1785,6 +1826,52 @@ export const createExecutorMcpServer = ( }), ); + /** + * `edit-artifact`: the update path for tweaks, patching the stored source + * with exact find-and-replace edits so the call scales with the change + * rather than the component. The edited result runs the same + * validate → smoke-render → bind → save pipeline as a full create, so an + * edit cannot save anything a create would have refused. + * + * A failed edit hands the CURRENT source back in `structuredContent.code`. + * The model's usual recovery — `show-artifact`, re-read, retry — is a whole + * extra round trip to fetch a thing this call already loaded; giving it + * back here makes the retry immediate. + */ + const editArtifact = (input: { + readonly artifactId: string; + readonly edits: readonly ArtifactEdit[]; + readonly title?: string; + readonly description?: string; + readonly connections?: Readonly>; + }): Effect.Effect => + Effect.gen(function* () { + // Same probe-proof refusal as create-artifact's update arm. + const existing = yield* loadArtifact(input.artifactId); + if (!existing) return actionArtifactUnavailableResult(); + + const applied = applyArtifactEdits(existing.code, input.edits); + if (!applied.ok) return editRejectedResult(applied.message, existing.code); + + yield* Effect.annotateCurrentSpan({ + "mcp.artifact.edit_count": input.edits.length, + }); + return yield* validateRenderAndSave({ + code: applied.code, + title: input.title ?? existing.title, + description: input.description ?? existing.description ?? undefined, + connections: input.connections, + existing, + }); + }).pipe( + Effect.withSpan("mcp.host.tool.edit_artifact", { + attributes: { + "mcp.tool.name": "edit-artifact", + "mcp.artifact.id": input.artifactId, + }, + }), + ); + const listArtifacts = (): Effect.Effect => Effect.gen(function* () { if (!artifacts) return artifactsUnavailableResult(); @@ -1863,7 +1950,7 @@ export const createExecutorMcpServer = ( 'To use two accounts of the same integration, tag each call site with a role — `tools.linear("prod").issues.list` and `tools.linear("staging").issues.list` — and map every role in `connections`.', "All data access is declarative `tools.*`: `.queryOptions()` to read, `.infiniteQueryOptions()` to page through a cursor, `.mutationOptions()` to write. There is no `run()` and no arbitrary code — never hand-roll `useQuery({ queryKey, queryFn })`, or invalidation breaks.", "To read every page of a paginated tool, call `useInfiniteQuery(tools...infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.", - "To CHANGE an artifact that already exists — a tweak, a fix, a new column — pass its `artifactId` and it is updated in place. Fetch the current source with `show-artifact` first, edit that, and send the whole component back. Never create a second artifact for a revision of an existing one.", + "To CHANGE an artifact that already exists, use `edit-artifact` — it patches the stored source with find-and-replace edits, so a tweak costs only the changed lines. Only use create-artifact with `artifactId` for a full rewrite, sending the complete new component. Never create a second artifact for a revision of an existing one.", "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.", ].join("\n"), inputSchema: { @@ -1874,7 +1961,7 @@ export const createExecutorMcpServer = ( .min(1) .optional() .describe( - "The artifact to update in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment.", + "The artifact to REWRITE in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment. For a tweak, use `edit-artifact` instead.", ), connections: z .record(z.string(), z.string()) @@ -1910,6 +1997,72 @@ export const createExecutorMcpServer = ( }), ); + yield* Effect.sync(() => + registerAppTool( + server, + "edit-artifact", + { + description: [ + "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.", + "PREFER THIS over create-artifact for tweaks — a new column, a fixed label, a restyled section — because you send only the changed lines, not the whole component. Use create-artifact with `artifactId` only for a rewrite where most of the code changes.", + "Each edit's `oldText` must appear EXACTLY ONCE in the current source, verbatim (whitespace included); include enough surrounding lines to make it unique, or set `replaceAll: true` to change every occurrence. Edits apply in order, each seeing the previous one's result.", + "The batch is atomic: if any edit fails to match, nothing is saved and the error returns the current source in structuredContent.code — rebuild the edits from that instead of calling show-artifact again.", + "The edited component is validated and smoke-rendered exactly like a create, and connection bindings are re-resolved from the result; pass `connections` if an edit introduces an ambiguous integration.", + ].join("\n"), + inputSchema: { + artifactId: z + .string() + .trim() + .min(1) + .describe("The artifact to edit, from `list-artifacts` or a previous create."), + edits: z + .array( + z.object({ + oldText: z + .string() + .min(1) + .describe( + "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.", + ), + newText: z.string().describe("The replacement text."), + replaceAll: z + .boolean() + .optional() + .describe("Replace every occurrence instead of requiring a unique match."), + }), + ) + .min(1) + .describe("Find-and-replace edits, applied in order. All-or-nothing."), + connections: z + .record(z.string(), z.string()) + .optional() + .describe( + "Connection for each integration role the EDITED code uses, exactly as on create-artifact. Only needed when an edit introduces an integration with several connections.", + ), + title: z + .string() + .trim() + .min(1) + .optional() + .describe("New title. Omit to keep the current one."), + description: z + .string() + .optional() + .describe("New description. Omit to keep the current one."), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, + }, + }, + ({ artifactId, edits, connections, title, description }) => + runToolEffect(editArtifact({ artifactId, edits, connections, title, description })), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "edit-artifact" }, + }), + ); + yield* Effect.sync(() => server.registerTool( "list-artifacts",