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

358 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Combobox,
Flex,
Grid,
InputErrorsTooltip,
InputField,
Link,
List,
ListItem,
ProChip,
rawTheme,
ScrollArea,
SearchField,
SmallIconButton,
Text,
theme,
Tooltip,
buttonStyle,
} from "@webstudio-is/design-system";
import { InfoCircleIcon, TrashIcon } from "@webstudio-is/icons";
import { useStore } from "@nanostores/react";
import {
createBasicAuthRoute,
serializeWsAuth,
type WsAuthRoute,
} from "@webstudio-is/wsauth";
import { validateBasicAuthCredentials } from "@webstudio-is/project-build/runtime";
import { $permissions } from "~/shared/nano-states";
import { $pages, $projectSettings } from "~/shared/sync/data-stores";
import { getExistingRoutePaths, sectionSpacing } from "./utils";
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
import {
parseProjectAuthRoutes,
validateProjectAuthRoute,
} from "@webstudio-is/project-build/contracts";
const saveAuthRoutes = (authRoutes: WsAuthRoute[]) => {
executeRuntimeMutation({
id: "projectSettings.update",
input: {
meta: {
auth: authRoutes.length === 0 ? null : serializeWsAuth(authRoutes),
},
},
});
};
export const SectionAuth = () => {
const { allowAuth } = useStore($permissions);
const pages = useStore($pages);
const projectSettings = useStore($projectSettings);
const routeRef = useRef<HTMLInputElement>(null);
const [authRoutes, setAuthRoutes] = useState(() => {
return parseProjectAuthRoutes($projectSettings.get()?.meta.auth).routes;
});
const [route, setRoute] = useState("");
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [routeErrors, setRouteErrors] = useState<string[]>([]);
const [loginErrors, setLoginErrors] = useState<string[]>([]);
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const authContent = projectSettings?.meta.auth;
const parseResult = useMemo(() => {
return parseProjectAuthRoutes(authContent);
}, [authContent]);
const parseErrors = parseResult.errors;
useEffect(() => {
setAuthRoutes(parseResult.routes);
}, [parseResult.routes]);
const existingPaths = getExistingRoutePaths(pages);
const routeSuggestions = ["/", ...Array.from(existingPaths).sort()];
const filteredAuthRoutes = searchQuery
? authRoutes.filter((authRoute) => {
const query = searchQuery.toLowerCase();
return (
authRoute.route.toLowerCase().includes(query) ||
authRoute.auth.login.toLowerCase().includes(query)
);
})
: authRoutes;
const handleRouteChange = (value: string) => {
setRoute(value);
setRouteErrors(validateProjectAuthRoute(value.trim(), authRoutes));
};
const handleSave = (nextAuthRoutes: WsAuthRoute[]) => {
setAuthRoutes(nextAuthRoutes);
saveAuthRoutes(nextAuthRoutes);
};
const handleAddAuthRoute = () => {
const nextRoute = route.trim();
const nextRouteErrors = validateProjectAuthRoute(nextRoute, authRoutes);
const basicAuthErrors = validateBasicAuthCredentials({
login,
password,
});
const nextLoginErrors = basicAuthErrors?.login ?? [];
const nextPasswordErrors = basicAuthErrors?.password ?? [];
setRouteErrors(nextRouteErrors);
setLoginErrors(nextLoginErrors);
setPasswordErrors(nextPasswordErrors);
if (
nextRouteErrors.length > 0 ||
nextLoginErrors.length > 0 ||
nextPasswordErrors.length > 0
) {
return;
}
handleSave([
createBasicAuthRoute({
route: nextRoute,
login,
password,
}),
...authRoutes,
]);
setRoute("");
setLogin("");
setPassword("");
routeRef.current?.focus();
};
const handleDeleteAuthRoute = (index: number) => {
const nextAuthRoutes = [...authRoutes];
nextAuthRoutes.splice(index, 1);
handleSave(nextAuthRoutes);
};
const handleReset = () => {
setRouteErrors([]);
setLoginErrors([]);
setPasswordErrors([]);
handleSave([]);
};
return (
<Grid gap={3} css={sectionSpacing}>
<Flex align="center" gap={1}>
<Text variant="titles">Authentication</Text>
{allowAuth === false && <ProChip>PRO</ProChip>}
<Tooltip
content={
<>
<Text>
Authentication asks visitors for HTTP Basic Auth credentials
before protected pages load on custom domains.
</Text>
<br />
<Text>
Routes use the same syntax as page paths, including :params and
* wildcards.
</Text>
{allowAuth === false && (
<>
<br />
<Text>
Authentication is a Pro feature. You can publish to staging
for free; upgrade to Pro to publish to custom domains.
</Text>
<Link
className={buttonStyle({ color: "gradient" })}
css={{ marginTop: theme.spacing[5], width: "100%" }}
color="contrast"
underline="none"
target="_blank"
href="https://webstudio.is/pricing"
>
Upgrade
</Link>
</>
)}
</>
}
variant="wrapped"
>
<InfoCircleIcon
color={rawTheme.colors.foregroundSubtle}
tabIndex={-1}
/>
</Tooltip>
</Flex>
{parseErrors.length > 0 && (
<Grid gap={2}>
<Grid gap={1}>
{parseErrors.map((error) => (
<Text key={`${error.path}:${error.message}`} color="destructive">
{error.path}: {error.message}
</Text>
))}
</Grid>
<Flex>
<Button color="destructive" onClick={handleReset}>
Reset authentication
</Button>
</Flex>
</Grid>
)}
<Flex gap="2" justify="between">
<SearchField
placeholder="Search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onAbort={() => setSearchQuery("")}
disabled={authRoutes.length === 0}
/>
<Button
color="ghost"
prefix={<TrashIcon />}
disabled={authRoutes.length === 0}
onClick={handleReset}
>
Delete all
</Button>
</Flex>
<Flex gap="2" align="center">
<InputErrorsTooltip
errors={routeErrors.length > 0 ? routeErrors : undefined}
side="top"
>
<Combobox<string>
inputRef={routeRef}
autoFocus
placeholder="/private or /docs/*"
value={route}
color={routeErrors.length === 0 ? undefined : "error"}
getItems={() => routeSuggestions}
itemToString={(item) => item ?? ""}
onItemSelect={(value) => handleRouteChange(value ?? "")}
onChange={(value) => {
if (value !== undefined) {
handleRouteChange(value);
}
}}
/>
</InputErrorsTooltip>
<InputErrorsTooltip
errors={loginErrors.length > 0 ? loginErrors : undefined}
side="top"
>
<InputField
placeholder="Login"
value={login}
color={loginErrors.length === 0 ? undefined : "error"}
onChange={(event) => {
setLogin(event.target.value);
setLoginErrors([]);
}}
/>
</InputErrorsTooltip>
<InputErrorsTooltip
errors={passwordErrors.length > 0 ? passwordErrors : undefined}
side="top"
>
<InputField
placeholder="Password"
type="password"
value={password}
color={passwordErrors.length === 0 ? undefined : "error"}
onChange={(event) => {
setPassword(event.target.value);
setPasswordErrors([]);
}}
/>
</InputErrorsTooltip>
<Button
disabled={
routeErrors.length > 0 ||
loginErrors.length > 0 ||
passwordErrors.length > 0
}
onClick={handleAddAuthRoute}
css={{ flexShrink: 0 }}
>
Add
</Button>
</Flex>
{authRoutes.length > 0 ? (
<ScrollArea>
<Grid>
<List asChild>
<Flex direction="column" gap="1" align="stretch">
{filteredAuthRoutes.map((authRoute) => {
const index = authRoutes.indexOf(authRoute);
return (
<ListItem asChild key={authRoute.route}>
<Grid
align="center"
gap="2"
css={{
p: theme.spacing[3],
overflow: "hidden",
gridTemplateColumns: "1fr 1fr",
position: "relative",
"& > button": {
opacity: 0,
position: "absolute",
right: 0,
top: 0,
bottom: 0,
height: "auto",
borderRadius: 0,
background: theme.colors.backgroundPanel,
},
"&:hover > button, &:focus-within > button": {
opacity: 1,
},
}}
>
<Tooltip content={authRoute.route}>
<Text
truncate
css={{
wordBreak: "break-all",
}}
>
{authRoute.route}
</Text>
</Tooltip>
<Tooltip content={authRoute.auth.login}>
<Text truncate>{authRoute.auth.login}</Text>
</Tooltip>
<SmallIconButton
variant="destructive"
icon={<TrashIcon />}
aria-label={`Delete authentication for ${authRoute.route}`}
onClick={() => handleDeleteAuthRoute(index)}
/>
</Grid>
</ListItem>
);
})}
</Flex>
</List>
</Grid>
</ScrollArea>
) : null}
</Grid>
);
};