Files
webstudio/apps/builder/app/services/api-build.server.ts
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

201 lines
5.0 KiB
TypeScript

import { nanoid } from "nanoid";
import { z } from "zod";
import { loadById, patchBuild } from "@webstudio-is/project/index.server";
import type { CompactBuild } from "@webstudio-is/project-build";
import {
loadBuildById,
loadDevBuildByProjectId,
} from "@webstudio-is/project-build/server";
import { buildPatchTransaction } from "@webstudio-is/protocol/schema";
import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
import { serializePages } from "@webstudio-is/project-migrations/pages";
import { assertApiProjectPermit } from "./api-permits.server";
import { throwApiError } from "./api-errors.server";
export const loadBuildByProjectVersion = async (
ctx: AppContext,
projectId: string,
version: number
) => {
const build = await ctx.postgrest.client
.from("Build")
.select("id")
.eq("projectId", projectId)
.eq("version", version)
.is("deployment", null)
.order("createdAt", { ascending: false })
.limit(1);
if (build.error) {
throw build.error;
}
const buildId = build.data.at(0)?.id;
if (buildId === undefined) {
return throwApiError("NOT_FOUND", "Build version not found for project");
}
return await loadBuildById(ctx, buildId);
};
export const loadReadableDevBuild = async (
ctx: AppContext,
projectId: string
) => {
await assertApiProjectPermit(ctx, projectId, "view");
return await loadDevBuildByProjectId(ctx, projectId);
};
const buildInclude = z.enum([
"pages",
"folders",
"instances",
"props",
"styles",
"styleSources",
"styleSourceSelections",
"designTokens",
"assets",
"resources",
"variables",
"breakpoints",
"projectSettings",
"marketplaceProduct",
]);
export type BuildInclude = z.infer<typeof buildInclude>;
export const buildGetInput = z.object({
projectId: z.string(),
include: z.array(buildInclude).optional(),
version: z.number().int().optional(),
});
export const buildPatchInput = z.object({
projectId: z.string(),
baseVersion: z.number().int(),
transactions: z.array(buildPatchTransaction).min(1),
});
export const createBuildSnapshot = ({
build,
include,
projectId,
}: {
build: Awaited<ReturnType<typeof loadDevBuildByProjectId>>;
include: Set<BuildInclude>;
projectId: string;
}) => {
const snapshot: Record<string, unknown> = {
projectId,
buildId: build.id,
version: build.version,
};
const add = (name: BuildInclude, value: unknown) => {
if (include.has(name)) {
snapshot[name] = value;
}
};
if (include.has("pages") || include.has("folders")) {
const pages = serializePages(build.pages);
snapshot.homePageId = pages.homePageId;
snapshot.rootFolderId = pages.rootFolderId;
if (include.has("pages")) {
snapshot.redirects = pages.redirects;
snapshot.pageTemplates = pages.pageTemplates;
}
add("pages", pages.pages);
add("folders", pages.folders);
}
add("projectSettings", build.projectSettings);
add("instances", build.instances);
add("props", build.props);
add("styles", build.styles);
add("styleSources", build.styleSources);
add("styleSourceSelections", build.styleSourceSelections);
if (include.has("designTokens")) {
snapshot.designTokens = build.styleSources.filter(
(styleSource) => styleSource.type === "token"
);
}
add("resources", build.resources);
add("variables", build.dataSources);
add("breakpoints", build.breakpoints);
add("marketplaceProduct", build.marketplaceProduct);
return snapshot;
};
export const serializeProjectSummary = (
project: Awaited<ReturnType<typeof loadById>>
) => {
if (project === null) {
throwApiError("NOT_FOUND", "Project not found");
}
return {
id: project.id,
name: project.title,
domain: project.domain ?? undefined,
createdAt: project.createdAt,
updatedAt:
project.latestBuildVirtual?.createdAt ??
project.latestStaticBuild?.updatedAt ??
project.createdAt,
};
};
export const commitBuildTransactions = async ({
ctx,
projectId,
buildId,
clientVersion,
transactions,
}: {
ctx: AppContext;
projectId: string;
buildId: string;
clientVersion: number;
transactions: z.infer<typeof buildPatchTransaction>[];
}) => {
const result = await patchBuild(
{
buildId,
projectId,
clientVersion,
transactions,
},
ctx
);
if (result.status === "version_mismatched") {
throwApiError("CONFLICT", result.errors);
}
if (result.status === "error") {
throwApiError("BAD_REQUEST", result.errors);
}
if (result.status !== "ok") {
return throwApiError("BAD_REQUEST", "Unexpected patch result");
}
return { version: result.version };
};
export const commitBuildPatch = async ({
build,
ctx,
projectId,
payload,
}: {
build: CompactBuild;
ctx: AppContext;
projectId: string;
payload: z.infer<typeof buildPatchTransaction>["payload"];
}) => {
return commitBuildTransactions({
ctx,
projectId,
buildId: build.id,
clientVersion: build.version,
transactions: [{ id: nanoid(), payload }],
});
};