Files
OneCAD-Draft/docs/automation/README.md
T
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

179 lines
10 KiB
Markdown

# 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:
```sh
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:
```json
{
"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:
```json
{
"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`:
```json
{
"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.
```json
{
"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:
```json
{
"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:
```json
{
"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:
```json
{
"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:
```sh
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:
```sh
python3 docs/automation/mcp_eval.py target/debug/OpenCADStudio
```