Unified · Split
| @@ -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 would | 819 | // 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", {}); |
| @@ -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 | ||
| @@ -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 a | 211 | // 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 | |||
| 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 | |||
| 1686 | // --------------------------------------------------------------------------- | 1978 | // --------------------------------------------------------------------------- |
| 1687 | // create-artifact — connection binding | 1979 | // create-artifact — connection binding |
| 1688 | // --------------------------------------------------------------------------- | 1980 | // --------------------------------------------------------------------------- |
| @@ -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. */ |
| 199 | const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]); | 199 | const 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 | |||
| 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 | |||
| 201 | // --------------------------------------------------------------------------- | 283 | // --------------------------------------------------------------------------- |
| 202 | // Create-time smoke render | 284 | // Create-time smoke render |
| 203 | // --------------------------------------------------------------------------- | 285 | // --------------------------------------------------------------------------- |
| @@ -53,8 +53,10 @@ import { · 52 unmodified lines | |||
| 53 | } from "@executor-js/execution"; | 53 | } from "@executor-js/execution"; |
| 54 | import { | 54 | import { |
| 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"; |
| 60 | import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; | 62 | import { 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. */ | ||
| 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 | |||
| 849 | /** `execute-action` was handed something other than a single proxy-shaped tool | 867 | /** `execute-action` was handed something other than a single proxy-shaped tool |
| 850 | * call. Names the contract rather than just refusing, since the reader is | 868 | * 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 only | 1678 | * 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 — and | 1679 | * 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 a | 1680 | * the pipeline is what guarantees an edit cannot save anything a create |
| 1663 | * broken artifact. The model finds out now, with the candidate list, rather | 1681 | * 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: z | 1966 | 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", |