forked from 1b-agent/opencadstudio-upstream
How to iterate the bridge: fast builds, digest pin, session model, discovery, launch serialization, cancellation, error routing, safety model, rebuild swaps, conformance gates. No diary entries, no harness names, no build-specific data.
99 lines
4.8 KiB
Markdown
99 lines
4.8 KiB
Markdown
# Developing the MCP bridge
|
|
|
|
Operator-facing setup lives in [README.md](README.md). This note is for
|
|
contributors iterating on the bridge itself: the stdio JSON-RPC layer in
|
|
`src/mcp.rs` and the operation validators in `src/mcp_ops.rs`.
|
|
|
|
## Fast iteration: no version bumps needed
|
|
|
|
- Run the bridge straight from a local build: `target/debug/OpenCADStudio --mcp`
|
|
for speed, `target/release/OpenCADStudio --mcp` when close to shipping.
|
|
Both report the same handshake; only the `build_profile` field differs.
|
|
- Tool-surface changes need no version-number change. `serverInfo` carries a
|
|
content `tool_schema` digest plus the `build_rev` revision hash, so staleness
|
|
is observable without a release.
|
|
- If you change any tool schema, the `tool_schema_digest_is_pinned` test
|
|
fails: review the diff, then update `TOOL_SCHEMA_DIGEST` deliberately
|
|
(never blindly).
|
|
|
|
## Sessions are per client connection
|
|
|
|
Clients bind the tool surface once when they connect, so after every
|
|
rebuild, restart the client session (or reconnect its MCP servers) to pick
|
|
up the new schema. The bridge helps: it watches its own executable and, once
|
|
it detects a rebuild, finishes the in-flight request and exits, so the next
|
|
call spawns a fresh bridge. No in-flight call ever fails because of this;
|
|
at worst the following call pays one respawn.
|
|
|
|
## Session discovery
|
|
|
|
`ocs_sessions` reads GUI descriptor files and verifies each one is alive
|
|
before probing it: process-liveness checks come first (cached snapshots
|
|
where the OS has no process table to read), dead descriptors are deleted,
|
|
and every remaining TCP probe uses a bounded timeout. Any step that cannot
|
|
run fails open toward treating the session as alive, so a broken check can
|
|
slow discovery but never wedge it.
|
|
|
|
Concurrent launches are serialized with `automation/starting.lock`
|
|
(`{"pid":..,"started":unix_secs}`, 60s TTL): the first `launch_if_none: true`
|
|
caller claims it and spawns the GUI, later callers with a fresh live claim
|
|
wait for the same descriptor instead of opening another window. Stale claims
|
|
and claims from dead pids are reclaimed; an unusable directory fails open so
|
|
a broken lock can never wedge launching. Covered by
|
|
`startup_lock_serializes_concurrent_launches` (claim, wait, stale-reclaim,
|
|
dead-pid-reclaim, release).
|
|
|
|
## Cancellation
|
|
|
|
`run()` reads stdin on a feeder thread into a channel (`CancelPump`); the
|
|
main loop stays the only stdout writer. Wait loops harvest the channel in
|
|
short quanta instead of sleeping blind. A `notifications/cancelled` naming
|
|
the in-flight request dismisses the GUI prompt with a second `{"op":"cancel"}`
|
|
exchange, drops the late result, and sends nothing back. Cancels arriving
|
|
between polls resolve through a pending-operation map; re-polls for dismissed
|
|
operations answer `cancelled` without touching the GUI. Interactive picks
|
|
(`user_select`, `getpoint`) have a ten-minute single-call ceiling ending in
|
|
an explicit timeout error; everything else keeps the one-minute clamp.
|
|
|
|
## Error routing
|
|
|
|
Tool-call failures are classified at the source (`CallError`): tool-domain
|
|
failures (bad arguments, failed operations) carry model-actionable guidance
|
|
and stay `isError` results; bridge and GUI infrastructure failures (no live
|
|
session, dead transport) become `-32603`; cancelled requests get no response.
|
|
Resource-not-found follows the negotiated era (`-32602` modern, `-32002`
|
|
legacy). Error results omit `structuredContent`: strict clients validate it
|
|
against `outputSchema`, which only describes success.
|
|
|
|
## Safety model
|
|
|
|
Never assume the client prompts before destructive calls. `ocs_execute` is
|
|
marked `destructiveHint: true`, but confirmation behavior is client-defined
|
|
and unobservable from here — server-side validation is the only safety net.
|
|
Validate every argument before touching the drawing, keep destructive and
|
|
read-only paths in separate ops, and prefer reversible effects.
|
|
|
|
## Rebuilding while the bridge runs
|
|
|
|
If the linker refuses to replace the binary while a bridge process runs
|
|
from it, move the running binary aside under a new name and rebuild. The
|
|
live bridge keeps serving from the moved file; restart the client
|
|
afterwards so it respawns from the fresh binary, and delete the moved file
|
|
once nothing runs from it anymore.
|
|
|
|
## Conformance gates
|
|
|
|
These tests hold the spec line; keep them green and extend them with every
|
|
protocol change: `tool_schema_digest_is_pinned`,
|
|
`initialize_is_spec_pure_with_experimental_build`,
|
|
`malformed_requests_get_jsonrpc_errors`,
|
|
`legacy_list_results_carry_sep2549_ttl`,
|
|
`resource_not_found_code_follows_era`,
|
|
`non_object_input_is_invalid_request`,
|
|
`unknown_tool_is_a_protocol_error_with_names`,
|
|
`cancel_matching_and_wait_deadlines`.
|
|
For end-to-end proof, run the official MCP Inspector CLI against a local
|
|
build (headless-safe methods only: `initialize`, `tools/list`,
|
|
`resources/list`, `resources/read` — anything touching the drawing needs a
|
|
live desktop session and stays in unit tests).
|