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
791 791
792 const ARTIFACT_TOOLS = [792 const ARTIFACT_TOOLS = [
793 "create-artifact",793 "create-artifact",
794 "edit-artifact",
794 "list-artifacts",795 "list-artifacts",
795 "show-artifact",796 "show-artifact",
796 "execute-action",797 "execute-action",
@@ -818,7 +819,12 @@ scenario( · 21 unmodified lines
818 // URL, gets everything by default. Without this the assertions above would819 // URL, gets everything by default. Without this the assertions above would
819 // also pass on a server that had simply lost artifacts.820 // also pass on a server that had simply lost artifacts.
820 const defaultTools = yield* defaultSession.listTools();821 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) {
822 expect(defaultTools, `${tool} is present on a default session`).toContain(tool);828 expect(defaultTools, `${tool} is present on a default session`).toContain(tool);
823 }829 }
824 const defaultSkills = yield* defaultSession.call("skills", {});830 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
113 "UPDATING that artifact, not making another one. A second artifact for a revision",113 "UPDATING that artifact, not making another one. A second artifact for a revision",
114 "leaves the user with two near-identical entries and a stale link to the old one.",114 "leaves the user with two near-identical entries and a stale link to the old one.",
115 "",115 "",
116 "1. `show-artifact({ id })` to get the current source back. Do not rewrite from memory.",116 "For a TWEAK — a new column, a fixed label, a restyled section — use `edit-artifact`:",
117 "2. Edit that source.",117 "exact find-and-replace patches against the stored source, so you send only the",
118 "3. `create-artifact` with the same `artifactId` and the complete edited component.",118 "changed lines instead of re-emitting the whole component.",
119 "",119 "",
120 "```",120 "```",
121 "code: '<the whole edited component>'",
122 'artifactId: "art_..."',121 'artifactId: "art_..."',
122 "edits: [{ oldText: '<exact current text>', newText: '<replacement>' }]",
123 "```",123 "```",
124 "",124 "",
125 "`code` fully replaces what was stored — send the whole component, never a fragment",125 "Each `oldText` must match the current source verbatim (whitespace included) and",
126 "or a diff. `title` and `description` are optional on an update: leave them out to",126 "exactly once — include surrounding lines to disambiguate, or set",
127 "keep the current ones, pass them to change them. Connection bindings are",127 "`replaceAll: true` to change every occurrence. Edits apply in order, each seeing",
128 "re-resolved from the new code, so a call to a new integration binds on the update",128 "the previous one's result, and the batch is atomic: on any miss nothing is saved",
129 "the same way it would on a create (and pass `connections` if it is ambiguous).",129 "and the error returns the current source in `structuredContent.code`, so rebuild",
130 "The artifact keeps its id and its URL, so anyone holding the link sees the new",130 "the retry from that rather than calling `show-artifact` again. If you don't have",
131 "version.",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.",
132 "",142 "",
133 "Only omit `artifactId` when the user genuinely wants a NEW, separate UI.",143 "Only omit `artifactId` when the user genuinely wants a NEW, separate UI.",
134 "",144 "",
@@ -331,7 +341,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [ · 196 unmodified lines
331 "",341 "",
332 "- `list-artifacts` returns every saved artifact's id, title, description and last-updated time.",342 "- `list-artifacts` returns every saved artifact's id, title, description and last-updated time.",
333 "- `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.",343 "- `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.',
335 "- 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.",345 "- 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.",
336].join("\n");346].join("\n");
337 347
packages/hosts/mcp/src/artifacts-tools.test.ts+293−1
@@ -208,9 +208,10 @@ describe("MCP host — artifact tool visibility", () => { · 207 unmodified lines
208 NO_APPS_CAPS,208 NO_APPS_CAPS,
209 async (client) => {209 async (client) => {
210 const names = await toolNames(client);210 const names = await toolNames(client);
211 // The model-facing three are always advertised — they degrade to a211 // The model-facing tools are always advertised — they degrade to a
212 // deep link rather than disappearing.212 // deep link rather than disappearing.
213 expect(names).toContain("create-artifact");213 expect(names).toContain("create-artifact");
214 expect(names).toContain("edit-artifact");
214 expect(names).toContain("list-artifacts");215 expect(names).toContain("list-artifacts");
215 expect(names).toContain("show-artifact");216 expect(names).toContain("show-artifact");
216 // `execute-action` is only callable from inside a rendered app.217 // `execute-action` is only callable from inside a rendered app.
@@ -491,6 +492,7 @@ describe("MCP host — artifact tool visibility", () => { · 274 unmodified lines
491 async (client) => {492 async (client) => {
492 const names = await toolNames(client);493 const names = await toolNames(client);
493 expect(names).not.toContain("create-artifact");494 expect(names).not.toContain("create-artifact");
495 expect(names).not.toContain("edit-artifact");
494 expect(names).not.toContain("list-artifacts");496 expect(names).not.toContain("list-artifacts");
495 expect(names).not.toContain("show-artifact");497 expect(names).not.toContain("show-artifact");
496 expect(names).not.toContain("execute-action");498 expect(names).not.toContain("execute-action");
@@ -554,6 +556,7 @@ describe("MCP host — artifact tool visibility", () => { · 57 unmodified lines
554 async (client) => {556 async (client) => {
555 const names = await toolNames(client);557 const names = await toolNames(client);
556 expect(names).toContain("create-artifact");558 expect(names).toContain("create-artifact");
559 expect(names).toContain("edit-artifact");
557 expect(names).toContain("list-artifacts");560 expect(names).toContain("list-artifacts");
558 expect(names).toContain("show-artifact");561 expect(names).toContain("show-artifact");
559 expect(names).toContain("execute-action");562 expect(names).toContain("execute-action");
@@ -1683,6 +1686,295 @@ describe("MCP host — create-artifact update in place", () => { · 1123 unmodified lines
1683 });1686 });
1684});1687});
1685 1688
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
1699describe("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
1686// ---------------------------------------------------------------------------1978// ---------------------------------------------------------------------------
1687// create-artifact — connection binding1979// create-artifact — connection binding
1688// ---------------------------------------------------------------------------1980// ---------------------------------------------------------------------------
packages/hosts/mcp/src/create-artifact.ts+82−0
@@ -198,6 +198,88 @@ const hookCalledInLoop = (code: string): string | undefined => { · 197 unmodified lines
198/** Hooks whose in-loop use is almost always hand-rolled cursor pagination. */198/** Hooks whose in-loop use is almost always hand-rolled cursor pagination. */
199const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]);199const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]);
200 200
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
218export 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
227export type AppliedArtifactEdits =
228 | { readonly ok: true; readonly code: string }
229 | { readonly ok: false; readonly message: string };
230
231const 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. */
237export 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
201// ---------------------------------------------------------------------------283// ---------------------------------------------------------------------------
202// Create-time smoke render284// Create-time smoke render
203// ---------------------------------------------------------------------------285// ---------------------------------------------------------------------------
packages/hosts/mcp/src/tool-server.ts+201−48
@@ -53,8 +53,10 @@ import { · 52 unmodified lines
53} from "@executor-js/execution";53} from "@executor-js/execution";
54import {54import {
55 MCP_APPS_SHELL_RESOURCE_URI,55 MCP_APPS_SHELL_RESOURCE_URI,
56 applyArtifactEdits,
56 smokeRenderRejection,57 smokeRenderRejection,
57 validateArtifactCode,58 validateArtifactCode,
59 type ArtifactEdit,
58 type ArtifactSmokeRenderResult,60 type ArtifactSmokeRenderResult,
59} from "./create-artifact";61} from "./create-artifact";
60import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";62import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
@@ -846,6 +848,22 @@ const renderRejectedResult = (reason: string): McpToolResult => ({ · 785 unmodified lines
846 isError: true,848 isError: true,
847});849});
848 850
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. */
853const 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
849/** `execute-action` was handed something other than a single proxy-shaped tool867/** `execute-action` was handed something other than a single proxy-shaped tool
850 * call. Names the contract rather than just refusing, since the reader is868 * call. Names the contract rather than just refusing, since the reader is
851 * either a confused iframe or someone probing the app channel by hand. */869 * 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
1655 };1673 };
1656 1674
1657 /**1675 /**
1658 * Bind the integration roles an artifact's code uses, at create time.1676 * The shared back half of `create-artifact` and `edit-artifact`: everything
1659 *1677 * that happens once the full candidate source is in hand. Static checks,
1660 * Binding happens HERE rather than at render time because this is the only1678 * the smoke render, binding and the save are identical whether the code
1661 * moment the author, the code and their connections are all in hand — and1679 * arrived whole or was assembled from stored source plus edits — sharing
1662 * because a create that can't bind is a create that would have saved a1680 * the pipeline is what guarantees an edit cannot save anything a create
1663 * broken artifact. The model finds out now, with the candidate list, rather1681 * would have refused.
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.
1679 */1682 */
1680 const createArtifact = (input: {1683 const validateRenderAndSave = (input: {
1681 readonly code: string;1684 readonly code: string;
1682 readonly title?: string;1685 readonly title: string;
1683 readonly description?: string;1686 readonly description?: string | undefined;
1684 readonly connections?: Readonly<Record<string, string>>;1687 readonly connections?: Readonly<Record<string, string>> | undefined;
1685 readonly artifactId?: string;1688 readonly existing: Artifact | null;
1686 }): Effect.Effect<McpToolResult, unknown> =>1689 }): Effect.Effect<McpToolResult, unknown> =>
1687 Effect.gen(function* () {1690 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
1705 const rejection = validateArtifactCode(input.code);1691 const rejection = validateArtifactCode(input.code);
1706 if (rejection) return renderRejectedResult(rejection);1692 if (rejection) return renderRejectedResult(rejection);
1707 1693
@@ -1744,10 +1730,10 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 36 unmodified lines
1744 1730
1745 const saveInput = {1731 const saveInput = {
1746 code: input.code,1732 code: input.code,
1747 title,1733 title: input.title,
1748 preview,1734 preview,
1749 ...(description === undefined ? {} : { description }),1735 ...(input.description === undefined ? {} : { description: input.description }),
1750 ...(existing === null ? {} : { existingId: existing.id }),1736 ...(input.existing === null ? {} : { existingId: input.existing.id }),
1751 };1737 };
1752 1738
1753 const roles = extractArtifactRoles(input.code);1739 const roles = extractArtifactRoles(input.code);
@@ -1775,6 +1761,61 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 21 unmodified lines
1775 "mcp.artifact.role_count": roles.length,1761 "mcp.artifact.role_count": roles.length,
1776 });1762 });
1777 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings });1763 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 });
1778 }).pipe(1819 }).pipe(
1779 Effect.withSpan("mcp.host.tool.create_artifact", {1820 Effect.withSpan("mcp.host.tool.create_artifact", {
1780 attributes: {1821 attributes: {
@@ -1785,6 +1826,52 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 4 unmodified lines
1785 }),1826 }),
1786 );1827 );
1787 1828
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
1788 const listArtifacts = (): Effect.Effect<McpToolResult, unknown> =>1875 const listArtifacts = (): Effect.Effect<McpToolResult, unknown> =>
1789 Effect.gen(function* () {1876 Effect.gen(function* () {
1790 if (!artifacts) return artifactsUnavailableResult();1877 if (!artifacts) return artifactsUnavailableResult();
@@ -1863,7 +1950,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 72 unmodified lines
1863 '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`.',1950 '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`.',
1864 "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.",1951 "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.",
1865 "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.",1952 "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.",
1867 "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.",1954 "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.",
1868 ].join("\n"),1955 ].join("\n"),
1869 inputSchema: {1956 inputSchema: {
@@ -1874,7 +1961,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 4 unmodified lines
1874 .min(1)1961 .min(1)
1875 .optional()1962 .optional()
1876 .describe(1963 .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.",
1878 ),1965 ),
1879 connections: z1966 connections: z
1880 .record(z.string(), z.string())1967 .record(z.string(), z.string())
@@ -1910,6 +1997,72 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 29 unmodified lines
1910 }),1997 }),
1911 );1998 );
1912 1999
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
1913 yield* Effect.sync(() =>2066 yield* Effect.sync(() =>
1914 server.registerTool(2067 server.registerTool(
1915 "list-artifacts",2068 "list-artifacts",