Files
OneCAD-Draft/docs/automation/README.md
T
Afonsop 2c38de53e2 feat: hatch PDF draw order (#1345), numeric CAS (#1348), web automation (#1349), DATALINK table, System theme; honest decode accounting
Upstream issues, fixed:

#1345 — hatches plotted at draw_depth 0.0: the plot path cloned models
from the hatch cache, which bakes the depth at seed time; the canvas
re-reads the live depth map every frame, which is why the editor was
right and every export (PDF and native printing — both route through
the exporter) was wrong, with hatch-vs-hatch ties broken by HashMap
order. The plot path now re-reads the depth map per entity, and the
exporter's tie-break ranks follow the editor's own pass order
(hatch < image < wire fill < wire < text < wipeout) instead of pinning
hatches between fills and wires. Pinned by content-stream operator-
order tests (back/front hatches, overlapping hatches, interleaved
wire-between-hatches) and a scene test that fails pre-fix.

#1348 — set_properties' optimistic-concurrency compare now matches
numbers by value (10.0 == 10) through json_values_match, compatible
with the shape of upstream PR #1350; nested objects/arrays recurse.

#1349 — web automation contract: control protocol 2 (v1 clients still
accepted) adds {op:'open', name, data_base64} routed through the
guarded open queue; window.ocsControl.ready/request promise wrapper
with an ocs-ready event (web/ocs-control.js); the startup donation
prompt is suppressed once a control request is seen (opt-in pre-boot
via localStorage); docs/automation documents the whole web contract.

Also: DATALINK CSV-to-table works on web (picker + bytes through the
session registry — was std::fs, silently dead); COLORSCHEME accepts
System (prefers-color-scheme on web, AppsUseLightTheme on Windows).

Decode accounting, made honest per the fix-of-fix review: into_rgba8
allocates the RGBA destination while the native buffer is alive for
every non-Rgba8 layout (L8 5, La8 6, Rgb8 7, L16 6, La16 8 B/px
peaks — the formula now says so), GIF bills the screen+frame
coexistence at 8 B/px, and TIFF drops the 3x multipliers both ways —
tiff 0.11 splits the given budget between frame and scratch (a total
ceiling), so the old double-billing refused honest 16-bit GeoTIFFs
~2.25x early while admitting hostile chunk transients at 3x. The
image-refusal-unknown-size locale key moves to the group the catalog
always claimed (file).
2026-09-18 13:21:18 +01:00

11 KiB

OpenCADStudio MCP control

Every native OpenCADStudio build contains the same MCP server as the editor. OpenCADStudio --mcp starts it over stdio, opens the desktop editor when needed, and exposes the live document without Python, a package manager, a sidecar service, or client-specific code.

MCP lets an AI client inspect the open drawing, execute editor commands, and verify the result through a shared protocol. Install OpenCADStudio, then add a local MCP server in the client and set its command to:

OpenCADStudio --mcp

The client must start that command over standard input/output. If it asks for the executable and arguments separately, select the installed OpenCADStudio executable and enter --mcp as its only argument. The exact registration screen or configuration location belongs to the client. Once connected, the four tools below should appear in the client's MCP tool list.

The server provides four tools:

  • ocs_sessions finds running editor sessions and opens OpenCADStudio when none exists.
  • ocs_read discovers capabilities and reads document state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events, and operation status.
  • ocs_execute performs one operation, an atomic record update, or a sequential batch against the real editor.
  • ocs_capture returns a bounded PNG of the drawing viewport or complete window.

The normal flow is to call ocs_sessions, pass its returned session_id as ocs_session_id to the other tools, read the chosen session with ocs_read, then pass the returned document state into ocs_execute. For example, an undo request has this shape:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "request": {
    "op": "undo",
    "request_id": "undo-1",
    "document_id": 1,
    "revision": 12
  }
}

Call ocs_read with op: "capabilities" before unfamiliar work. It reports the command, geometry, transaction, capture, and database facilities supported by the running build. The records.collections list gives every available database collection, its record count, and whether it can be edited.

Call ocs_read with op: "record_schema" before editing an unfamiliar record. With no parameters it lists the complete generated type registry. A collection returns the record types accepted by that collection even when the current drawing has no instance of a type. Supplying both collection and type returns the type's complete dependency graph, flattened property paths, JSON types, optional and sequence markers, enum variants, integer bounds, unambiguous unit annotations, identity fields, and write rules:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "op": "record_schema",
  "parameters": {
    "collection": "entities",
    "type": "Insert"
  }
}

Sequence fields include an item_path_template such as /attributes/{index}. Replace {index} with an index returned by records before using the path in set_properties. When a matching record exists, runtime_schema adds its exact JSON shape and bounded scalar examples. The embedded registry is generated from all 48 entity variants, all 36 non-graphical object variants, symbol-table records, header data, summary data, and the remaining stored record roots at build time.

op: "records" returns every serializable property of entities, non-graphical objects, symbol-table records, the complete header, document metadata, and derived database views. Omitting collection returns the same collection manifest; use collection: "all" for a paged query across the complete database. Records have a stable collection plus a handle or name, a type, and properties. Entity records also include their display and file record type names.

Property filters and projections use RFC 6901 JSON Pointer paths relative to properties. Multiple where filters are combined with AND. Supported operators are eq, ne, lt, lte, gt, gte, contains, starts_with, ends_with, in, exists, and not_exists:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "op": "records",
  "parameters": {
    "collection": "entities",
    "type": "Insert",
    "where": [
      {"path": "/common/layer", "value": "Equipment"},
      {"path": "/attributes/0/tag", "value": "COMPANY"}
    ],
    "paths": ["/block_name", "/insert_point", "/attributes"]
  }
}

