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

219 lines
6.0 KiB
TypeScript

import type { Instance, WebstudioFragment } from "@webstudio-is/sdk";
import { findClosestInsertable } from "../../instance-utils/insert";
import { $project, $styleSources } from "~/shared/sync/data-stores";
import {
type WfData,
wfData,
wfNodeTypes,
type WfNode,
type WfStyle,
type WfAsset,
} from "@webstudio-is/project-build/transfer";
import { addInstanceAndProperties } from "@webstudio-is/project-build/transfer";
import { addStyles } from "@webstudio-is/project-build/transfer";
import { builderApi } from "~/shared/builder-api";
import { denormalizeSrcProps } from "../asset-upload";
import { nanoHash } from "~/shared/nano-hash";
import { pasteHandled, pasteIgnored, type Plugin } from "../copy-paste";
import { builderRuntimeContext } from "@webstudio-is/project-build/runtime";
import {
hasFragmentData,
insertFragmentWithBreakpointWarning,
} from "../fragment-utils";
const { toast } = builderApi;
const toWebstudioFragment = async (
wfData: WfData,
createId = builderRuntimeContext.createId
) => {
const fragment: WebstudioFragment = {
children: [],
instances: [],
props: [],
breakpoints: [],
styles: [],
styleSources: [],
styleSourceSelections: [],
dataSources: [],
resources: [],
assets: [],
};
const wfNodes = new Map<WfNode["_id"], WfNode>();
for (const node of wfData.payload.nodes) {
if ("type" in node || "text" in node) {
wfNodes.set(node._id, node);
}
}
const wfStyles = new Map<WfStyle["_id"], WfStyle>(
wfData.payload.styles.map((style: WfStyle) => [style._id, style])
);
const wfAssets = new Map<WfAsset["_id"], WfAsset>(
wfData.payload.assets.map((asset: WfAsset) => [asset._id, asset])
);
// False value used to skip a node.
const doneNodes = new Map<WfNode["_id"], Instance["id"] | false>();
for (const wfNode of wfNodes.values()) {
addInstanceAndProperties(wfNode, doneNodes, wfNodes, fragment, createId);
}
/**
* Generates deterministic style IDs based on sourceId or unique data.
* This simplifies merging and deduplicating styles from different sources.
*/
const generateStyleSourceId = async (sourceData: string) => {
// We are using projectId here to avoid id collisions between different projects.
const projectId = $project.get()?.id;
if (projectId === undefined) {
throw new Error("Project id is not set");
}
return nanoHash(`${projectId}-${sourceData}`);
};
await addStyles({
wfNodes,
wfStyles,
wfAssets,
doneNodes,
fragment,
generateStyleSourceId,
createId,
existingStyleSourceIds: new Set($styleSources.get().keys()),
onInvalidStyleValue: (message) => {
toast.error(message);
},
});
// First node should be always the root node in theory, if not
// we need to find a node that is not a child of any other node.
const rootWfNode = wfData.payload.nodes[0];
const rootInstanceId = doneNodes.get(rootWfNode._id);
if (rootInstanceId === false) {
return fragment;
}
if (rootInstanceId === undefined) {
console.error(`No root instance id found for node ${rootWfNode._id}`);
return fragment;
}
fragment.children = [
{
type: "id",
value: rootInstanceId,
},
];
return fragment;
};
type WebflowParseResult =
| { owned: false }
| { owned: true; success: false; error: string }
| { owned: true; success: true; data: WfData };
const parse = (clipboardData: string): WebflowParseResult => {
let data;
try {
data = JSON.parse(clipboardData);
} catch {
return { owned: false };
}
if (data.type !== "@webflow/XscpData") {
return { owned: false };
}
const payloadNodes = Array.isArray(data.payload?.nodes)
? data.payload.nodes
: [];
const unsupportedNodeTypes: Set<string> = new Set(
payloadNodes
.filter((node: { type: string }) => {
return (
node.type !== undefined &&
wfNodeTypes.includes(node.type as (typeof wfNodeTypes)[number]) ===
false
);
})
.map((node: { type: string }) => node.type)
);
if (unsupportedNodeTypes.size !== 0) {
const message = `Skipping unsupported elements: ${[...unsupportedNodeTypes.values()].join(", ")}`;
toast.info(message);
console.info(message);
}
const result = wfData.safeParse(data);
if (result.success) {
const unparsedTypes = new Set<string>();
for (let i = 0; i !== result.data.payload.nodes.length; ++i) {
if ("type" in result.data.payload.nodes[i]) {
continue;
}
if (data.payload.nodes[i].type === undefined) {
continue;
}
const probablyUnparsedType = data.payload.nodes[i].type;
if (unsupportedNodeTypes.has(probablyUnparsedType)) {
continue;
}
unparsedTypes.add(probablyUnparsedType);
}
if (unparsedTypes.size !== 0) {
const message = `The following types were skipped due to a parsing error: ${[...unparsedTypes.values()].join(", ")}`;
toast.info(message);
console.info(message);
}
return { owned: true, success: true, data: result.data };
}
return { owned: true, success: false, error: result.error.message };
};
const handlePasteWebflow = async (clipboardData: string) => {
const project = $project.get();
const result = parse(clipboardData);
if (result.owned === false) {
return pasteIgnored;
}
if (result.success === false) {
return { success: false, error: result.error } as const;
}
if (project === undefined) {
return pasteHandled;
}
let fragment = await toWebstudioFragment(result.data);
if (hasFragmentData(fragment) === false) {
return pasteHandled;
}
fragment = await denormalizeSrcProps(fragment);
const insertable = findClosestInsertable(fragment);
if (insertable === undefined) {
return pasteHandled;
}
insertFragmentWithBreakpointWarning(fragment, insertable);
return pasteHandled;
};
export const webflow: Plugin = {
name: "webflow",
mimeType: "application/json",
onPaste: handlePasteWebflow,
};
export const __testing__ = {
toWebstudioFragment,
};