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

216 lines
5.9 KiB
TypeScript

import { ROOT_INSTANCE_ID, type Instances } from "@webstudio-is/sdk";
import {
areInstanceSelectorsEqual,
isDescendantOrSelf,
type InstanceSelector,
} from "@webstudio-is/project-build/runtime";
export const areSelectorListsEqual = (
left: InstanceSelector[],
right: InstanceSelector[]
) => {
if (left.length !== right.length) {
return false;
}
return left.every((selector, index) =>
areInstanceSelectorsEqual(selector, right[index])
);
};
const getSelectorKey = (selector: InstanceSelector) => JSON.stringify(selector);
const sortSelectorsByOrder = (
selectors: InstanceSelector[],
orderedSelectors: InstanceSelector[]
) => {
const order = new Map(
orderedSelectors.map((selector, index) => [getSelectorKey(selector), index])
);
return [...selectors].sort((left, right) => {
const leftIndex = order.get(getSelectorKey(left)) ?? Infinity;
const rightIndex = order.get(getSelectorKey(right)) ?? Infinity;
return leftIndex - rightIndex;
});
};
export const getInstanceSelectionUpdate = ({
selectedSelectors,
clickedSelector,
orderedSelectors,
anchorSelector,
isToggle,
isRange,
}: {
selectedSelectors: InstanceSelector[];
clickedSelector: InstanceSelector;
orderedSelectors: InstanceSelector[];
anchorSelector: undefined | InstanceSelector;
isToggle: boolean;
isRange: boolean;
}) => {
if (isRange && anchorSelector !== undefined) {
const anchorIndex = orderedSelectors.findIndex((selector) =>
areInstanceSelectorsEqual(selector, anchorSelector)
);
const clickedIndex = orderedSelectors.findIndex((selector) =>
areInstanceSelectorsEqual(selector, clickedSelector)
);
if (anchorIndex !== -1 && clickedIndex !== -1) {
const start = Math.min(anchorIndex, clickedIndex);
const end = Math.max(anchorIndex, clickedIndex);
const rangeSelectors = orderedSelectors.slice(start, end + 1);
return {
selectedSelectors: sortSelectorsByOrder(
[
...selectedSelectors.filter(
(selector) =>
rangeSelectors.some((rangeSelector) =>
areInstanceSelectorsEqual(selector, rangeSelector)
) === false
),
...rangeSelectors,
],
orderedSelectors
),
anchorSelector: clickedSelector,
};
}
}
if (isToggle) {
const isSelected = selectedSelectors.some((selector) =>
areInstanceSelectorsEqual(selector, clickedSelector)
);
const nextSelectedSelectors = isSelected
? selectedSelectors.filter(
(selector) =>
areInstanceSelectorsEqual(selector, clickedSelector) === false
)
: [...selectedSelectors, clickedSelector];
return {
selectedSelectors: sortSelectorsByOrder(
nextSelectedSelectors,
orderedSelectors
),
anchorSelector: clickedSelector,
};
}
return {
selectedSelectors: [clickedSelector],
anchorSelector: clickedSelector,
};
};
export const normalizeInstanceSelectors = (
instanceSelectors: InstanceSelector[]
) => {
const normalized: InstanceSelector[] = [];
for (const instanceSelector of instanceSelectors) {
if (normalized[0]?.[0] === ROOT_INSTANCE_ID) {
continue;
}
if (
instanceSelector.length === 1 &&
instanceSelector[0] === ROOT_INSTANCE_ID
) {
normalized.splice(0, normalized.length, instanceSelector);
continue;
}
const hasSelectedAncestor = normalized.some((selectedSelector) =>
isDescendantOrSelf(instanceSelector, selectedSelector)
);
if (hasSelectedAncestor) {
continue;
}
let removedSelectedDescendant = false;
for (let index = normalized.length - 1; index >= 0; index -= 1) {
const selectedSelector = normalized[index];
if (isDescendantOrSelf(selectedSelector, instanceSelector)) {
normalized.splice(index, 1);
removedSelectedDescendant = true;
}
}
if (removedSelectedDescendant) {
normalized.splice(0, normalized.length);
}
normalized.push(instanceSelector);
}
return normalized;
};
const indexedInstanceIdPattern = /^(.*)\[[^\]]+\]$/;
const getIndexedBaseInstanceId = (instanceId: InstanceSelector[number]) =>
indexedInstanceIdPattern.exec(instanceId)?.[1];
const isKnownSelectorSegment = (
instanceSelector: InstanceSelector,
index: number,
instances: Instances
) => {
const instanceId = instanceSelector[index];
if (instanceId === ROOT_INSTANCE_ID || instances.has(instanceId)) {
return true;
}
const baseInstanceId = getIndexedBaseInstanceId(instanceId);
return (
baseInstanceId !== undefined &&
instanceSelector[index + 1] === baseInstanceId &&
instances.has(baseInstanceId)
);
};
export const canResolveInstanceSelector = (
instanceSelector: InstanceSelector,
instances: Instances
) => {
for (let index = 0; index < instanceSelector.length; index += 1) {
if (isKnownSelectorSegment(instanceSelector, index, instances) === false) {
return false;
}
const parentId = instanceSelector[index + 1];
if (parentId === undefined) {
continue;
}
const instanceId = instanceSelector[index];
const baseInstanceId = getIndexedBaseInstanceId(instanceId);
if (baseInstanceId !== undefined && parentId === baseInstanceId) {
continue;
}
const parentInstance = instances.get(parentId);
if (
parentInstance?.children.some(
(child) => child.type === "id" && child.value === instanceId
) !== true
) {
return false;
}
}
return true;
};
export const getContextMenuSelectedInstanceSelectors = ({
selectedSelectors,
clickedSelector,
}: {
selectedSelectors: InstanceSelector[];
clickedSelector: InstanceSelector;
}) => {
const isClickedAlreadySelected = selectedSelectors.some((selector) =>
areInstanceSelectorsEqual(selector, clickedSelector)
);
return isClickedAlreadySelected ? selectedSelectors : [clickedSelector];
};