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

203 lines
5.8 KiB
TypeScript

// Mutation utilities own changing existing instances in the live tree: delete,
// reparent, wrap, unwrap, convert, and visibility toggles. Put operations that
// move or transform existing nodes here; use insert.ts for new content and
// slot.ts for shared Slot boundary rules.
import { toast } from "@webstudio-is/design-system";
import { builderApi } from "~/shared/builder-api";
import { showAttribute } from "@webstudio-is/react-sdk";
import { type Instance, isComponentDetachable } from "@webstudio-is/sdk";
import {
$isContentMode,
$isPreviewMode,
$registeredComponentMetas,
$selectedInstancePath,
$selectedPage,
$textEditingInstanceSelector,
selectInstance,
} from "../nano-states";
import type { InstancePath } from "@webstudio-is/project-build/runtime";
import { $instances, $props } from "../sync/data-stores";
import { $instanceTags } from "~/builder/features/style-panel/shared/model";
import {
type DroppableTarget,
type InstanceSelector,
} from "@webstudio-is/project-build/runtime";
import { canUnwrapInstancePath } from "@webstudio-is/project-build/runtime";
import { canDeleteInstanceInContentMode } from "@webstudio-is/project-build/runtime";
import { executeRuntimeMutation } from "./data";
export const reparentInstance = (
sourceInstanceSelector: InstanceSelector,
dropTarget: DroppableTarget
) => {
const result = executeRuntimeMutation({
id: "instances.reparent",
input: {
sourceInstanceSelector,
dropTarget,
},
});
if (result !== undefined) {
selectInstance(result.result.instanceSelector);
}
};
export const deleteInstanceBySelector = (
instanceSelector: undefined | InstanceSelector
) => {
if (instanceSelector === undefined || instanceSelector.length < 2) {
return;
}
return executeRuntimeMutation({
id: "instances.deleteBySelector",
input: { instanceSelector },
});
};
export const canUnwrapInstance = (instancePath: InstancePath) => {
return canUnwrapInstancePath({
instancePath,
rootInstanceId: $selectedPage.get()?.rootInstanceId,
instances: $instances.get(),
props: $props.get(),
metas: $registeredComponentMetas.get(),
});
};
export const toggleInstanceShow = (instanceId: Instance["id"]) => {
const showProp = Array.from($props.get().values()).find(
(prop) => prop.instanceId === instanceId && prop.name === showAttribute
);
if (showProp !== undefined && showProp.type !== "boolean") {
return;
}
executeRuntimeMutation({
id: "instances.updateProps",
input: {
updates: [
{
instanceId,
name: showAttribute,
type: "boolean",
value: showProp?.type === "boolean" ? !showProp.value : false,
},
],
},
});
};
export const wrapInstance = (component: string, tag?: string) => {
const instancePath = $selectedInstancePath.get();
// global root or body are selected
if (instancePath === undefined || instancePath.length === 1) {
return;
}
try {
const [selectedItem] = instancePath;
const result = executeRuntimeMutation({
id: "instances.wrap",
input: {
instanceSelector: selectedItem.instanceSelector,
component,
tag,
},
});
if (result !== undefined) {
selectInstance(result.result.instanceSelector);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Cannot wrap instance"
);
}
};
export const convertInstance = (component: string, tag?: string) => {
const instancePath = $selectedInstancePath.get();
// global root or body are selected
if (instancePath === undefined || instancePath.length === 1) {
return;
}
const instanceTags = $instanceTags.get();
try {
const [selectedItem] = instancePath;
executeRuntimeMutation({
id: "instances.convert",
input: {
instanceSelector: selectedItem.instanceSelector,
component,
tag,
currentTag: instanceTags.get(selectedItem.instance.id),
},
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Cannot convert instance"
);
}
};
export const unwrapInstance = () => {
const instancePath = $selectedInstancePath.get();
if (instancePath === undefined || !canUnwrapInstance(instancePath)) {
return;
}
try {
const [selectedItem] = instancePath;
const result = executeRuntimeMutation({
id: "instances.unwrap",
input: {
instanceSelector: selectedItem.instanceSelector,
},
});
if (result !== undefined) {
selectInstance(result.result.instanceSelector);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Cannot unwrap instance"
);
}
};
export const deleteSelectedInstance = () => {
if ($isPreviewMode.get()) {
return;
}
const textEditingInstanceSelector = $textEditingInstanceSelector.get();
const instancePath = $selectedInstancePath.get();
// cannot delete instance while editing
if (textEditingInstanceSelector) {
return;
}
if (instancePath === undefined || instancePath.length === 1) {
return;
}
const [selectedItem] = instancePath;
const selectedInstanceSelector = selectedItem.instanceSelector;
const instances = $instances.get();
if (!isComponentDetachable(selectedItem.instance.component)) {
toast.error(
"This instance can not be moved outside of its parent component."
);
return false;
}
if ($isContentMode.get()) {
if (
canDeleteInstanceInContentMode({
instanceSelector: selectedInstanceSelector,
instances,
}) === false
) {
builderApi.toast.info("You can't delete this instance in content mode.");
return;
}
}
const result = deleteInstanceBySelector(selectedInstanceSelector);
if (result !== undefined && result.noop === false) {
selectInstance(result.result.instanceSelector);
}
};