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

236 lines
6.5 KiB
TypeScript

import {
executeRuntimeMutation,
getWebstudioData,
} from "../instance-utils/data";
import { toast } from "@webstudio-is/design-system";
import {
type WebstudioFragment,
findPageByIdOrPath,
findParentFolderByChildId,
type Folder,
type Page,
type PageTemplate,
} from "@webstudio-is/sdk";
import { $pages, $project } from "~/shared/sync/data-stores";
import { detectFragmentTokenConflicts } from "@webstudio-is/project-build/runtime";
import { builderApi } from "../builder-api";
import {
createFolderCopyData,
createPageCopyData,
createTemplateCopyData,
} from "@webstudio-is/project-build/runtime";
import {
pageTransferDataVersion,
parsePageTransferData,
type FolderTransferData,
type PageTransferData,
type PageTransferItem,
type TemplateTransferData,
} from "@webstudio-is/project-build/transfer";
import { $selectedPage } from "../nano-states";
import { getPageActionTarget } from "../page-action-target";
import { pasteHandled, pasteIgnored, type Plugin } from "./copy-paste";
import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime";
const invalidPasteDataMessage =
"Could not paste Webstudio page data. The clipboard data appears to be incomplete or invalid.";
const stringify = (data: PageTransferItem) => {
return JSON.stringify({ [pageTransferDataVersion]: data });
};
const getDefaultTargetFolderId = () => {
const pages = $pages.get();
if (pages === undefined) {
return;
}
const selectedPage = $selectedPage.get();
if (selectedPage) {
return (
findParentFolderByChildId(selectedPage.id, pages.folders)?.id ??
pages.rootFolderId
);
}
return pages.rootFolderId;
};
const mergeWebstudioFragments = (
...fragments: WebstudioFragment[]
): WebstudioFragment => {
return {
children: fragments.flatMap((fragment) => fragment.children),
instances: fragments.flatMap((fragment) => fragment.instances),
assets: fragments.flatMap((fragment) => fragment.assets),
dataSources: fragments.flatMap((fragment) => fragment.dataSources),
resources: fragments.flatMap((fragment) => fragment.resources),
props: fragments.flatMap((fragment) => fragment.props),
breakpoints: fragments.flatMap((fragment) => fragment.breakpoints),
styleSourceSelections: fragments.flatMap(
(fragment) => fragment.styleSourceSelections
),
styleSources: fragments.flatMap((fragment) => fragment.styleSources),
styles: fragments.flatMap((fragment) => fragment.styles),
} satisfies WebstudioFragment;
};
const addPageType = (
data: ReturnType<typeof createPageCopyData>
): PageTransferData => ({
...data,
type: "page",
});
const addTemplateType = (
data: ReturnType<typeof createTemplateCopyData>
): TemplateTransferData => ({
...data,
type: "template",
});
const addFolderType = (
data: NonNullable<ReturnType<typeof createFolderCopyData>>
): FolderTransferData => ({
type: "folder",
folder: data.folder,
children: data.children.map((child) =>
"folder" in child ? addFolderType(child) : addPageType(child)
),
});
const getPageCopyData = (
data: ReturnType<typeof getWebstudioData>,
pageId: Page["id"]
): PageTransferData | undefined => {
const page = findPageByIdOrPath(pageId, data.pages);
if (page === undefined) {
return;
}
return addPageType(createPageCopyData({ data, page }));
};
const handleCopyPage = () => {
const target = getPageActionTarget();
if (target?.type === "page") {
return copyPageData(target.id);
}
if (target?.type === "folder") {
return copyFolderData(target.id);
}
if (target?.type === "template") {
return copyTemplateData(target.id);
}
};
const collectPageLikeItems = (
item: PageTransferItem
): Array<PageTransferData | TemplateTransferData> => {
if (item.type === "page" || item.type === "template") {
return [item];
}
return item.children.flatMap(collectPageLikeItems);
};
export const copyPageData = (pageId: Page["id"]) => {
const data = getWebstudioData();
const pageData = getPageCopyData(data, pageId);
if (pageData === undefined) {
return;
}
return stringify(pageData);
};
export const copyTemplateData = (templateId: PageTemplate["id"]) => {
const data = getWebstudioData();
const template = data.pages.pageTemplates?.get(templateId);
if (template === undefined) {
return;
}
return stringify(addTemplateType(createTemplateCopyData({ data, template })));
};
export const copyFolderData = (folderId: Folder["id"]) => {
const data = getWebstudioData();
const folderData = createFolderCopyData({ data, folderId });
if (folderData === undefined) {
return;
}
return stringify(addFolderType(folderData));
};
export const handlePastePage = async (
clipboardData: string,
targetFolderId?: Folder["id"]
) => {
const transferData = parsePageTransferData(clipboardData);
if (transferData.owned === false) {
return pasteIgnored;
}
if (transferData.valid === false) {
return { success: false, error: invalidPasteDataMessage } as const;
}
const item = transferData.data;
const pages = $pages.get();
const folderId = targetFolderId ?? getDefaultTargetFolderId();
if (
pages === undefined ||
folderId === undefined ||
pages.folders.has(folderId) === false
) {
return pasteHandled;
}
try {
const targetData = getWebstudioData();
const projectId = $project.get()?.id;
if (projectId === undefined) {
return pasteHandled;
}
const conflicts = collectPageLikeItems(item).flatMap((item) =>
detectFragmentTokenConflicts({
fragment: mergeWebstudioFragments(item.rootFragment, item.bodyFragment),
targetData,
})
);
const conflictResolution =
conflicts.length > 0
? await builderApi.showTokenConflictDialog(conflicts)
: "theirs";
const result = executeRuntimeMutation({
id: "pageTransfer.insert",
input: {
item,
targetFolderId: folderId,
projectId,
conflictResolution,
},
});
if (result?.result.didReachBreakpointLimit) {
toast.warn(breakpointPasteLimitWarning);
}
const newId = result?.result.id;
if (newId) {
toast.success(
item.type === "page"
? "Page pasted"
: item.type === "template"
? "Template pasted"
: "Folder pasted"
);
return pasteHandled;
}
} catch {
return pasteHandled;
}
return pasteHandled;
};
export const pageText: Plugin = {
name: "page-text",
mimeType: "text/plain",
onCopy: handleCopyPage,
onPaste: (clipboardData) => handlePastePage(clipboardData),
};