Files
OneCAD-Draft/docs/automation
buyun14 899af80c2f fix(automation): consume and report the tail of a batch command line
`run_command_line` feeds the tokens after the command verb to the interactive
command and then terminates it as if Enter were pressed. Two gaps left a
headless caller unable to tell what actually happened.

In-place text editor takes over, remaining tokens were dropped
----------------------------------------------------------------
The `TEXT` content step hands over to the in-place editor, at which point the
command has already ended (`active_cmd` is `None`). The feed loop stopped
there, so the rest of the line — including the text itself — vanished:
`TEXT 0,0 5 0 hello` reported `completed` with `added: 0` and created nothing.
The tail is now typed into that editor and committed, and the commit is done
synchronously so its result is observable: `text_inline_commit()` returning
false leaves the tokens in `unconsumed` instead of reporting a successful line.
Dispatch the commit as a queued message instead and a failed commit would look
exactly like a good one — the text dropped, `added: 0`, `unconsumed: []`.

Only an editor this line opens is taken over, and after a successful commit the
line is closed with an escape: `TEXT` re-arms for the next line
(`open_next_line`), which would otherwise let the *next* batch line be eaten as
the next text position.

Leftover tokens are dropped
---------------------------
Any token no prompt ever asked for is recorded in `CommandLine::unconsumed` and
surfaced by the automation `run` response, so over-typed or misplaced input is
visible instead of silently ignored: `CIRCLE 0,0 5 9` reports
`"unconsumed": ["9"]` while a fully consumed line reports an empty list.

Pending surfaces are reported instead of `completed`
----------------------------------------------------
`run` now carries `blocked_by` — `"command"`, `"text_editor"`,
`"mtext_editor"`, `"modal:<Kind>"`, or `null` — and answers `waiting_input`
when something is still open rather than claiming `completed`.

`blocked_by` is attributed to the line that opened the surface: a condition and
its snapshot are taken before the command runs, so an editor left open by an
earlier line, another tab, or a startup dialog does not turn a finished command
into `waiting_input`. Without that attribution a caller polling for `completed`
would wait forever on someone else's editor. `docs/automation` documented
`waiting_input` as "more input is required", which is exactly what a pending
surface is; the two new fields are documented there as well.

Tests
-----
* `batch_text_line_creates_text_and_consumes_every_token` — the regression case
  creates exactly one `Text` entity and leaves nothing unconsumed.
* `multi_word_text_keeps_the_spaces` — asserts the committed string, so an
  off-by-one in the consumed index or a wrong join cannot pass.
* `run_reports_leftover_tokens_instead_of_dropping_them`.
* `text_without_content_reports_the_open_editor`.
* `a_stray_editor_is_not_hijacked_by_the_next_line` — the next line neither
  types into the stray editor nor inherits its blocker (`blocked_by: null`,
  `status: completed`).
* `a_command_still_waiting_is_named_as_the_blocker`.

Verified over the `--serve` protocol: `TEXT 0,0 5 0 hello world` → `completed`,
`added: 1`, and the entity really holds `hello world`; `TEXT 40,0 5 0` →
`waiting_input` + `blocked_by: "text_editor"`; the following `CIRCLE 0,0 5 9` →
`completed`, `blocked_by: null`, `unconsumed: ["9"]`; `LINE` → `waiting_input`
+ `blocked_by: "command"`.

Full lib suite: 1497 passed, 2 failed — both failures
(`io::plot_style::…named_tables_and_missing_files_get_a_reason`,
`scene::parametric_constraints::…arc_grips_drive_center_start_and_end_but_not_midpoint`)
reproduce on the unmodified tree; run with `LC_ALL=C` since several xref tests
otherwise fail on a non-English locale.

Known remaining gap, unchanged here: `MTEXT` / `HATCH` still answer `completed`
with `added: 0` in this path (they need interactive surfaces the feeder has no
tokens to fill). Reported separately in #1390.
2026-09-20 19:15:55 +08:00
..

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.

A run reply also carries two fields that describe what happened to the line:

  • blocked_by — null when the command finished, otherwise what this line left open and waiting: "command" (an interactive command), "text_editor" (the in-place editor opened by the TEXT content step), "mtext_editor", or "modal:<Kind>" for a dialog. A surface that was already open when the line started (an earlier line's editor, another tab, a startup dialog) is not attributed to this line, so a caller polling for completed is never stuck waiting on someone else's editor. blocked_by is set whenever status is waiting_input.
  • unconsumed — the tokens of the line that no prompt ever asked for, in order. A fully consumed line reports []; CIRCLE 0,0 5 9 reports ["9"] because the radius step ends the command. Over-typed input is reported instead of being dropped on the floor.

When a line ends at the TEXT content step (TEXT 0,0 5 0 hello) the text is typed into the in-place editor and committed, so the line behaves exactly as the same input typed into the GUI command line would: one Text entity, nothing unconsumed, and the command closed rather than left armed for a second line. 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