Files
Oleg Isonen dde30ffb54 feat: Improve MCP authoring, compact output, and agent onboarding (#5883)
## Summary

This improves how agents discover, author, inspect, and verify Webstudio
projects while reducing unnecessary MCP context usage.

## What changed

- Adds an **Agents** section to Project Settings with ready-to-run setup
commands and client configuration for Claude Code, Codex, Cursor, and VS
Code.
- Makes large list and schema responses compact and paginated by
default, with **verbose** available when complete records or diagnostics
are needed.
- Applies consistent compact output to assets, instances, styles, design
tokens, CLI schemas, and project-session metadata.
- Returns actionable expression warnings for props, resources, and
Collection bindings, including unknown variables and risky render-time
mutation resources.
- Improves Collection expression guidance so agents can bind array items
with the correct scoped variables.
- Reports rendered audit outcomes explicitly as complete, partial,
confirmation-required, or failed.
- Expands runtime output schemas so MCP clients can validate responses
reliably.
- Strengthens the packaged CLI smoke workflow with a realistic
seven-page stdio MCP scenario.

## Architecture

- Consolidates `project-build` from 65 file-oriented entrypoints to 11
architecture-owned surfaces.
- Separates the core model, contracts, runtime authoring, transfer,
state, project sessions, MCP, visual tooling, persistence codecs, and
authenticated server services.
- Isolates Node-only JSX evaluation from browser-safe transfer utilities
and authenticated server modules.
- Keeps persistence codecs independent from authenticated database
services, preventing unrelated module initialization.
- Defines shared instance-path types once and reuses them across runtime
modules.
- Preserves Immer initialization through explicit package side-effect
metadata.
- Adds package tests that prevent a return to source-file entrypoints or
generic exposure of Node-only surfaces.

## Validation

- Full repository checks, tests, typechecks, generated-file checks, and
fixtures
- All 163 Builder test files and 2,141 tests
- All 71 project-build test files and 1,794 tests
- Lint and package-boundary validation
- Full packed CLI release smoke test, including install, preview, agent
setup, seven-page stdio MCP workflow, rendered audit, and structured
commands
2026-07-15 18:11:28 +02:00

781 lines
23 KiB
TypeScript

import {
useEffect,
forwardRef,
type ForwardedRef,
useRef,
useLayoutEffect,
useMemo,
Fragment,
type ReactNode,
type JSX,
} from "react";
import { $getSelection, $isRangeSelection } from "lexical";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { mergeRefs } from "@react-aria/utils";
import type {
Instance,
Instances,
Prop,
WsComponentMeta,
} from "@webstudio-is/sdk";
import {
collectionComponent,
descendantComponent,
blockComponent,
blockTemplateComponent,
getIndexesWithinAncestors,
elementComponent,
} from "@webstudio-is/sdk";
import { indexProperty, tagProperty } from "@webstudio-is/sdk/runtime";
import {
idAttribute,
componentAttribute,
showAttribute,
selectorIdAttribute,
type AnyComponent,
textContentAttribute,
standardAttributesToReactProps,
getCollectionEntries,
} from "@webstudio-is/react-sdk";
import { rawTheme } from "@webstudio-is/design-system";
import {
Input,
Link,
Select,
Textarea,
} from "@webstudio-is/sdk-components-react/components";
import { LinkCurrentUrlContext } from "@webstudio-is/sdk-components-react";
import {
$propValuesByInstanceSelectorWithMemoryProps,
getIndexedInstanceId,
$registeredComponentMetas,
$selectedInstanceRenderState,
$selectedPageHash,
} from "~/shared/nano-states";
import { $props } from "~/shared/sync/data-stores";
import { $textEditingInstanceSelector } from "~/shared/nano-states";
import { $instances } from "~/shared/sync/data-stores";
import {
type InstanceSelector,
areInstanceSelectorsEqual,
} from "@webstudio-is/project-build/runtime";
import { findBlockSelector } from "@webstudio-is/project-build/runtime";
import { inflateInstance } from "~/canvas/inflator";
import { getIsVisuallyHidden } from "~/shared/visually-hidden";
import { TextEditor } from "../text-editor";
import { $selectedPage, getInstanceKey } from "~/shared/nano-states";
import { selectInstance } from "~/shared/nano-states";
import { $currentSystem } from "~/shared/system";
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
import {
createInstanceChildrenElements,
type WebstudioComponentProps,
} from "~/canvas/elements";
import { Block } from "../build-mode/block";
import { BlockTemplate } from "../build-mode/block-template";
import {
editablePlaceholderAttribute,
editingPlaceholderVariable,
} from "~/canvas/shared/styles";
import { richTextPlaceholders } from "@webstudio-is/project-build/runtime";
const computeComponentKey = (props: Record<string, unknown>) => {
const assetId = props.$webstudio$canvasOnly$assetId;
const src = props.src;
const defaultValue = props.defaultValue;
return (
(typeof assetId === "string" ? assetId : undefined) ??
(defaultValue != null ? String(defaultValue) : undefined) ??
(src != null ? String(src) : undefined)
);
};
const getPreviewCurrentUrl = (
currentSystem: {
pathname: string;
search: Record<string, string | undefined>;
},
hash: string
) => {
// Preview renders inside the builder canvas route, so window.location points
// at the builder shell, not the page being previewed. Recreate the page URL
// from the selected page system data so :local-link state matches preview
// navigation, including query params and hash-only links.
const currentUrl = new URL(currentSystem.pathname, "https://webstudio.local");
currentUrl.search = new URLSearchParams(
Object.entries(currentSystem.search).filter(
(entry): entry is [string, string] => entry[1] !== undefined
)
).toString();
currentUrl.hash = hash;
return currentUrl;
};
export const __testing__ = { computeComponentKey, getPreviewCurrentUrl };
const PreviewLinkCurrentUrlProvider = ({
children,
}: {
children: ReactNode;
}) => {
const currentSystem = useStore($currentSystem);
const selectedPageHash = useStore($selectedPageHash);
const currentUrl = getPreviewCurrentUrl(currentSystem, selectedPageHash.hash);
return (
<LinkCurrentUrlContext.Provider value={currentUrl}>
{children}
</LinkCurrentUrlContext.Provider>
);
};
const ContentEditable = ({
placeholder,
renderComponentWithRef,
}: {
placeholder: string | undefined;
renderComponentWithRef: (
elementRef: ForwardedRef<HTMLElement>
) => JSX.Element;
}) => {
const [editor] = useLexicalComposerContext();
const ref = useRef<HTMLElement>(null);
/**
* useLayoutEffect to be sure that editor plugins on useEffect would have access to rootElement
*/
useLayoutEffect(() => {
const rootElement = ref.current;
if (rootElement == null) {
return;
}
if (getIsVisuallyHidden(rootElement)) {
return;
}
if (rootElement.tagName === "A") {
if (window.getComputedStyle(rootElement).display === "inline-flex") {
// Issue: <a> tag doesn't work with inline-flex when the cursor is at the start or end of the text.
// Solution: Inline-flex is not supported by Lexical. Use "inline" during editing.
rootElement.style.display = "inline";
}
}
// Issue: <button> with contentEditable does not allow pressing space.
// Solution: Add space on space keydown.
const abortController = new AbortController();
if (rootElement.closest("button")) {
rootElement.addEventListener(
"keydown",
(event) => {
if (event.code === "Space") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(" ");
}
});
event.preventDefault();
}
},
{ signal: abortController.signal }
);
// Some controls like Tab and TabTrigger intercept arrow keys for navigation.
// Prevent propagation to avoid conflicts with Lexical's default behavior.
rootElement.addEventListener(
"keydown",
(event) => {
if (["ArrowLeft", "ArrowRight"].includes(event.code)) {
event.stopPropagation();
}
},
{ signal: abortController.signal }
);
}
rootElement.contentEditable = "true";
editor.setRootElement(rootElement);
// Must be done after 'setRootElement' to avoid Lexical's default behavior
// white-space affects "text-wrap", remove it and use "white-space-collapse" instead
rootElement.style.removeProperty("white-space");
rootElement.style.setProperty("white-space-collapse", "pre-wrap");
if (placeholder !== undefined) {
rootElement.style.setProperty(
editingPlaceholderVariable,
`'${placeholder.replaceAll("'", "\\'")}'`
);
}
return () => {
abortController.abort();
};
}, [editor, placeholder]);
return renderComponentWithRef(ref);
};
const ErrorStub = forwardRef<
HTMLDivElement,
{
children?: ReactNode;
}
>((props, ref) => {
return (
<div
{...props}
ref={ref}
style={{
padding: rawTheme.spacing[5],
border: `1px solid ${rawTheme.colors.borderDestructiveMain}`,
color: rawTheme.colors.foregroundDestructive,
}}
/>
);
});
ErrorStub.displayName = "ErrorStub";
const MissingComponentStub = forwardRef<
HTMLDivElement,
{ children?: ReactNode }
>((props, ref) => {
return (
<ErrorStub ref={ref} {...props}>
Component {props[componentAttribute as never]} does not exist
</ErrorStub>
);
});
MissingComponentStub.displayName = "MissingComponentStub";
const InvalidCollectionDataStub = forwardRef<
HTMLDivElement,
{ children?: ReactNode }
>((props, ref) => {
return (
<ErrorStub ref={ref} {...props}>
The Collection component requires an array in the data property. When
binding external data, it is likely that the array is nested somewhere
within, and you need to provide the correct path in the binding.{" "}
<a
style={{ color: "inherit" }}
target="_blank"
href="https://docs.webstudio.is/university/core-components/collection#whats-an-array"
// avoid preventing click by events interceptor
onClickCapture={(event) => event.stopPropagation()}
>
Learn more
</a>
</ErrorStub>
);
});
InvalidCollectionDataStub.displayName = "InvalidCollectionDataStub";
const DroppableComponentStub = forwardRef<
HTMLDivElement,
{ children?: ReactNode }
>((props, ref) => {
return (
<div {...props} ref={ref} style={{ display: "block" }}>
{/* explicitly specify undefined to override passed children */}
{undefined}
</div>
);
});
DroppableComponentStub.displayName = "DroppableComponentStub";
// this utility is temporary solution to compute instance selectors
// for rich text subtree which cannot have slots so its safe to traverse ancestors
// until editor instance is reached
//
// once all lexical formats are replaced with elmenents it should be
// straightforward to compute selectors from lexical tree
const getInstanceSelector = (
instances: Instances,
rootInstanceSelector: InstanceSelector,
instanceId: Instance["id"]
) => {
const parentInstancesById = new Map<Instance["id"], Instance["id"]>();
for (const instance of instances.values()) {
for (const child of instance.children) {
if (child.type === "id") {
parentInstancesById.set(child.value, instance.id);
}
}
}
const selector: InstanceSelector = [];
let currentInstanceId: undefined | Instance["id"] = instanceId;
while (currentInstanceId) {
selector.push(currentInstanceId);
currentInstanceId = parentInstancesById.get(currentInstanceId);
if (currentInstanceId === rootInstanceSelector[0]) {
return [...selector, ...rootInstanceSelector];
}
}
return;
};
const $indexesWithinAncestors = computed(
[$registeredComponentMetas, $instances, $selectedPage],
(metas, instances, page) => {
return getIndexesWithinAncestors(
metas,
instances,
page ? [page.rootInstanceId] : []
);
}
);
const useInstanceProps = (instanceSelector: InstanceSelector) => {
const instanceKey = getInstanceKey(instanceSelector);
const [instanceId] = instanceSelector;
const $instancePropsObject = useMemo(() => {
return computed(
[
$propValuesByInstanceSelectorWithMemoryProps,
$instances,
$indexesWithinAncestors,
$registeredComponentMetas,
],
(
propValuesByInstanceSelector,
instances,
indexesWithinAncestors,
metas
) => {
const instancePropsObject: Record<Prop["name"], unknown> = {};
const instance = instances.get(instanceId);
const tag = instance?.tag;
if (tag !== undefined) {
instancePropsObject[tagProperty] = tag;
}
const meta = metas.get(instance?.component ?? "");
const hasTags = Object.keys(meta?.presetStyle ?? {}).length > 0;
const index = indexesWithinAncestors.get(instanceId);
if (index !== undefined) {
instancePropsObject[indexProperty] = index.toString();
}
const instanceProps = propValuesByInstanceSelector.get(instanceKey);
if (instanceProps) {
for (const [name, value] of instanceProps) {
let propName = name;
// convert html attribute only when component has tags
// and does not specify own property with this name
if (hasTags && !meta?.props?.[propName]) {
propName = standardAttributesToReactProps[propName] ?? propName;
}
instancePropsObject[propName] = value;
}
}
return instancePropsObject;
}
);
}, [instanceKey, instanceId]);
const instancePropsObject = useStore($instancePropsObject);
return instancePropsObject;
};
const existingElements = new Set<string>();
/**
* We are identifying newly created instances like Tooltips and ensuring the calculation of 'inflated' elements.
*/
const useInflateOnNewElement = (instanceId: Instance["id"]) => {
useEffect(() => {
if (existingElements.has(instanceId) === false) {
inflateInstance(instanceId);
}
existingElements.add(instanceId);
return () => {
existingElements.delete(instanceId);
};
}, [instanceId]);
};
/**
* We combine Radix's implicit event handlers with user-defined ones,
* such as onClick or onSubmit. For instance, a Button within
* a TooltipTrigger receives an onClick handler from the TooltipTrigger.
* We might also need an additional onClick handler on the Button for other
* purposes (setting variable).
**/
const mergeProps = (
// here we assume all on* props are callbacks
// cast to avoid extra checks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
restProps: Record<string, any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
instanceProps: Record<string, any>,
callbackStrategy: "merge" | "delete"
) => {
// merge props into single object
const props = { ...restProps, ...instanceProps };
for (const propName of Object.keys(props)) {
const restPropValue = restProps[propName];
const instancePropValue = instanceProps[propName];
const isHandler = /^on[A-Z]/.test(propName);
if (isHandler === false) {
continue;
}
// combine handlers for preview
if (callbackStrategy === "merge") {
props[propName] = (...args: unknown[]) => {
restPropValue?.(...args);
instancePropValue?.(...args);
};
}
// delete all handlers from canvas mode
if (callbackStrategy === "delete") {
delete props[propName];
}
}
return props;
};
const getTextContent = (instanceProps: Record<string, unknown>) => {
const value = instanceProps[textContentAttribute];
// serialize objects and let react render literal types
if (typeof value === "object" && value !== null) {
return String(value);
}
return value as ReactNode;
};
const getEditableComponentPlaceholder = (
instance: Instance,
instanceSelector: InstanceSelector,
instances: Instances,
metas: Map<string, WsComponentMeta>,
mode: "editing" | "editable"
) => {
const meta = metas.get(instance.component);
const tags = Object.keys(meta?.presetStyle ?? {});
const tag = instance.tag ?? tags[0];
const placeholder = richTextPlaceholders.get(tag);
if (placeholder === undefined) {
return;
}
const isContentBlockChild =
undefined !== findBlockSelector({ anchor: instanceSelector, instances });
// The paragraph contains only an "editing" placeholder within the content block.
if (tag === "p" && isContentBlockChild && mode === "editing") {
return "Write something or press '/' for commands...";
}
return placeholder;
};
export const WebstudioComponentCanvas = forwardRef<
HTMLElement,
WebstudioComponentProps
>(({ instance, instanceSelector, components, ...restProps }, ref) => {
const instanceId = instance.id;
const instances = useStore($instances);
const allProps = useStore($props);
const metas = useStore($registeredComponentMetas);
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
const { [showAttribute]: show = true, ...instanceProps } =
useInstanceProps(instanceSelector);
const children =
getTextContent(instanceProps) ??
createInstanceChildrenElements({
instances,
instanceSelector,
children: instance.children,
Component: WebstudioComponentCanvas,
components,
});
/**
* Prevents edited element from having a size of 0 on the first render.
* Directly using `children` in Text Edit
* conflicts with React due to lexical node changes.
*/
const initialContentEditableContent = useRef(children);
useInflateOnNewElement(instanceId);
// this assumes presence of `useStore($selectedInstanceSelector)` above
// we rely on root re-rendering after selected instance changes
useEffect(() => {
// 1 means root
if (instanceSelector.length === 1) {
// If by the time root is rendered,
// no selected instance renders and sets state to "mounted",
// then it's clear that selected instance will not render at all, so we set it to "notMounted"
if ($selectedInstanceRenderState.get() === "pending") {
$selectedInstanceRenderState.set("notMounted");
}
}
});
if (show === false) {
return <></>;
}
let Component: string | AnyComponent =
components.get(instance.component) ??
(MissingComponentStub as AnyComponent);
if (instance.component === elementComponent) {
Component = instance.tag ?? "div";
// replace to enable uncontrolled state
if (Component === "input") {
Component = Input as AnyComponent;
}
if (Component === "textarea") {
Component = Textarea as AnyComponent;
}
if (Component === "select") {
Component = Select as AnyComponent;
}
}
if (instance.component === collectionComponent) {
const originalData = instanceProps.data;
if (originalData && instance.children.length > 0) {
const entries = getCollectionEntries(originalData);
if (entries.length > 0) {
return entries.map(([key]) => (
<Fragment key={key}>
{createInstanceChildrenElements({
instances,
instanceSelector: [
getIndexedInstanceId(instance.id, key),
...instanceSelector,
],
children: instance.children,
Component: WebstudioComponentCanvas,
components,
})}
</Fragment>
));
}
}
Component = DroppableComponentStub as AnyComponent;
}
if (instance.component === descendantComponent) {
return <></>;
}
if (instance.component === blockComponent) {
Component = Block;
}
if (instance.component === blockTemplateComponent) {
Component = BlockTemplate;
}
const mergedProps = mergeProps(restProps, instanceProps, "delete");
const props: {
[componentAttribute]: string;
[idAttribute]: string;
[selectorIdAttribute]: string;
} & Record<string, unknown> = {
...mergedProps,
// current props should override bypassed from parent
// important for data-ws-* props
tabIndex: 0,
[selectorIdAttribute]: instanceSelector.join(","),
[componentAttribute]: instance.component,
[idAttribute]: instance.id,
[editablePlaceholderAttribute]: getEditableComponentPlaceholder(
instance,
instanceSelector,
instances,
metas,
"editable"
),
};
// React ignores defaultValue changes after first render.
// Key prop forces re-creation to reflect updates on canvas.
// Also use assetId to recreate component when asset changes (e.g., deleted, replaced)
// For expressions that resolve to asset URLs (via assets resource), use the src value itself
const key = computeComponentKey(props);
const instanceElement = (
<>
<Component key={key} {...props} ref={ref}>
{children}
</Component>
</>
);
if (
areInstanceSelectorsEqual(
textEditingInstanceSelector?.selector,
instanceSelector
) === false
) {
initialContentEditableContent.current = children;
return instanceElement;
}
return (
<TextEditor
rootInstanceSelector={instanceSelector}
instances={instances}
props={allProps}
contentEditable={
<ContentEditable
placeholder={getEditableComponentPlaceholder(
instance,
instanceSelector,
instances,
metas,
"editing"
)}
renderComponentWithRef={(elementRef) => (
<Component {...props} ref={mergeRefs(ref, elementRef)}>
{initialContentEditableContent.current}
</Component>
)}
/>
}
onChange={(instancesList) => {
const result = executeRuntimeMutation({
id: "instances.updateTextTree",
input: {
rootInstanceId: instance.id,
instances: instancesList,
},
});
return result?.result.idMap;
}}
onSelectInstance={(instanceId) => {
const instances = $instances.get();
const newSelectedSelector = getInstanceSelector(
instances,
instanceSelector,
instanceId
);
$textEditingInstanceSelector.set(undefined);
selectInstance(newSelectedSelector);
}}
/>
);
});
export const WebstudioComponentPreview = forwardRef<
HTMLElement,
WebstudioComponentProps
>(({ instance, instanceSelector, components, ...restProps }, ref) => {
const instances = useStore($instances);
const { [showAttribute]: show = true, ...instanceProps } =
useInstanceProps(instanceSelector);
const props: {
[componentAttribute]: string;
[idAttribute]: string;
[selectorIdAttribute]: string;
} & Record<string, unknown> = {
...mergeProps(restProps, instanceProps, "merge"),
[idAttribute]: instance.id,
[componentAttribute]: instance.component,
[selectorIdAttribute]: instanceSelector.join(","),
};
if (show === false) {
return <></>;
}
if (instance.component === collectionComponent) {
const originalData = instanceProps.data;
if (originalData && instance.children.length > 0) {
const entries = getCollectionEntries(originalData);
if (entries.length > 0) {
return entries.map(([key]) => (
<Fragment key={key}>
{createInstanceChildrenElements({
instances,
instanceSelector: [
getIndexedInstanceId(instance.id, key),
...instanceSelector,
],
children: instance.children,
Component: WebstudioComponentPreview,
components,
})}
</Fragment>
));
}
}
}
if (instance.component === descendantComponent) {
return <></>;
}
let Component: undefined | string | AnyComponent = components.get(
instance.component
);
if (instance.component === elementComponent) {
Component = instance.tag ?? "div";
// replace to enable uncontrolled state
if (Component === "input") {
Component = Input as AnyComponent;
}
if (Component === "textarea") {
Component = Textarea as AnyComponent;
}
if (Component === "select") {
Component = Select as AnyComponent;
}
if (Component === "a") {
Component = Link as AnyComponent;
}
}
if (instance.component === blockComponent) {
Component = Block;
}
if (instance.component === blockTemplateComponent) {
Component = BlockTemplate;
}
if (Component === undefined) {
return <></>;
}
const element = (
<Component {...props} ref={ref}>
{getTextContent(instanceProps) ??
createInstanceChildrenElements({
instances,
instanceSelector,
children: instance.children,
Component: WebstudioComponentPreview,
components,
})}
</Component>
);
if (
instance.component === "Link" ||
instance.component === "RichTextLink" ||
(instance.component === elementComponent && instance.tag === "a")
) {
return (
<PreviewLinkCurrentUrlProvider>{element}</PreviewLinkCurrentUrlProvider>
);
}
return element;
});