docs / automation / Driving BREP from an AI agent (MCP)

Driving BREP from an AI agent (MCP)

For scripts entered directly in the CAD application, see the JavaScript script runner and API reference.

Status: Implemented (the app's own window with --mcp, and a headless host; Linux)

BREP speaks MCP, the protocol AI coding agents use to call tools. An agent sees five tools and drives everything else from a JavaScript program (see The front door). Every command the app understands is a function such a program calls: open and edit documents, add and change features by their schemas, click the real user interface, take screenshots, measure mass properties, and save or export the result. Claude Code and OpenAI Codex both speak MCP, so either one can build models, reproduce a bug report, or check a change in the app for you.

There are two ways to run it. Launch the app you downloaded with --mcp and the agent drives the window you are looking at. Or build brep-mcp, a server that runs the whole application headlessly, one private instance per session, which is what the test gate and the docs generator use.

The script functions are not hand-written. The server generates one per command in the app's own command registry, with its documentation string and argument schema (typed in docs/developer/mcp/generated/brep-cad.d.ts). It does this with --mcp at startup. The headless server seeds discovery from the command registry before starting a renderer, then refreshes it when its app starts. The app cannot drift away from what the agent sees.

The front door: five tools and scripts

ToolWhat it does
run_script {source, language?, wait_ms?, key?}Runs a program (an ES module with export default async function main() returning JSON) and awaits its terminal result through the same tool by default. Set wait_ms explicitly for a bounded wait (0 returns immediately); an unfinished run then answers its executionId and status and keeps going. Recovery through script_result remains available after a disconnect. language: "typescript" is type-checked first (strict, against the generated declarations), and a type error is refused with tsc's diagnostics. With no session live it starts a headless one.
script_result {executionId, wait_ms?}The result once it has ended, else its status.
script_halt {executionId}Kills the program's worker wherever it is. CAD operations it had already started run on.
screenshot {region?}A PNG of the app as it is now.
input {steps}Mouse and keyboard steps (click, drag, hotkey, type_text, key, wheel, click_entity, …), run in order.

Inside a program:

import { brep, cad } from "@brep/cad";
export default async function main() {
  await brep.document.document_open({ path: "/home/me/bracket.nbrep" });
  await brep.features.feature_set_params({ id: "F1", patch: { radius: 3 } });
  const shot = await brep.capture.screenshot({ region: "viewport" });
  return { solids: (await brep.scene.model_inspect({})).solidCount, image: shot.images[0] };
}

A program imports only @brep/cad. It has no filesystem, network or timers of its own (the app's tools read and write files). It runs in a separate worker process with wall-clock supervision. Linux also samples worker CPU and resident memory; these are watchdog limits, not an OS sandbox or a hard allocation cap. Other native platforms do not currently enforce CPU or memory limits. The trusted run_script front door can invoke app functions that access files and external services: its authority is the local user's. Restricted executions omit that general app bridge and check document grants separately for each capability.

key is a retry key of your choosing. Submitting the same key with the same program answers the execution it already started, so a run whose reply was lost is found again. The same key with a different program is refused (idempotency_conflict). Issued admission keys expire after five minutes; the front door preserves its retry-key mapping until process exit (at most 4096 distinct keys, then new keys are refused). Results have separate, bounded retention. An expired key or missing result is a reconciliation error, not permission to repeat an edit with a new key. Inspect document and operation state before deciding whether another edit is needed. By default, execution records do not survive server exit. For a named headless server, opt into persistent records with brep-mcp serve --state-dir /private/path/server-state.

The state directory belongs to one server identity; a second live server cannot open it. Create its parent directory first and reuse the same state directory after restart. Admission is synced before a worker starts. Completed result JSON can be read after restart using script_result or tasks/get; retrying the same key and program returns the same execution. A different program using that key is refused. Interrupted work becomes a failed execution with reconciliation_required and unknown effects, never an automatically replayed program. Inspect document and external state before deciding on a new edit.

Cancelling a script stops its worker; accepted CAD operations keep their own lifetime. Operation cancellation before start prevents dispatch. Document creation, closure and STEP export have a nonpreemptible dispatch boundary: cancellation reports too_late after that boundary, and the final operation result establishes the outcome. Staged recipe evaluation supports cooperative cancellation, but a late request does not undo a stage that already committed. Only an explicit host rollback acknowledgement permits reporting that a staged edit left no changes. These guarantees do not provide rollback for an entire script or external effects. If a follow-up query fails after a confirmed edit, the error retains the known document changes and completed artifact reads. A lost connection can leave the overall outcome unknown even with those observations. Inspect the affected state; a failed final query is not permission to repeat the mutation.

Persistent executions record host-call intent before dispatch and reply metadata before acknowledgement. Recovered hostCallEvidence (also recovery for an interrupted execution) lists calls, unacknowledged call IDs, and observed operation IDs, states and effect dispositions when supplied by the operation service. These are observations, not a complete transaction log: an accepted asynchronous call may continue after its reply or cancellation. Overall interrupted effects remain unknown, even when an individual operation was observed committed. Older records identify that host-call recording was not enabled. Call metadata excludes arguments and response payloads; at most 1024 host calls are admitted per persistent execution (wait-only pauses are excluded).

Persistence does not restore a running app, document handles, artifact bytes or an interrupted program's stack. Recovered results explicitly warn that referenced artifacts and handles may be unavailable. The private journal can contain result data; it does not record program source or raw retry keys as admission metadata. It retains at most 4096 admission identities and 128 MiB, reserving space for terminal outcomes. Capacity exhaustion refuses new admissions rather than forgetting old keys. Corruption or a truncated record refuses startup; do not delete state to bypass that refusal without reconciling prior work. This initial opt-in facility is exposed by the headless CLI and the server builder; embedded desktop sessions retain their existing process-scoped behavior.

Discover contracts and workflow skills

Start with cad.discovery.search({query, limit, cursor?}) inside a program, then cad.discovery.describe({paths}) for up to eight matching functions. Search includes the semantic operation catalogue and the generated brep.* registry. Cursors are bound to the query and catalogue. The MCP resource equivalents are brep://api/commands, brep://api/group/{group} and brep://api/function/{path}. brep://cad/sdk.d.ts contains the current typed module, including registry output schemas where provided.

The server advertises the io.modelcontextprotocol/skills extension. skills/list and skills/get expose eight versioned-by-digest workflows: inspect-model, safe-parametric-edit, import-step, recover-execution, edit-assembly, repair-feature, export-model and design-part (how to build parts and assemblies well; the server instructions point every client at it before a part is built or substantially edited). Read their skill://…/SKILL.md resources on demand. Skills are instructions, not authorization; every actual operation still passes the same permission and validation checks.

resources/templates/list discovers parameterized resources. Artifact references include a manifest URI and a chunk URI template. Read chunks at decimal byte offsets; each response contains at most 64 KiB of base64-encoded bytes. Artifacts expire and are scoped to the local transport owner. They are not public URLs.

Tasks, events and recovery

A client advertising the Tasks extension can receive a task handle from run_script and use tasks/get or tasks/cancel. Other clients continue using script_result and script_halt. Both interfaces observe the same execution; there is no second job queue. Without --state-dir, tasks and results are process-scoped, with bounded retention. The opt-in persistent mode retains their admission and result records as described above. Cancellation stops the program; already admitted CAD operations may finish and are not rolled back.

Subscribe through subscriptions/listen to brep://events, brep://documents or brep://execution/{executionId}. Notifications invalidate snapshots; read the resource again for authoritative state. Observation polls at 500 ms, coalesces changes, and limits a subscription to 32 resource URIs. The diagnostic ring keeps 1024 payload-free events with process epoch, stable correlation and monotonically ordered event sequence. It is not a durable journal: after reconnect or a gap, reconcile document/execution state. Resource reads and listings explicitly carry private cache scope and TTL; live state and artifact reads use a zero TTL. Read brep://events for the first retained page (at most 128 events), then follow nextUri while hasMore is true. Each page also gives nextCursor, the process epoch, and the oldest and next event sequence numbers. The replay template is brep://events/replay/{epoch}/{sequence}/{limit}; the sequence is the next event wanted and the limit is 1–128. Reuse a saved cursor after a disconnect. An expired cursor, a changed epoch, or a cursor ahead of the server returns an empty page with an explicit gap reason and a fresh continuation URI. Reconcile current state before resuming after a gap; the ring cannot recover evicted events. For diagnostic history across restarts, opt in with brep-mcp serve --event-journal-dir /private/path/event-history. This setting is independent of --state-dir. Read brep://events/archive for confirmed synced records and persistence health, then follow nextUri. Archive pages use brep://events/archive/{archiveId}/{sequence}/{limit} with limits of 1–128. The archive identity survives restart; each event retains its original process epoch. A disabled archive explicitly reports enabled: false.

The archive retains 512–1024 records after rotation, in two segments totaling at most 2 MiB. Events contain diagnostic metadata only, never program source, arguments, geometry or response bodies. A bounded background writer keeps disk I/O off request execution. Check persistence.pending, unrecordedLoss and status: an event in the live ring may not yet be synced to the archive. Queue loss and repaired truncated writes appear as gap records. Every reopen also adds a conservative process_boundary gap because the previous process may have stopped with unsynced events in its queue, even when the file ends cleanly. Expired or mismatched cursors return an explicit gap and a fresh continuation URI. A write failure is reported as failed persistence without failing CAD work. Reconcile current state after any gap or persistence failure; archived diagnostics are not transaction history and never authorize replay. Archive pages are polled, not subscribed.

The static skill manifests are publicly cacheable.

Tool replies contain both structured JSON and the compatible text representation. tools/list publishes the output schemas declared by each tool. The execution tools distinguish refusal (ok: false, error) from a successful query or admission (ok: true, value). For run_script and script_result, value is either {running: true, status} or a terminal execution result; script_halt returns an execution status. A successful query can describe a failed execution, so inspect value.state and value.error as well as ok. The schemas type lifecycle state, string sequence numbers, effects, artifacts and recovery evidence; the program's own output and error-specific context remain arbitrary JSON. MCP transport errors without structured content are outside these response envelopes. A failed synchronous execution sets MCP isError; querying the status of a failed execution remains a successful query. Sources are limited to 256 KiB, request arguments to 1 MiB and input batches to 256 steps. JavaScript and CAD require no external compiler. Optional TypeScript execution requires locally installed Node and TypeScript 7.0.2; neither is bundled. Native discovery checks explicit overrides, PATH and known user-local installation locations (see the MCP installation guide). Compiler work is time bounded and Linux RSS supervised. Native session and automatically created document-store directories are owner-only on Unix. MCP transport sessions start with argument recording disabled; enable it explicitly with brep.session.session_record({on: true}) when needed. Screenshot retention refuses new captures at 4096 captures or 256 MiB, preserving earlier files. Recording stops at 4096 calls or 16 MiB; callers can inspect or disable it with brep.session functions. Recording can contain arguments and file paths; use the session directory as private data. Browser workers support JavaScript only, at most two concurrent programs and 64 retained executions. Keyed records are preserved until reload; when retention is full, new work is refused instead of silently reusing a key.

Drive the app you have open: brep-app --mcp

Start the application from a terminal with the flag:

./brep-app --mcp                 # listens on http://127.0.0.1:8765/mcp
./brep-app --mcp --mcp-port 9000 # another port

Every switch the app accepts is in the command-line reference.

The window opens as usual, and the terminal prints how to connect each agent:

BREP MCP endpoint http://127.0.0.1:8765/mcp (serving after application host readiness)
This window is the session: an agent's tools act on the documents you see here.
Every request needs the server's token, `Authorization: Bearer <token>`; the token is in
  ~/.config/brep-app/mcp-token (readable by you only).

Claude Code:
  In a project's .mcp.json, with BREP_MCP_TOKEN loaded privately from the token file:
    { "mcpServers": { "brep": { "type": "http", "url": "http://127.0.0.1:8765/mcp", "headers": { "Authorization": "Bearer ${BREP_MCP_TOKEN}" } } } }

Codex, with BREP_MCP_TOKEN loaded privately from the token file:
  codex mcp add brep --url http://127.0.0.1:8765/mcp --bearer-token-env-var BREP_MCP_TOKEN
  or in ~/.codex/config.toml:
    [mcp_servers.brep]
    url = "http://127.0.0.1:8765/mcp"
    bearer_token_env_var = "BREP_MCP_TOKEN"

The token (loopback credential)

This is bearer authentication, not OAuth or Dynamic Client Registration. Load the credential file into the client’s token environment setting without logging it; do not store the token in committed configuration or screenshots. Every HTTP request must carry Authorization: Bearer <token>. Without it the server answers 401. A request from a browser page whose Origin is not loopback (127.0.0.1, localhost, [::1], any port) is answered 403. So another user on the machine, or a web page you visit, cannot drive your app.

The token is 256 random bits, minted the first time a server starts. It is kept in mcp-token in the app's config directory: $XDG_CONFIG_HOME/brep-app/, else ~/.config/brep-app/ (%APPDATA%\brep-app\ on Windows). The file is readable by you only (mode 0600; a looser file is narrowed). It is the same file for brep-app --mcp and brep-mcp serve --http, and it lasts, so you configure an agent once. Delete it to mint a new one. BREP_MCP_TOKEN_FILE names another file. The stdio transport (brep-mcp serve) needs no token: only the process that launched it can talk to it.

You do not need the flag: the Info window's Start MCP server button starts the same server at any time and shows this same text to copy.

Run the line for your agent once; it remembers the server. From then on, an agent started while the app is running sees the brep tools, and everything it does happens in your window, on your documents, in your own document store. The server is attached to the window from the start: a program acts on it directly, and nothing an agent does closes the app. Screenshots and the call log land under the OS temporary directory in brep-mcp/<session id>/, or under --session-root <dir> if you pass one.

A current Claude Code (protocol 2026-07-28) never sends initialize: it discovers the server, opens a subscriptions/listen stream on which it hears that the tool list changed, and lists the tools; the server answers all three, and every reply stream carries its JSON-RPC message as the first event. Note that claude mcp list only connects — it does not fetch tools — so judge the connection from a session: /mcp shows the tool count, and if it reports "tools fetch failed" the client's own log, under ~/.cache/claude-cli-nodejs/<project>/mcp-logs-<server>/, names the request that failed.

The server listens on the loopback address only and refuses requests whose Host is not local, so nothing outside your machine can reach the window. A second agent connecting at the same time shares the same window; there is no queueing between them beyond the app's own frame order. A port already in use stops the app with the reason instead of opening a window without its server; if the server then fails to attach, the terminal says so and the window runs without it.

The headless server: brep-mcp

The server binary hosts the app itself, in-process and headless: real user-interface frames on a real GPU device (a software renderer is enough), the real engine, no window and no browser. Every session is its own app instance with its own private document store beside its session directory, so an agent can never touch your open documents or your saved models by accident. It is what you want when the agent should work without a display.

Verified on Linux. The Windows builds of the app and the server compile; running the gate there is still open.

Build the server

From the repository root:

./build.sh mcp          # release build of target/release/brep-mcp
./build.sh test-mcp     # optional: the end-to-end gate, one headless session per script

The binary has five subcommands. brep-mcp serve speaks MCP over standard input and output and is what the agents launch; brep-mcp serve --http PORT speaks the same protocol over the streamable-HTTP transport brep-app --mcp uses, with no window — the way to point an HTTP-only client at a headless session, and the way the transport itself is exercised without a display. brep-mcp schema prints the feature catalogue as JSON Schema, or writes the generated reference pages. brep-mcp test <scripts…> runs recorded scripts with no MCP client at all. brep-mcp shot <doc.nbrep> <out.png> renders one document to a PNG. brep-mcp render-mesh <mesh.json> <out.png> rasterises a triangle mesh on the CPU instead — no session, no app, no GPU — and reports its silhouette; --help lists the framing modes and the mesh-versus-mesh comparison it also performs.

Sessions keep their store, screenshots and logs under the OS temporary directory in brep-mcp/; pass --session-root <dir> to serve, or set BREP_MCP_SESSION_ROOT, to put them somewhere you can find.

Connect Claude Code to the headless server

The repository root carries a .mcp.json that registers the server at project scope:

{
  "mcpServers": {
    "brep": {
      "command": "./target/release/brep-mcp",
      "args": ["serve"]
    }
  }
}

Start Claude Code in the repository and it offers to enable the project's servers on first use; approve brep, and the tools appear as mcp__brep__<tool> (for example mcp__brep__run_script). Type /mcp in the session to see the server's status and its tool list.

To use the server from any directory, register it at user scope instead:

claude mcp add --transport stdio --scope user brep -- /path/to/target/release/brep-mcp serve
claude mcp list

Connect Codex to the headless server

Codex reads MCP servers from ~/.codex/config.toml (a trusted project may also carry .codex/config.toml). Add a stdio server entry:

[mcp_servers.brep]
command = "/path/to/target/release/brep-mcp"
args = ["serve"]

or let the CLI write it:

codex mcp add brep -- /path/to/target/release/brep-mcp serve
codex mcp list

Both agents launch the server on demand, one process per conversation, and stop it when the conversation ends. The tools are the same as with --mcp. The first run_script builds the headless app: a 1400×960 point surface, seeded with the built-in example model, on a private store.

In the browser: WebMCP

The web build has no server process to speak MCP from, so it uses the web platform's own tool surface instead: WebMCP, the API a page uses to hand tools to the browser's agent. The page registers the same front door as the server: run_script, script_result, script_halt, screenshot and input. Five tools fit every browser's tool-count cap; ChatGPT's browser disables a page whose configuration "exceeds supported limits".

A program runs in a Web Worker of its own: BREP_app/web/code-worker.js, Boa in the app's own wasm. Its await brep.<group>.<tool>(args) calls come back to the page and run the server's tool set: BREP_mcp_core without its server feature compiles for the browser, and the page is its host (BREP_app/src/webmcp.rs). That gives every command of the registry under the server's name for it, and the compositions (click_entity, drag_widget, wait_state, the schema-seeded feature_add, …), with the same arguments and replies. script_halt terminates the worker wherever the program is.

In the browser:

An agent built into the browser (ChatGPT's browser; Chrome's early preview behind chrome://flags/#enable-webmcp-testing) sees the five tools on the page; nothing is installed, configured or launched.

The page is the session's platform. The clock is the page's, a wait is a setTimeout, and a FILE is a name in the app's document store (the browser's IndexedDB outside a PLM session, the PLM server inside one):

A refused call answers {error} with its reason, because a browser reports a tool that threw as a bare "invocation failed".

Whether or not the browser has an agent, the page publishes window.__brepWebMcp:

WebMCP needs a secure context (https://, or localhost / 127.0.0.1), so a page served over plain HTTP from another host registers nothing and says so in api. The browser gate (./build.sh test-web, verify_webmcp.mjs) drives the front door through Chrome's own executeTool: programs that seed and click by name, save, reopen and export; a halted spinning worker; a screenshot.

A first session

Ask the agent, in plain words, for what you want: "open bracket.nbrep, change the fillet radius on F1 to 3 and show me the result". Under the hood the agent writes a program whose calls look like the steps below, each await brep.<group>.<tool>(args) (the group is in brep-cad.d.ts):

  1. document_open {path} — opens a file (the first run_script attaches to your window, or with brep-mcp starts a headless app).
  2. feature_catalogue, then feature_schema {type} — what the kernel can build and what each feature's parameters mean, as JSON Schema with defaults. Reference names (Box_PX, S1:FACE, E1|E2, …) come from scene_entities, pick or selection.
  3. feature_add {type, params} or feature_set_params {id, patch} — parameters are validated against the schema before the engine sees them; the history reruns and the call returns when the run is idle. feature_add_many {features: [...]} takes a list of the same items and runs the history ONCE for all of them — the way to build a pattern or an assembly without paying a rebuild per part. It is atomic: if any item fails to validate, the error names its position and nothing is appended. A profile-driven feature (Extrude, Revolve) needs a Sketch first, and feature_schema {type: "S"} returns both the schema of its persistentData block and a complete working example to start from — also readable as the brep://schema/sketch resource.
  4. wait_idle, then screenshot {region: "viewport"} — the image comes back inline (downscaled) and the full-resolution PNG lands in the session's shots/ directory. For a presentation render pass settings: {showVertices: false, showEdges: false} and clear_selection: true: they apply to that capture only and are put back afterwards (except a vertex selection keyed by position, which does not survive the round trip). mass_properties {name} gives the closed-form check: volume, area, centroid, inertia.
  5. document_save {path} or document_export {path, format} — the server writes the file; the app only ever sees the content.

The session's own lifecycle (session_start, session_stop, session_restart) is not a script function: a program runs inside a session. wait_frames {count} lets the app draw on: the headless host steps it only while a call is in flight, so a debounce, a tooltip delay or an animation only advances inside that call.

Colour, material and any other durable per-object attribute is the metadata store: metadata_set {name, key, value} writes one, metadata_set_many writes a whole model's worth in one call. The well-known color key (a CSS hex #rrggbb) shades the object immediately and survives every later edit — the display's colours are re-derived from the store after each history run — and density (g/cm³) overrides the part's density for that one body's mass in its Info window and the BOM (mass_properties takes its own density argument, default 1).

A part's own BOM attributes — Part Number, Material, Density, and whatever else the BOM column configuration names — are part_attributes_get and part_attribute_set {field, value} on the active document; a null or empty value removes a field. Mass and Volume are not typed: part_attributes_get answers them in record and massProperties, computed from the geometry and the part's Density (or its Material's built-in density), and a Mass is accepted only for a document with no geometry. They are the record the toolbar's Part Properties dialog edits (part_properties_window {open}), and the one an assembly's BOM reads off each part.

Each workbench answers over the same surface: assembly_* and component_* for components and mates, bom_* for the BOM's part and occurrence attributes, interference_check; pmi_* for annotation views and their annotations; wire_harness_* for harness connections and the routing report; describe_workbenches for what the active one offers and workbench_button {id} to press one of its buttons. The Diagram, PCB, Symbol and Pads editors answer to ecad_action {id}, which runs any of eCAD's own actions its editor offers (zoom, delete, duplicate, cancel and the tools, as well as the row's), and ecad_state, which reads the editor back: what it shows, what is selected, its components, wires and pins, and inStep, whether the editor and the document hold exactly the same thing. eCAD's own undo is refused: the document keeps one undo history, undo / redo. Sheet Metal needs nothing of its own — its features come from the same catalogue as every other feature, and the flat pattern is document_export {format: "dxf" | "svg"}.

Everything positional is in egui points with the origin at the top-left of the app surface. Every reply from an app command carries the frame counter; frame_info, hit_rects and screenshot also carry the pixels-per-point value, and screenshots their pixel size, so the agent can convert when it needs to.

Clicking the real interface

The agent does not guess where buttons are. hit_rects returns every clickable widget the app published this frame as panel/key → [x, y, w, h], hit_key_docs explains what each key prefix means, and click_widget {key} scrolls the widget's pane until it is visible and clicks it. drag_widget {key, by} does the same and then drags it; drag_widget {key, to_key} drags it onto another published widget instead, such as one pin of an eCAD sheet onto another.

The 3D view is addressed the same way, by NAME rather than by coordinate: click_entity {kind, name} and drag_entity {kind, name, by} take a solid, face, edge or vertex, work out where it is on screen at the moment of the call, and click or drag there — refusing, and saying which entity, when it is off screen or something nearer is covering it. locate {kind, name} answers the same question without acting on it.

A click resolves to what is in FRONT — the nearest admitted hit by depth, the one the hover highlight is showing — even where several things share the point. To reach the ones behind it, ask for the pick list: dwell: true rests the pointer on the highlight first, which is the gesture the app opens the list on, and modifiers: {alt: true} opens the same list with no waiting. That list is a panel like any other: its rows are candidate/0, candidate/1, … in the order state_get __brepCandidates reports them, so click_widget {key: "candidate/1"} takes the second entry. That is how a script chooses between overlapping faces without a coordinate. locate says which of the two a click would be: takes is what a regular click lands on and reaches whether that is the entity you named, while under and rank describe the list a dwell would open.

selection reads back what is picked, and selection_set puts a whole selection back. Both list the names in PICK ORDER, oldest first: the first face in faces is the first one clicked, and a face clicked off and on again is last. That order is what every seeded reference spends — the first element of an assembly Distance is its BASE face, and a feature created from the selection fills its reference fields in the same order — so two picks build the same thing every time.

Below all of those sit the raw pointer and keyboard tools — click, drag, wheel, hotkey, type_text — for anything a key or a name does not cover, such as orbiting the view with a middle-button drag or typing into a field it just clicked.

notices drains what the app RAISED since the last call — every message it showed as a toast: a refused feature, a sketch that would not solve after an edit, an import or export failure, a save that did not reach storage, a restored session. Each entry carries its kind, the text the user would have read and the frame it arrived on, so a refusal is a reply the agent reads, not a toast it has to see. Messages the on-screen overlay had no room for are in the drain too, and every app-command reply carries the ones raised since the previous reply (the composed pointer, feature, document and screenshot tools do not).

notices is cumulative and empties when read; the __brepNotices state blob is the other half of the same story — the toast texts on screen right now, which expire on their own timer.

Recording and replay

Sessions record every call by default. session_script returns the recording as a script — a JSON list of tool calls with room for expectations — and brep-mcp test <script.json> replays it in a fresh headless session with no agent involved. A checked-in set of those scripts is the app's own end-to-end gate (./build.sh test-mcp), so a bug an agent reproduces can be kept as a regression test by saving its script beside them and adding the expectation that failed.

Those scripts are nearly all of the app's interaction tests: real egui frames, the real engine and a real pointer, with no display. What exists only in a browser runs separately under ./build.sh test-web. A script that needs a real FILE — to drive the Open dialog, or an import through the structure-choice modal the way a user would — lists it under fixtures, and the runner copies it into the session's own store before the app starts, so it is there in the file dialog and nothing is written outside the session.

perf_stats answers "what did that cost?": the frame phases in milliseconds, and runs, the history-replay tally since the last perf_reset — one entry per place that asked the engine to execute the document, with how many times, how long, and how many features came back from the incremental cache. An edit should cost exactly ONE; a second entry means something re-ran the whole document, which frame times alone cannot tell from a slow machine.

The generated reference

brep-mcp schema --write <dir> writes the reference pages for everything above from the live registries, so you can generate them against the server you built:

A test in ./build.sh test-mcp fails when a checked-in copy of these pages goes stale. Generate your own with the command above rather than reading a copy that may predate your build.

The PLM page's tools (WebMCP)

The PLM server's own web page registers its API as WebMCP tools on document.modelContext (Chrome 155 behind chrome://flags/#enable-webmcp-testing), as the signed-in user: search and read parts, BOMs, reviews and the audit trail; create and change parts and revisions; check out and in; submit and decide reviews; act on workflows; open pages of the app. The tools are listed, with the reply shape and the refusal contract, in BREP_plm/GUIDE.md under "WebMCP"; the page publishes window.__plmWebMcp.call(name, args) for a devtools console, and ./build.sh test-web drives the registered tools through Chrome's own executeTool (verify_plm_webmcp.mjs).

Troubleshooting

Audit contract updates

run_script now awaits terminal completion in the original call unless an explicit wait_ms requests a bounded wait. Task capability alone no longer changes this behavior. script_result remains available for recovery.

Waited feature/history mutations now align top-level report, step and history_len with after. A current report can contain feature errors: submission or transport success never proves geometric success. wait: false still returns submission state. Non-positive cylinder radius literals are refused before edits; expressions that evaluate to non-positive radii fail in the rebuild report.

For headless launch, TypeScript provisioning, artifact placement and current coordinate units, see the server guide.