History

Add edit-artifact: patch-based artifact updates

Rhys Sullivan committed 3 months ago

Commit e8b4dad1 parent 7eb795d5 files · +605 −62Browse files

Unified · Split

5 files
  1. e2e/scenarios/artifacts.test.ts +7 −1
  2. packages/core/execution/src/skills.ts +22 −12
  3. packages/hosts/mcp/src/artifacts-tools.test.ts +293 −1
  4. packages/hosts/mcp/src/create-artifact.ts +82 −0
  5. packages/hosts/mcp/src/tool-server.ts +201 −48
e2e/scenarios/artifacts.test.ts+7−1
@@ -791,6 +791,7 @@ scenario( · 790 unmodified lines
791791
792792 const ARTIFACT_TOOLS = [
793793 "create-artifact",
794+ "edit-artifact",
794795 "list-artifacts",
795796 "show-artifact",
796797 "execute-action",
@@ -818,7 +819,12 @@ scenario( · 21 unmodified lines
818819 // URL, gets everything by default. Without this the assertions above would
819820 // also pass on a server that had simply lost artifacts.
820821 const defaultTools = yield* defaultSession.listTools();
821− for (const tool of ["create-artifact", "list-artifacts", "show-artifact"] as const) {
822+ for (const tool of [
823+ "create-artifact",
824+ "edit-artifact",
825+ "list-artifacts",
826+ "show-artifact",
827+ ] as const) {
822828 expect(defaultTools, `${tool} is present on a default session`).toContain(tool);
823829 }
824830 const defaultSkills = yield* defaultSession.call("skills", {});
packages/core/execution/src/skills.ts+22−12
@@ -113,22 +113,32 @@ const CREATE_ARTIFACT_SKILL_BODY = [ · 112 unmodified lines
113113 "UPDATING that artifact, not making another one. A second artifact for a revision",
114114 "leaves the user with two near-identical entries and a stale link to the old one.",
115115 "",
116− "1. `show-artifact({ id })` to get the current source back. Do not rewrite from memory.",
117− "2. Edit that source.",
118− "3. `create-artifact` with the same `artifactId` and the complete edited component.",
116+ "For a TWEAK — a new column, a fixed label, a restyled section — use `edit-artifact`:",
117+ "exact find-and-replace patches against the stored source, so you send only the",
118+ "changed lines instead of re-emitting the whole component.",
119119 "",
120120 "```",
121− "code: '<the whole edited component>'",
122121 'artifactId: "art_..."',
122+ "edits: [{ oldText: '<exact current text>', newText: '<replacement>' }]",
123123 "```",
124124 "",
125− "`code` fully replaces what was stored — send the whole component, never a fragment",
126− "or a diff. `title` and `description` are optional on an update: leave them out to",
127− "keep the current ones, pass them to change them. Connection bindings are",
128− "re-resolved from the new code, so a call to a new integration binds on the update",
129− "the same way it would on a create (and pass `connections` if it is ambiguous).",
130− "The artifact keeps its id and its URL, so anyone holding the link sees the new",
131− "version.",
125+ "Each `oldText` must match the current source verbatim (whitespace included) and",
126+ "exactly once — include surrounding lines to disambiguate, or set",
127+ "`replaceAll: true` to change every occurrence. Edits apply in order, each seeing",
128+ "the previous one's result, and the batch is atomic: on any miss nothing is saved",
129+ "and the error returns the current source in `structuredContent.code`, so rebuild",
130+ "the retry from that rather than calling `show-artifact` again. If you don't have",
131+ "the source at all (the artifact came from `list-artifacts`), `show-artifact` once",
132+ "and edit from what it returns — never from memory.",
133+ "",
134+ "For a REWRITE, where most of the code changes, edits would be longer than the code:",
135+ "call `create-artifact` with the same `artifactId` and the complete new component.",
136+ "`code` then fully replaces what was stored — the whole component, never a fragment.",
137+ "",
138+ "Both paths validate and smoke-render the result identically, keep the artifact's id",
139+ "and URL, and re-resolve connection bindings from the resulting code (pass",
140+ "`connections` if it is ambiguous). `title` and `description` are optional on both:",
141+ "leave them out to keep the current ones.",
132142 "",
133143 "Only omit `artifactId` when the user genuinely wants a NEW, separate UI.",
134144 "",
@@ -331,7 +341,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [ · 196 unmodified lines
331341 "",
332342 "- `list-artifacts` returns every saved artifact's id, title, description and last-updated time.",
333343 "- `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.",
334− '- To change one, edit that source and call `create-artifact` with its `artifactId` — see "Changing An Artifact That Already Exists" above.',
344+ '- To change one, patch it with `edit-artifact` (or rewrite it with `create-artifact` + `artifactId`) — see "Changing An Artifact That Already Exists" above.',
335345 "- 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.",
336346 ].join("\n");
337347
packages/hosts/mcp/src/artifacts-tools.test.ts+293−1
@@ -208,9 +208,10 @@ describe("MCP host — artifact tool visibility", () => { · 207 unmodified lines
208208 NO_APPS_CAPS,
209209 async (client) => {
210210 const names = await toolNames(client);
211− // The model-facing three are always advertised — they degrade to a
211+ // The model-facing tools are always advertised — they degrade to a
212212 // deep link rather than disappearing.
213213 expect(names).toContain("create-artifact");
214+ expect(names).toContain("edit-artifact");
214215 expect(names).toContain("list-artifacts");
215216 expect(names).toContain("show-artifact");
216217 // `execute-action` is only callable from inside a rendered app.
@@ -491,6 +492,7 @@ describe("MCP host — artifact tool visibility", () => { · 274 unmodified lines
491492 async (client) => {
492493 const names = await toolNames(client);
493494 expect(names).not.toContain("create-artifact");
495+ expect(names).not.toContain("edit-artifact");
494496 expect(names).not.toContain("list-artifacts");
495497 expect(names).not.toContain("show-artifact");
496498 expect(names).not.toContain("execute-action");
@@ -554,6 +556,7 @@ describe("MCP host — artifact tool visibility", () => { · 57 unmodified lines
554556 async (client) => {
555557 const names = await toolNames(client);
556558 expect(names).toContain("create-artifact");
559+ expect(names).toContain("edit-artifact");
557560 expect(names).toContain("list-artifacts");
558561 expect(names).toContain("show-artifact");
559562 expect(names).toContain("execute-action");
@@ -1683,6 +1686,295 @@ describe("MCP host — create-artifact update in place", () => { · 1123 unmodified lines
16831686 });
16841687 });
16851688
1689+// ---------------------------------------------------------------------------
1690+// edit-artifact — patch-based updates
1691+// ---------------------------------------------------------------------------
1692+//
1693+// The contract: an edit is exact find-and-replace against the stored source,
1694+// applied in order, all-or-nothing, and the result goes through the same
1695+// validate → smoke-render → bind → save pipeline as a full create. A failed
1696+// batch changes nothing and hands the current source back so the retry needs
1697+// no show-artifact round trip.
1698+
1699+describe("MCP host — edit-artifact", () => {
1700+ const seed = async (client: Client, code: string = COUNTER_CODE) =>
1701+ client.callTool({
1702+ name: "create-artifact",
1703+ arguments: { code, title: "Draft", description: "First pass" },
1704+ });
1705+
1706+ it("patches the stored source and delivers the edited component", async () => {
1707+ const store = makeArtifactStore();
1708+ await withClient(
1709+ makeStubEngine({}),
1710+ APPS_CAPS,
1711+ async (client) => {
1712+ await seed(client);
1713+ const edited = await client.callTool({
1714+ name: "edit-artifact",
1715+ arguments: {
1716+ artifactId: "art_1",
1717+ edits: [{ oldText: "<div>hi</div>", newText: "<div>hi again</div>" }],
1718+ },
1719+ });
1720+ expect(edited.isError).toBeFalsy();
1721+ expect(structuredOf(edited)).toEqual({ code: UPDATED_CODE, artifactId: "art_1" });
1722+ expect(store.rows.get("art_1")).toMatchObject({
1723+ code: UPDATED_CODE,
1724+ // An edit that says nothing about title/description keeps them.
1725+ title: "Draft",
1726+ description: "First pass",
1727+ });
1728+ // One artifact, not two.
1729+ expect(store.rows.size).toBe(1);
1730+ },
1731+ { artifacts: store.port },
1732+ );
1733+ });
1734+
1735+ it("applies edits in order, each against the previous result", async () => {
1736+ const store = makeArtifactStore();
1737+ await withClient(
1738+ makeStubEngine({}),
1739+ APPS_CAPS,
1740+ async (client) => {
1741+ await seed(client);
1742+ const edited = await client.callTool({
1743+ name: "edit-artifact",
1744+ arguments: {
1745+ artifactId: "art_1",
1746+ edits: [
1747+ { oldText: "<div>hi</div>", newText: "<div>hello</div>" },
1748+ // Matches only the first edit's output, proving sequencing.
1749+ { oldText: "<div>hello</div>", newText: "<div>hello there</div>" },
1750+ ],
1751+ },
1752+ });
1753+ expect(edited.isError).toBeFalsy();
1754+ expect(store.rows.get("art_1")?.code).toBe(
1755+ "function App() { return <div>hello there</div>; }",
1756+ );
1757+ },
1758+ { artifacts: store.port },
1759+ );
1760+ });
1761+
1762+ it("replaces every occurrence only under replaceAll, and refuses ambiguity otherwise", async () => {
1763+ const store = makeArtifactStore();
1764+ const REPEATED = "function App() { return <div><b>x</b><b>x</b></div>; }";
1765+ await withClient(
1766+ makeStubEngine({}),
1767+ APPS_CAPS,
1768+ async (client) => {
1769+ await seed(client, REPEATED);
1770+
1771+ const ambiguous = await client.callTool({
1772+ name: "edit-artifact",
1773+ arguments: {
1774+ artifactId: "art_1",
1775+ edits: [{ oldText: "<b>x</b>", newText: "<b>y</b>" }],
1776+ },
1777+ });
1778+ expect(ambiguous.isError).toBe(true);
1779+ expect(textOf(ambiguous)).toContain("appears 2 times");
1780+ expect(textOf(ambiguous)).toContain("replaceAll");
1781+ expect(store.rows.get("art_1")?.code).toBe(REPEATED);
1782+
1783+ const replaced = await client.callTool({
1784+ name: "edit-artifact",
1785+ arguments: {
1786+ artifactId: "art_1",
1787+ edits: [{ oldText: "<b>x</b>", newText: "<b>y</b>", replaceAll: true }],
1788+ },
1789+ });
1790+ expect(replaced.isError).toBeFalsy();
1791+ expect(store.rows.get("art_1")?.code).toBe(
1792+ "function App() { return <div><b>y</b><b>y</b></div>; }",
1793+ );
1794+ },
1795+ { artifacts: store.port },
1796+ );
1797+ });
1798+
1799+ it("rejects the whole batch on a miss, returns the current source, and saves nothing", async () => {
1800+ const store = makeArtifactStore();
1801+ await withClient(
1802+ makeStubEngine({}),
1803+ APPS_CAPS,
1804+ async (client) => {
1805+ await seed(client);
1806+ const missed = await client.callTool({
1807+ name: "edit-artifact",
1808+ arguments: {
1809+ artifactId: "art_1",
1810+ edits: [
1811+ { oldText: "<div>hi</div>", newText: "<div>hello</div>" },
1812+ { oldText: "not in the source", newText: "anything" },
1813+ ],
1814+ },
1815+ });
1816+ expect(missed.isError).toBe(true);
1817+ expect(textOf(missed)).toContain("Edit 2 of 2");
1818+ expect(textOf(missed)).toContain("matched nothing");
1819+ // The retry material: the stored source rides along on the error.
1820+ expect(structuredOf(missed)).toMatchObject({ code: COUNTER_CODE });
1821+ // Atomic: the first (valid) edit was not half-applied.
1822+ expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE);
1823+ expect(store.calls.length).toBe(1);
1824+ },
1825+ { artifacts: store.port },
1826+ );
1827+ });
1828+
1829+ it("validates the edited result like a create, and leaves the stored row untouched", async () => {
1830+ const store = makeArtifactStore();
1831+ await withClient(
1832+ makeStubEngine({}),
1833+ APPS_CAPS,
1834+ async (client) => {
1835+ await seed(client);
1836+ const rejected = await client.callTool({
1837+ name: "edit-artifact",
1838+ arguments: {
1839+ artifactId: "art_1",
1840+ edits: [
1841+ {
1842+ oldText: "return <div>hi</div>;",
1843+ newText: "const Card = 1; return <div>hi</div>;",
1844+ },
1845+ ],
1846+ },
1847+ });
1848+ expect(rejected.isError).toBe(true);
1849+ expect(textOf(rejected)).toContain("cannot be redeclared");
1850+ expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE);
1851+ expect(store.calls.length).toBe(1);
1852+ },
1853+ { artifacts: store.port },
1854+ );
1855+ });
1856+
1857+ it("smoke-renders the edited result, so a patch cannot break a working artifact", async () => {
1858+ const store = makeArtifactStore();
1859+ const smokeRenderArtifact = vi.fn((code: string) =>
1860+ Promise.resolve(
1861+ code.includes("boom")
1862+ ? { status: "failed" as const, message: "boom is not defined" }
1863+ : { status: "ok" as const },
1864+ ),
1865+ );
1866+ await withClient(
1867+ makeStubEngine({}),
1868+ APPS_CAPS,
1869+ async (client) => {
1870+ await seed(client);
1871+ const broken = await client.callTool({
1872+ name: "edit-artifact",
1873+ arguments: {
1874+ artifactId: "art_1",
1875+ edits: [{ oldText: "return", newText: "boom(); return" }],
1876+ },
1877+ });
1878+ expect(broken.isError).toBe(true);
1879+ expect(textOf(broken)).toContain("boom is not defined");
1880+ expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE);
1881+ // The candidate it rendered was the EDITED source, not the stored one.
1882+ expect(smokeRenderArtifact).toHaveBeenLastCalledWith(
1883+ expect.stringContaining("boom(); return"),
1884+ );
1885+ },
1886+ { artifacts: store.port, smokeRenderArtifact },
1887+ );
1888+ });
1889+
1890+ it("refuses an artifact id that is not the caller's, without saying whether it exists", async () => {
1891+ const store = makeArtifactStore();
1892+ await withClient(
1893+ makeStubEngine({}),
1894+ APPS_CAPS,
1895+ async (client) => {
1896+ const result = await client.callTool({
1897+ name: "edit-artifact",
1898+ arguments: {
1899+ artifactId: "art_someone_else",
1900+ edits: [{ oldText: "a", newText: "b" }],
1901+ },
1902+ });
1903+ expect(result.isError).toBe(true);
1904+ // The probe-proof answer, with no stored source riding along.
1905+ expect(structuredOf(result)).toMatchObject({ error: "artifact_unavailable" });
1906+ expect(structuredOf(result)).not.toHaveProperty("code");
1907+ expect(store.calls).toEqual([]);
1908+ },
1909+ { artifacts: store.port },
1910+ );
1911+ });
1912+
1913+ it("re-resolves bindings when an edit introduces an integration", async () => {
1914+ const store = makeArtifactStore();
1915+ await withClient(
1916+ makeStubEngine({}),
1917+ APPS_CAPS,
1918+ async (client) => {
1919+ await seed(client);
1920+ expect(store.calls[0]?.bindings).toEqual({});
1921+ const edited = await client.callTool({
1922+ name: "edit-artifact",
1923+ arguments: {
1924+ artifactId: "art_1",
1925+ edits: [
1926+ {
1927+ oldText: "return <div>hi</div>;",
1928+ newText:
1929+ "const q = useQuery(tools.vercel.domains.getDomains.queryOptions({})); return <div>hi</div>;",
1930+ },
1931+ ],
1932+ },
1933+ });
1934+ expect(edited.isError).toBeFalsy();
1935+ expect(store.calls[1]?.bindings).toEqual({
1936+ vercel: {
1937+ integration: "vercel",
1938+ owner: "user",
1939+ connection: "personalVercel",
1940+ },
1941+ });
1942+ },
1943+ {
1944+ artifacts: store.port,
1945+ connections: connectionsPort([conn("vercel", "user", "personalVercel")]),
1946+ },
1947+ );
1948+ });
1949+
1950+ it("updates the title and description when asked, keeping the patched code", async () => {
1951+ const store = makeArtifactStore();
1952+ await withClient(
1953+ makeStubEngine({}),
1954+ APPS_CAPS,
1955+ async (client) => {
1956+ await seed(client);
1957+ const edited = await client.callTool({
1958+ name: "edit-artifact",
1959+ arguments: {
1960+ artifactId: "art_1",
1961+ edits: [{ oldText: "hi", newText: "hi again" }],
1962+ title: "Active users dashboard",
1963+ description: "Second pass",
1964+ },
1965+ });
1966+ expect(edited.isError).toBeFalsy();
1967+ expect(store.rows.get("art_1")).toMatchObject({
1968+ title: "Active users dashboard",
1969+ description: "Second pass",
1970+ code: UPDATED_CODE,
1971+ });
1972+ },
1973+ { artifacts: store.port },
1974+ );
1975+ });
1976+});
1977+
16861978 // ---------------------------------------------------------------------------
16871979 // create-artifact — connection binding
16881980 // ---------------------------------------------------------------------------
packages/hosts/mcp/src/create-artifact.ts+82−0
@@ -198,6 +198,88 @@ const hookCalledInLoop = (code: string): string | undefined => { · 197 unmodified lines
198198 /** Hooks whose in-loop use is almost always hand-rolled cursor pagination. */
199199 const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]);
200200
201+// ---------------------------------------------------------------------------
202+// Edits
203+// ---------------------------------------------------------------------------
204+//
205+// `edit-artifact`'s pure half. An edit is an exact find-and-replace against the
206+// stored source, so a model changing one column of a 300-line dashboard sends
207+// the changed lines rather than re-emitting the whole component — the tool
208+// call's size scales with the edit, not the artifact.
209+//
210+// The semantics are the ones every model already knows from its own file-edit
211+// tool: `oldText` must match verbatim (whitespace included) and exactly once,
212+// unless `replaceAll` opts into every occurrence. Edits apply in order, each
213+// against the result of the previous, and the whole batch is atomic — any
214+// miss rejects the call and nothing is saved. Ambiguity is refused rather than
215+// resolved by picking the first hit, because a wrong pick would save silently
216+// and the user would see the wrong line change.
217+
218+export type ArtifactEdit = {
219+ /** Exact text to find in the current source. */
220+ readonly oldText: string;
221+ /** What to put in its place. */
222+ readonly newText: string;
223+ /** Replace every occurrence instead of requiring `oldText` to be unique. */
224+ readonly replaceAll?: boolean | undefined;
225+};
226+
227+export type AppliedArtifactEdits =
228+ | { readonly ok: true; readonly code: string }
229+ | { readonly ok: false; readonly message: string };
230+
231+const countOccurrences = (haystack: string, needle: string): number =>
232+ haystack.split(needle).length - 1;
233+
234+/** The edited source, or the first edit that could not be applied. Messages
235+ * name the edit by position and say what to do, because the model retries
236+ * from the message alone. */
237+export const applyArtifactEdits = (
238+ code: string,
239+ edits: readonly ArtifactEdit[],
240+): AppliedArtifactEdits => {
241+ let current = code;
242+ for (const [index, edit] of edits.entries()) {
243+ const position = `Edit ${index + 1} of ${edits.length}`;
244+ if (edit.oldText === "") {
245+ return { ok: false, message: `${position} has an empty oldText, which matches nothing.` };
246+ }
247+ if (edit.oldText === edit.newText) {
248+ return {
249+ ok: false,
250+ message: `${position} is a no-op: oldText and newText are identical.`,
251+ };
252+ }
253+ const occurrences = countOccurrences(current, edit.oldText);
254+ if (occurrences === 0) {
255+ return {
256+ ok: false,
257+ message: [
258+ `${position} matched nothing: its oldText does not appear in the current source.`,
259+ "Copy oldText verbatim from the source, whitespace included",
260+ index > 0 ? "— and remember each edit sees the result of the earlier ones." : ".",
261+ ].join(" "),
262+ };
263+ }
264+ if (occurrences > 1 && edit.replaceAll !== true) {
265+ return {
266+ ok: false,
267+ message: [
268+ `${position} is ambiguous: its oldText appears ${occurrences} times in the current source.`,
269+ "Include enough surrounding text to make it unique, or set replaceAll: true to change every occurrence.",
270+ ].join(" "),
271+ };
272+ }
273+ // split/join for both arms: `String.replace` gives `$&` and friends in the
274+ // replacement special meaning, and generated JSX is full of `$`.
275+ current =
276+ edit.replaceAll === true
277+ ? current.split(edit.oldText).join(edit.newText)
278+ : current.replace(edit.oldText, () => edit.newText);
279+ }
280+ return { ok: true, code: current };
281+};
282+
201283 // ---------------------------------------------------------------------------
202284 // Create-time smoke render
203285 // ---------------------------------------------------------------------------
packages/hosts/mcp/src/tool-server.ts+201−48
@@ -53,8 +53,10 @@ import { · 52 unmodified lines
5353 } from "@executor-js/execution";
5454 import {
5555 MCP_APPS_SHELL_RESOURCE_URI,
56+ applyArtifactEdits,
5657 smokeRenderRejection,
5758 validateArtifactCode,
59+ type ArtifactEdit,
5860 type ArtifactSmokeRenderResult,
5961 } from "./create-artifact";
6062 import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
@@ -846,6 +848,22 @@ const renderRejectedResult = (reason: string): McpToolResult => ({ · 785 unmodified lines
846848 isError: true,
847849 });
848850
851+/** An edit batch that could not be applied. Carries the current stored source
852+ * so the model can rebuild its edits without a `show-artifact` round trip. */
853+const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({
854+ content: [
855+ {
856+ type: "text",
857+ text: [
858+ `edit-artifact rejected: ${reason}`,
859+ "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.",
860+ ].join("\n"),
861+ },
862+ ],
863+ structuredContent: { status: "error", error: reason, code: currentCode },
864+ isError: true,
865+});
866+
849867 /** `execute-action` was handed something other than a single proxy-shaped tool
850868 * call. Names the contract rather than just refusing, since the reader is
851869 * either a confused iframe or someone probing the app channel by hand. */
@@ -1655,53 +1673,21 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 803 unmodified lines
16551673 };
16561674
16571675 /**
1658− * Bind the integration roles an artifact's code uses, at create time.
1659− *
1660− * Binding happens HERE rather than at render time because this is the only
1661− * moment the author, the code and their connections are all in hand — and
1662− * because a create that can't bind is a create that would have saved a
1663− * broken artifact. The model finds out now, with the candidate list, rather
1664− * than the user finding out later through a query error inside the UI.
1665− *
1666− * `artifactId` turns the same call into an update in place. A model tweaking
1667− * a dashboard has the whole component in hand already — it fetched it with
1668− * `show-artifact`, edited it, and is calling back with the result — so a
1669− * separate `update-artifact` tool would take the same four arguments and
1670− * differ only in whether a row is minted. One tool keeps the catalog small
1671− * and makes the wrong thing (a copy per tweak) the thing the model has to
1672− * ask for rather than the thing it gets by default.
1673− *
1674− * An update replaces the code outright — v1 keeps no version history — and
1675− * re-extracts and re-resolves the bindings from the NEW source, because the
1676− * roles the new code uses are not necessarily the ones the old code did.
1677− * `title` and `description` are optional on an update and absent means keep
1678− * what is stored, so a pure code tweak doesn't have to restate them.
1676+ * The shared back half of `create-artifact` and `edit-artifact`: everything
1677+ * that happens once the full candidate source is in hand. Static checks,
1678+ * the smoke render, binding and the save are identical whether the code
1679+ * arrived whole or was assembled from stored source plus edits — sharing
1680+ * the pipeline is what guarantees an edit cannot save anything a create
1681+ * would have refused.
16791682 */
1680− const createArtifact = (input: {
1683+ const validateRenderAndSave = (input: {
16811684 readonly code: string;
1682− readonly title?: string;
1683− readonly description?: string;
1684− readonly connections?: Readonly<Record<string, string>>;
1685− readonly artifactId?: string;
1685+ readonly title: string;
1686+ readonly description?: string | undefined;
1687+ readonly connections?: Readonly<Record<string, string>> | undefined;
1688+ readonly existing: Artifact | null;
16861689 }): Effect.Effect<McpToolResult, unknown> =>
16871690 Effect.gen(function* () {
1688− // An update reads the existing row FIRST, both to carry its title and
1689− // description forward and to refuse a foreign id before any work. The
1690− // refusal is `artifact_unavailable` — the same answer `execute-action`
1691− // gives — so create-artifact cannot be used to probe which ids exist.
1692− const existing =
1693− input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId);
1694− if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult();
1695−
1696− const title = input.title ?? existing?.title;
1697− if (title === undefined) {
1698− return renderRejectedResult(
1699− "title is required when creating an artifact. Give it a short human-readable name.",
1700− );
1701− }
1702− // Only an update inherits; a create with no description stores none.
1703− const description = input.description ?? existing?.description ?? undefined;
1704−
17051691 const rejection = validateArtifactCode(input.code);
17061692 if (rejection) return renderRejectedResult(rejection);
17071693
@@ -1744,10 +1730,10 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 36 unmodified lines
17441730
17451731 const saveInput = {
17461732 code: input.code,
1747− title,
1733+ title: input.title,
17481734 preview,
1749− ...(description === undefined ? {} : { description }),
1750− ...(existing === null ? {} : { existingId: existing.id }),
1735+ ...(input.description === undefined ? {} : { description: input.description }),
1736+ ...(input.existing === null ? {} : { existingId: input.existing.id }),
17511737 };
17521738
17531739 const roles = extractArtifactRoles(input.code);
@@ -1775,6 +1761,61 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 21 unmodified lines
17751761 "mcp.artifact.role_count": roles.length,
17761762 });
17771763 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings });
1764+ });
1765+
1766+ /**
1767+ * Bind the integration roles an artifact's code uses, at create time.
1768+ *
1769+ * Binding happens HERE rather than at render time because this is the only
1770+ * moment the author, the code and their connections are all in hand — and
1771+ * because a create that can't bind is a create that would have saved a
1772+ * broken artifact. The model finds out now, with the candidate list, rather
1773+ * than the user finding out later through a query error inside the UI.
1774+ *
1775+ * `artifactId` turns the same call into an update in place — for a REWRITE,
1776+ * where the new source shares little with the old and edits would be longer
1777+ * than the code. A tweak belongs on `edit-artifact`, which patches the
1778+ * stored source instead of replacing it. Either way one row is kept: a copy
1779+ * per revision is the thing the model has to ask for, never the default.
1780+ *
1781+ * An update replaces the code outright — v1 keeps no version history — and
1782+ * re-extracts and re-resolves the bindings from the NEW source, because the
1783+ * roles the new code uses are not necessarily the ones the old code did.
1784+ * `title` and `description` are optional on an update and absent means keep
1785+ * what is stored, so a pure code tweak doesn't have to restate them.
1786+ */
1787+ const createArtifact = (input: {
1788+ readonly code: string;
1789+ readonly title?: string;
1790+ readonly description?: string;
1791+ readonly connections?: Readonly<Record<string, string>>;
1792+ readonly artifactId?: string;
1793+ }): Effect.Effect<McpToolResult, unknown> =>
1794+ Effect.gen(function* () {
1795+ // An update reads the existing row FIRST, both to carry its title and
1796+ // description forward and to refuse a foreign id before any work. The
1797+ // refusal is `artifact_unavailable` — the same answer `execute-action`
1798+ // gives — so create-artifact cannot be used to probe which ids exist.
1799+ const existing =
1800+ input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId);
1801+ if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult();
1802+
1803+ const title = input.title ?? existing?.title;
1804+ if (title === undefined) {
1805+ return renderRejectedResult(
1806+ "title is required when creating an artifact. Give it a short human-readable name.",
1807+ );
1808+ }
1809+ // Only an update inherits; a create with no description stores none.
1810+ const description = input.description ?? existing?.description ?? undefined;
1811+
1812+ return yield* validateRenderAndSave({
1813+ code: input.code,
1814+ title,
1815+ description,
1816+ connections: input.connections,
1817+ existing,
1818+ });
17781819 }).pipe(
17791820 Effect.withSpan("mcp.host.tool.create_artifact", {
17801821 attributes: {
@@ -1785,6 +1826,52 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 4 unmodified lines
17851826 }),
17861827 );
17871828
1829+ /**
1830+ * `edit-artifact`: the update path for tweaks, patching the stored source
1831+ * with exact find-and-replace edits so the call scales with the change
1832+ * rather than the component. The edited result runs the same
1833+ * validate → smoke-render → bind → save pipeline as a full create, so an
1834+ * edit cannot save anything a create would have refused.
1835+ *
1836+ * A failed edit hands the CURRENT source back in `structuredContent.code`.
1837+ * The model's usual recovery — `show-artifact`, re-read, retry — is a whole
1838+ * extra round trip to fetch a thing this call already loaded; giving it
1839+ * back here makes the retry immediate.
1840+ */
1841+ const editArtifact = (input: {
1842+ readonly artifactId: string;
1843+ readonly edits: readonly ArtifactEdit[];
1844+ readonly title?: string;
1845+ readonly description?: string;
1846+ readonly connections?: Readonly<Record<string, string>>;
1847+ }): Effect.Effect<McpToolResult, unknown> =>
1848+ Effect.gen(function* () {
1849+ // Same probe-proof refusal as create-artifact's update arm.
1850+ const existing = yield* loadArtifact(input.artifactId);
1851+ if (!existing) return actionArtifactUnavailableResult();
1852+
1853+ const applied = applyArtifactEdits(existing.code, input.edits);
1854+ if (!applied.ok) return editRejectedResult(applied.message, existing.code);
1855+
1856+ yield* Effect.annotateCurrentSpan({
1857+ "mcp.artifact.edit_count": input.edits.length,
1858+ });
1859+ return yield* validateRenderAndSave({
1860+ code: applied.code,
1861+ title: input.title ?? existing.title,
1862+ description: input.description ?? existing.description ?? undefined,
1863+ connections: input.connections,
1864+ existing,
1865+ });
1866+ }).pipe(
1867+ Effect.withSpan("mcp.host.tool.edit_artifact", {
1868+ attributes: {
1869+ "mcp.tool.name": "edit-artifact",
1870+ "mcp.artifact.id": input.artifactId,
1871+ },
1872+ }),
1873+ );
1874+
17881875 const listArtifacts = (): Effect.Effect<McpToolResult, unknown> =>
17891876 Effect.gen(function* () {
17901877 if (!artifacts) return artifactsUnavailableResult();
@@ -1863,7 +1950,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 72 unmodified lines
18631950 '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`.',
18641951 "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.",
18651952 "To read every page of a paginated tool, call `useInfiniteQuery(tools.<integration>.<tool>.infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.",
1866− "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.",
1953+ "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.",
18671954 "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.",
18681955 ].join("\n"),
18691956 inputSchema: {
@@ -1874,7 +1961,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 4 unmodified lines
18741961 .min(1)
18751962 .optional()
18761963 .describe(
1877− "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.",
1964+ "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.",
18781965 ),
18791966 connections: z
18801967 .record(z.string(), z.string())
@@ -1910,6 +1997,72 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 29 unmodified lines
19101997 }),
19111998 );
19121999
2000+ yield* Effect.sync(() =>
2001+ registerAppTool(
2002+ server,
2003+ "edit-artifact",
2004+ {
2005+ description: [
2006+ "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.",
2007+ "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.",
2008+ "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.",
2009+ "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.",
2010+ "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.",
2011+ ].join("\n"),
2012+ inputSchema: {
2013+ artifactId: z
2014+ .string()
2015+ .trim()
2016+ .min(1)
2017+ .describe("The artifact to edit, from `list-artifacts` or a previous create."),
2018+ edits: z
2019+ .array(
2020+ z.object({
2021+ oldText: z
2022+ .string()
2023+ .min(1)
2024+ .describe(
2025+ "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.",
2026+ ),
2027+ newText: z.string().describe("The replacement text."),
2028+ replaceAll: z
2029+ .boolean()
2030+ .optional()
2031+ .describe("Replace every occurrence instead of requiring a unique match."),
2032+ }),
2033+ )
2034+ .min(1)
2035+ .describe("Find-and-replace edits, applied in order. All-or-nothing."),
2036+ connections: z
2037+ .record(z.string(), z.string())
2038+ .optional()
2039+ .describe(
2040+ "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.",
2041+ ),
2042+ title: z
2043+ .string()
2044+ .trim()
2045+ .min(1)
2046+ .optional()
2047+ .describe("New title. Omit to keep the current one."),
2048+ description: z
2049+ .string()
2050+ .optional()
2051+ .describe("New description. Omit to keep the current one."),
2052+ },
2053+ _meta: {
2054+ ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2055+ },
2056+ },
2057+ ({ artifactId, edits, connections, title, description }) =>
2058+ runToolEffect(editArtifact({ artifactId, edits, connections, title, description })),
2059+ ),
2060+ ).pipe(
2061+ Effect.withSpan("mcp.host.register_tool", {
2062+ attributes: { "mcp.tool.name": "edit-artifact" },
2063+ }),
2064+ );
2065+
19132066 yield* Effect.sync(() =>
19142067 server.registerTool(
19152068 "list-artifacts",