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
| Tool | What 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:
- Every other tool is
await brep.<group>.<tool>(args), for examplebrep.features.feature_add,brep.scene.model_inspect,brep.widgets.click_widgetandbrep.document.document_save. Each runs that tool's own handler and answers its JSON. cad(from the same module) is the typed operation client (cad.documents.create,doc.solids.extrude,doc.files.import, …).serviceholds the rawcad.*methods.
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:
- The wall-clock limit (15 minutes), the source and result sizes and the outstanding-call cap are enforced. CPU time and heap cannot be measured in a page; the worker is terminated at the wall.
- Programs are JavaScript. TypeScript is type-checked and run by the native
app and
brep-mcp. - The
cad.*operation client (cad,service) is the native server's. Naming it in a page says so.
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):
document_save {path: "bracket.nbrep"}saves where the File menu's Save As would, and Open lists it.document_open {path}reads it back.document_export {path: "bracket.step", format: "step"}lands as a download, where the Export dialog's would, and never in the store.store_list,store_read,store_writeandstore_removeare the store's own doors.- The front door's
screenshotkeeps its capture as ablob:URL (path; the last few atwindow.__brepWebMcp.shots()), with the inline picture inimagesand, oninline: true, asdata_urltoo. From a program, a screenshot's picture is a data URL inimages.
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:
apinames the object the tools were registered on (document.modelContext, ornone).toolsandregisteredsay what was registered;errorsholds any registration the browser refused.call(name, args)andrun(source)drive the same path from the devtools console.
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):
document_open {path}— opens a file (the firstrun_scriptattaches to your window, or withbrep-mcpstarts a headless app).feature_catalogue, thenfeature_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 fromscene_entities,pickorselection.feature_add {type, params}orfeature_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, andfeature_schema {type: "S"}returns both the schema of itspersistentDatablock and a complete working example to start from — also readable as thebrep://schema/sketchresource.wait_idle, thenscreenshot {region: "viewport"}— the image comes back inline (downscaled) and the full-resolution PNG lands in the session'sshots/directory. For a presentation render passsettings: {showVertices: false, showEdges: false}andclear_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.document_save {path}ordocument_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:
commands.md— every tool, grouped (assembly, camera, capture, document, ecad, features, frame, harness, history, keyboard, metadata, pmi, pointer, scene, session, settings, sheets, shell, state, widgets), with its argument schema.features.md— every feature's parameter schema and defaults.state.md— the state blobs the app publishes per frame.widgets.md— the documented hit-key prefixes per panel, each with the command that does the same thing without a pointer; every toolbar button names one.
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
- The agent lists no
breptools. With--mcp: the app is not running, or was started without the flag, or on another port than the one you registered; the terminal line names the URL. With the headless server: the binary is missing or not executable: run./build.sh mcpand check the path in.mcp.jsonorconfig.tomlfrom the directory the agent starts in. brep-app --mcpexits with "Address already in use". Another app (or an earlierbrep-app --mcp) holds the port: close it, or pass--mcp-port.- The first
run_scriptfails to create a GPU device. The headless host needs a Vulkan-capable device or a software renderer such as Mesa's llvmpipe; install the Mesa Vulkan drivers on a headless machine. - A click "does nothing". Read
hit_rectsfor the key: a widget outside its pane'spanel:cliprect is not on screen.click_widgetscrolls it into view first; a rawclickat a stale coordinate does not. - Where did the screenshot go?
session_inforeports the session directory; captures are under itsshots/, the call log inlog.jsonl.
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.