/** * The `create-artifact` tool's pure half: what the model is told, and what the server * refuses to render. * * The tool itself lives in `tool-server.ts` beside `execute` and `skills` — * this module holds only the description text and the code guard, so both can * be tested without standing up an MCP server. */ import { PROVIDED_GLOBAL_NAMES } from "@executor-js/execution"; import { oldStyleAddressRejection } from "./artifact-bindings"; /** The MCP-Apps resource the ui-bearing tools render into. */ export const MCP_APPS_SHELL_RESOURCE_URI = "ui://executor/shell.html"; /** * The web-app deep link for a saved artifact — the delivery path for clients * that can't render MCP Apps. One helper so every host emits the same shape; * hosts differ only in the origin and org slug they pass, and hosts with no * known origin (stdio) pass none and skip the link entirely. * * The console routes artifacts at `/{-$orgSlug}/artifacts/$artifactId`, where * the org segment is optional. Optional is not the same as absent: on a host * that scopes by org, a bare link resolves against whatever org the browser * happens to land on after auth, which is the wrong one as soon as a user * belongs to two. So every host that knows its session's org slug passes it, * exactly as `buildResumeApprovalUrl` does for the approval hand-off, and the * bare form is left to genuinely unscoped hosts. */ export const artifactUrlFor = (webBaseUrl: string, organizationSlug?: string | null) => (artifactId: string): string => { const orgPath = organizationSlug ? `/${encodeURIComponent(organizationSlug)}` : ""; return new URL(`${orgPath}/artifacts/${encodeURIComponent(artifactId)}`, webBaseUrl).toString(); }; // --------------------------------------------------------------------------- // Code guard // --------------------------------------------------------------------------- // // Generated code is evaluated inside the shell with ~280 names already bound as // function parameters (React hooks, TanStack Query, every shadcn/Recharts/Lucide // export, `tools`). A `const Card = ...` in the model's source shadows the real // binding and the component renders as a blank frame with a confusing runtime // error — so we reject redeclarations before the code ever reaches the iframe, // with a message that tells the model what to do instead. // // The other rejection is `run(...)`. It was once the escape hatch for multi-step // composition and is now gone from the scope entirely: arbitrary code is opaque // to the invalidation helpers (a hand-rolled `queryKey` defeats // `queryFilter`-based refresh) and to artifact analysis, which reads // `tools....` paths out of the source. Code that calls it would // fail at render time with a bare ReferenceError, so it is caught here where the // message can point at the declarative replacement. // // The third rejection is a hook called inside a loop body. Models that never // fetch the skill reach for pagination the imperative way — a `for` loop in the // component body with a `useQuery` per page, each one `enabled` by the previous // page's cursor. That is a rules-of-hooks violation (the hook count varies per // render) and a silently capped read, and `.infiniteQueryOptions` is the thing // they didn't know about. The error names it. // // This is deliberately NOT a general "is this good code" check. The donor branch // also rejected array literals whose variable name looked data-ish // (`const rows = [{...},{...}]`), which false-positives on legitimate display // constants — chart configs, tab definitions, column headers. That heuristic is // dropped; the "fetch live data with useQuery" guidance lives in the skill. const REACT_DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*\{[^{}]*\}\s*=\s*React\b/s; /** A call to the removed `run` global. Not preceded by `.` or an identifier * character, so `foo.run(...)` and `rerun(...)` — a component's own helpers — * stay legal. */ const REMOVED_RUN_CALL = /(?): string | undefined => values.find((value): value is string => value !== undefined); const localDestructuredName = (part: string): string | undefined => { const binding = part .replace(/^\s*\.\.\./, "") .split("=")[0] ?.trim(); const alias = binding?.match(/:\s*([A-Za-z_$][\w$]*)\s*$/)?.[1]; return alias ?? binding?.match(/^([A-Za-z_$][\w$]*)\b/)?.[1]; }; /** A component's own local `run` — legal, since it shadows nothing anymore. */ const LOCAL_RUN_DECLARATION = /\b(?:const|let|var|function)\s+run\b/; // --------------------------------------------------------------------------- // Hooks inside loop bodies // --------------------------------------------------------------------------- // // Finding a loop's body needs brace matching, not a regex — a regex either stops // at the first `}` (missing the common nested-block shape) or runs to the last // one (flagging a `useQuery` that merely follows a closed loop). So: blank out // comments and string/template literals so their braces and the word "useQuery" // can't skew the scan, then walk braces from each loop header. // // Nothing here is a parser. Loops assembled dynamically, hooks reached through // a helper called from a loop, and other exotica slip past — false negatives are // fine. A false positive is not: legal code must never be refused. /** Replaces comments and quoted/template literals with same-length blanks, so * offsets stay valid and their contents can't be mistaken for code. */ const withLiteralsBlanked = (code: string): string => { const blank = (text: string): string => text.replace(/[^\n]/g, " "); return code.replace( // Order matters: line comment, block comment, then each quote flavour. /\/\/[^\n]*|\/\*[\s\S]*?\*\/|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g, blank, ); }; /** A React hook call: `useFoo(`, not `x.useFoo(` and not `abusefoo(`. */ const HOOK_CALL = /(? { let index = afterKeyword; const skipSpace = (): void => { while (index < code.length && /\s/.test(code[index] ?? "")) index += 1; }; // `for (...)` / `while (...)`: step over the header's parens first. A hook in // the header itself is not the shape we're after. if (keyword !== "do") { skipSpace(); if (code[index] !== "(") return null; let depth = 0; for (; index < code.length; index += 1) { if (code[index] === "(") depth += 1; else if (code[index] === ")") { depth -= 1; if (depth === 0) { index += 1; break; } } } if (depth !== 0) return null; } skipSpace(); // Only a braced body is scanned. A braceless one has no delimiter to trust — // its end would have to be guessed at the next semicolon, which in JSX prose // can run far past the statement and reach an unrelated hook. Giving up costs // only `for (…) useQuery(…)`, which no generator writes. if (code[index] !== "{") return null; const start = index; let depth = 0; for (; index < code.length; index += 1) { if (code[index] === "{") depth += 1; else if (code[index] === "}") { depth -= 1; if (depth === 0) return code.slice(start + 1, index); } } return null; }; /** `do` is also an ordinary English word that can appear in JSX text ahead of an * expression container ("nothing to do {count}"), where `for`/`while` are * protected by their required header parens. Only a `do` that starts a * statement is a loop. */ const startsStatement = (code: string, at: number): boolean => { const before = code.slice(0, at).trimEnd(); return before === "" || /[;{}()]$/.test(before); }; /** The first hook called inside any loop body, or `undefined`. */ const hookCalledInLoop = (code: string): string | undefined => { const scannable = withLiteralsBlanked(code); for (const match of scannable.matchAll(LOOP_HEAD)) { const keyword = match[1] ?? ""; if (keyword === "do" && !startsStatement(scannable, match.index)) continue; const body = loopBody(scannable, keyword, match.index + match[0].length); const hook = body === null ? undefined : HOOK_CALL.exec(body)?.[1]; if (hook) return hook; } return undefined; }; /** Hooks whose in-loop use is almost always hand-rolled cursor pagination. */ const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]); // --------------------------------------------------------------------------- // Edits // --------------------------------------------------------------------------- // // `edit-artifact`'s pure half. An edit is an exact find-and-replace against the // stored source, so a model changing one column of a 300-line dashboard sends // the changed lines rather than re-emitting the whole component — the tool // call's size scales with the edit, not the artifact. // // The semantics are the ones every model already knows from its own file-edit // tool: `oldText` must match verbatim (whitespace included) and exactly once, // unless `replaceAll` opts into every occurrence. Edits apply in order, each // against the result of the previous, and the whole batch is atomic — any // miss rejects the call and nothing is saved. Ambiguity is refused rather than // resolved by picking the first hit, because a wrong pick would save silently // and the user would see the wrong line change. export type ArtifactEdit = { /** Exact text to find in the current source. */ readonly oldText: string; /** What to put in its place. */ readonly newText: string; /** Replace every occurrence instead of requiring `oldText` to be unique. */ readonly replaceAll?: boolean | undefined; }; export type AppliedArtifactEdits = | { readonly ok: true; readonly code: string } | { readonly ok: false; readonly message: string }; const countOccurrences = (haystack: string, needle: string): number => haystack.split(needle).length - 1; /** The edited source, or the first edit that could not be applied. Messages * name the edit by position and say what to do, because the model retries * from the message alone. */ export const applyArtifactEdits = ( code: string, edits: readonly ArtifactEdit[], ): AppliedArtifactEdits => { let current = code; for (const [index, edit] of edits.entries()) { const position = `Edit ${index + 1} of ${edits.length}`; if (edit.oldText === "") { return { ok: false, message: `${position} has an empty oldText, which matches nothing.` }; } if (edit.oldText === edit.newText) { return { ok: false, message: `${position} is a no-op: oldText and newText are identical.`, }; } const occurrences = countOccurrences(current, edit.oldText); if (occurrences === 0) { return { ok: false, message: [ `${position} matched nothing: its oldText does not appear in the current source.`, "Copy oldText verbatim from the source, whitespace included", index > 0 ? "— and remember each edit sees the result of the earlier ones." : ".", ].join(" "), }; } if (occurrences > 1 && edit.replaceAll !== true) { return { ok: false, message: [ `${position} is ambiguous: its oldText appears ${occurrences} times in the current source.`, "Include enough surrounding text to make it unique, or set replaceAll: true to change every occurrence.", ].join(" "), }; } // split/join for both arms: `String.replace` gives `$&` and friends in the // replacement special meaning, and generated JSX is full of `$`. current = edit.replaceAll === true ? current.split(edit.oldText).join(edit.newText) : current.replace(edit.oldText, () => edit.newText); } return { ok: true, code: current }; }; // --------------------------------------------------------------------------- // Create-time smoke render // --------------------------------------------------------------------------- /** * What a server-side trial render concluded, as this package sees it. * * Declared here rather than imported so the MCP host keeps no type dependency * on the shell package — `@executor-js/mcp-apps-shell`'s renderer satisfies this * structurally, and a host that has no renderer to inject does not have to * resolve the module at all. */ export type ArtifactSmokeRenderResult = | { readonly status: "ok"; /** * The markup the render produced, when it produced any within its cap. * * The smoke render is driven with a transport that never settles, so this * is the artifact's LOADING state — its real composition with no data in * it — which is what makes it storable as the gallery's layout preview. * Absent whenever nothing could be concluded, and every reader treats * absent as "fall back", never as "empty". */ readonly markup?: string; } | { readonly status: "failed"; readonly message: string; readonly componentStack?: string }; /** The leading frames of a component stack: enough to locate the component that * threw, not so much that the message is a wall. */ const COMPONENT_STACK_FRAMES = 6; const trimmedComponentStack = (stack: string): string => stack .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .slice(0, COMPONENT_STACK_FRAMES) .join("\n"); /** * Turn a failed smoke render into the message the model is refused with. * * The real error, verbatim, plus where it happened and what class of problem it * is. The last part matters: saying the render happened with every query still * pending tells the model that guarding the loading state is a valid fix, which * is not obvious from `Cannot read properties of undefined` alone. */ export const smokeRenderRejection = (result: ArtifactSmokeRenderResult): string | null => { if (result.status === "ok") return null; return [ `This component threw on its first render, so the artifact would show an error instead of a UI: ${result.message}`, result.componentStack ? `\n${trimmedComponentStack(result.componentStack)}\n` : "", "The render happened with every query still pending and no data returned — exactly the state the user sees first — so guard the loading state (`if (query.isLoading) return `) and read possibly-absent data with `?.`.", "Fix the component and call create-artifact again.", ] .filter((part) => part.length > 0) .join(" "); }; // --------------------------------------------------------------------------- // Provider pairing // --------------------------------------------------------------------------- // // A handful of the components in scope read a React context that only their own // wrapper supplies, and throw outright when it is missing. `` outside a `` is the case this was written for: the // artifact saves, the row is fine, and then the page dies on first render with // `useChart must be used within a ` — a failure the user sees // and the model never does. // // The check is a table, not an analysis. If a name that requires a wrapper // appears anywhere in the code and the wrapper's name appears nowhere, the code // cannot possibly be correct, so it is refused with the pairing spelled out. // That is deliberately weaker than the smoke render below (which catches the // same class properly, and much more): this pass is free, runs first, and gives // a message that names the fix rather than quoting a stack. // // Scope: `packages/react`'s components that `throw` on a missing context are // `useChart` (chart.tsx), `useCarousel` (carousel.tsx), `useFormField` // (form.tsx) and `useSidebar` (sidebar.tsx). Only the chart family is exported // from the shell's component barrel, so only it is listed. `Tooltip` needs a // `TooltipProvider` too, but the shell's inner renderer already wraps every // artifact in one, so requiring it here would refuse working code. /** Names whose presence obliges a wrapper, and the wrapper each one needs. */ const PROVIDER_PAIRINGS: readonly { readonly required: string; readonly dependents: readonly string[]; readonly hint: string; }[] = [ { required: "ChartContainer", dependents: [ "useChart", "ChartTooltip", "ChartTooltipContent", "ChartLegend", "ChartLegendContent", ], hint: 'Wrap the chart in `…`.', }, ]; /** * Whether `name` is INVOKED — rendered as `No ChartTooltipContent here`) * and is not blanked by `withLiteralsBlanked`, because it is not a literal — so * a bare-word match would refuse a component that merely names another one in * its copy. Requiring a `<` or a `(` costs only the alias case * (`const T = ChartTooltip`), which is a false negative, and false negatives are * the side to err on: the smoke render below catches it anyway. */ const invokesName = (scannable: string, name: string): boolean => new RegExp(String.raw`<\s*${name}(?![\w$])|(? new RegExp(String.raw`(? { const scannable = withLiteralsBlanked(code); for (const pairing of PROVIDER_PAIRINGS) { if (mentionsName(scannable, pairing.required)) continue; const dependent = pairing.dependents.find((name) => invokesName(scannable, name)); if (!dependent) continue; return [ `\`${dependent}\` only works inside a \`<${pairing.required}>\`, which this code never renders.`, `It reads a React context that \`${pairing.required}\` provides, so it throws on the first render and the artifact shows an error instead of a UI.`, pairing.hint, ].join(" "); } return null; }; /** `null` when the code may be rendered, otherwise the reason to hand back. */ export const validateArtifactCode = (code: string): string | null => { // Checked first: a five-segment address is the mistake a model that only read // the `execute` skill will make, and every other rejection below would be a // confusing thing to hear about code whose real problem is its addressing. const oldStyleAddress = oldStyleAddressRejection(code); if (oldStyleAddress) return oldStyleAddress; if (REMOVED_RUN_CALL.test(code) && !LOCAL_RUN_DECLARATION.test(code)) { return [ "`run(code)` no longer exists — artifact code is purely declarative `tools.*`.", "Read data with useQuery(tools...queryOptions(args)),", "page through a cursor with useInfiniteQuery(tools...infiniteQueryOptions(args, { getNextPageParam })),", "and write with useMutation(tools...mutationOptions({ onSuccess })).", ].join(" "); } const loopedHook = hookCalledInLoop(code); if (loopedHook) { return [ `\`${loopedHook}\` is called inside a loop body.`, "React hooks must run unconditionally at the top level of the component, in the same order every render.", PAGINATION_HOOKS.has(loopedHook) ? "To read every page of a cursor, use one useInfiniteQuery(tools...infiniteQueryOptions(args, { cursorKey, getNextPageParam })) and render data.pages — never a loop of useQuery calls, one per page." : "Move the hook out of the loop, and derive per-item values from its result instead.", ].join(" "); } const missingProvider = providerPairingRejection(code); if (missingProvider) return missingProvider; if (REACT_DESTRUCTURING_DECLARATION.test(code)) { return [ "Do not destructure React in create-artifact.", "Hooks such as useState are already in scope; use useState(...) directly or React.useState(...).", ].join(" "); } for (const match of code.matchAll(OBJECT_DESTRUCTURING_DECLARATION)) { const names = match[1]?.split(",").flatMap((part) => { const name = localDestructuredName(part); return name ? [name] : []; }); const providedName = names?.find((name) => PROVIDED_GLOBAL_NAMES.has(name)); if (providedName) { return [ `Provided global "${providedName}" is already in scope and cannot be redeclared.`, "Remove the destructuring declaration and use the provided global directly.", ].join(" "); } } for (const match of code.matchAll(PROVIDED_GLOBAL_DECLARATION)) { const name = firstDefined(match[1], match[2], match[3]); if (name && PROVIDED_GLOBAL_NAMES.has(name)) { return [ `Provided global "${name}" is already in scope and cannot be redeclared.`, "Remove the local declaration and use the provided global directly.", ].join(" "); } } return null; };