Use set_properties to replace one or more fields atomically. OCS verifies the record identity, JSON types, optional compare-and-set values, layer locks, document revision, and request ID before committing one undoable edit. Handles, ownership slots, table names, and other database identity fields are read-only; their dedicated editor commands preserve cross-record references.

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "request": {
    "op": "set_properties",
    "request_id": "attributes-1",
    "document_id": 1,
    "revision": 12,
    "collection": "entities",
    "handle": "2A",
    "updates": [
      {"path": "/attributes/0/value", "expected": "BPH", "value": "Updated"},
      {"path": "/insert_point/z", "value": 3.0}
    ]
  }
}

The same operation edits entries in objects, every symbol table, header, and summary_info. Collections marked mutable: false are derived or storage metadata and remain queryable. Their source records must be changed so OCS can rebuild those views consistently.

To discover command syntax without reading application source, call ocs_read with op: "commands". The unfiltered response lists commands and actions. Add parameters: {"name": "PLINE"} for a command's batch examples and interactive guidance.

A complete command can be sent with run. Prompt answers are separated by spaces, points use x,y or x,y,z, and option answers use their displayed token:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "request": {
    "op": "run",
    "request_id": "polyline-1",
    "document_id": 1,
    "revision": 12,
    "cmd": "PLINE 0,0 10,0 10,10 C"
  }
}

Use batch when the steps are already known. OCS supplies each step with the state produced by the previous one and stops on the first failure. completed_steps and next_step show exactly what committed:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "response_detail": "changed_entities",
  "request": {
    "op": "batch",
    "request_id": "shape-1",
    "steps": [
      {"op": "run", "cmd": "LINE 0,0 10,0"},
      {"op": "run", "cmd": "CIRCLE 5,5 2"}
    ]
  }
}

Execute responses use response_detail: "compact" by default and return only the state needed for the next edit. Use changed_entities to receive the current geometry of affected handles in the same response, or full when the complete editor state is needed.

ocs_read query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. detail: "full" adds the complete serialized entity properties. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "op": "query",
  "parameters": {"intersections": ["2A", "31"]}
}

For unfamiliar or conditional commands, use start, then inspect state.command in every response. Its accepts array gives the valid MCP input kinds, options gives the current tokens, and input_example gives the next request shape. Add the current state fields and a new request_id to each step.

Every ocs_execute request requires a caller-generated request_id. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report waiting_input while more input is required and asynchronous work remains running until its real callback finishes. Geometry stays in OpenCADStudio and its geometry kernel.

ocs_capture defaults to the drawing viewport and a longest edge of 1600 pixels. Set scope to window for the full interface or change max_dimension between 256 and 4096.

Clients using MCP 2026-07-28 can advertise io.modelcontextprotocol/tasks. OCS then returns a standard task handle when an operation is still running and accepts tasks/get, tasks/update, and tasks/cancel. Other clients continue to receive the existing operation status and can read it with ocs_read.

The source-tree protocol smoke test treats the executable as a black box and uses only the Python standard library. It checks both supported protocol styles, the published schemas, structured errors, and the four-tool surface:

cargo build
python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio

The repeatable live-editor evaluation draws three isolated entities in one batch, verifies exact intersections, nearest geometry and kernel measurements, removes the entities, and reports call count, elapsed time and wire bytes:

python3 docs/automation/mcp_eval.py target/debug/OpenCADStudio

Web automation

The web build exposes the same control protocol (protocol 2; protocol 1 requests are still accepted unchanged) through two wasm exports on window.wasmBindings:

  • ocs_control_submit(requestJson) returns a ticket id immediately.
  • ocs_control_take(ticketId) returns the JSON reply once the app has settled the request, or null while it runs.

web/ocs-control.js (loaded by web-app.html) wraps that pair in a small promise API:

await window.ocsControl.ready;              // resolves when the bridge exists
window.addEventListener("ocs-ready", () => {}); // ...or listen for the one-shot event
const state = await window.ocsControl.request({op: "state"});
const reply = await window.ocsControl.request({
  op: "run", request_id: "line-1",
  document_id: state.document_id, revision: state.revision,
  cmd: "LINE 0,0 10,0",
});

request resolves with the final reply, following an accepted/running operation through op: "operation" until it completes, fails, or waits for input. It rejects on timeout (default 120 s, override with {timeoutMs, pollMs}) or when the bridge is missing.

Opening an in-memory drawing (web only)

The open operation accepts base64 bytes instead of a path, so automation can open drawings it already holds in memory — no OPFS copy, no file picker. The bytes go through the same guarded open queue drag-and-drop uses:

const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
await window.ocsControl.request({
  op: "open", request_id: "open-1", name: "plan.dwg",
  data_base64: btoa(binary),
});

The operation stays pending until the open settles; if another open holds the slot, the reply reports result.queued and the poll opens the drawing as soon as the slot frees. state.capabilities lists open_bytes on the web build. Native builds keep the path-based open and reject data_base64 with invalid_request.

Startup prompts during automation

The first control request of a session marks it automated: a donation prompt that has not been shown yet is dropped, and no new one is queued while control stays enabled. A prompt already on screen is left for its reader — close it with {"op":"action","name":"close_modal"}. A web host that intends to automate from the first page load can set localStorage["opencadstudio.automation"] = "1" before loading the app so the prompt never appears (the same pre-load mechanism the settings use).