diff --git a/.github/release-notes/next-mcp-claude-code.md b/.github/release-notes/next-mcp-claude-code.md
new file mode 100644
index 00000000..203b0a03
--- /dev/null
+++ b/.github/release-notes/next-mcp-claude-code.md
@@ -0,0 +1,59 @@
+
+
+## Highlights — use Claude Code as your OpenPCB agent
+
+- **Claude Code integration (MCP).** Claude Code — using the Claude subscription you already
+ have — can now drive OpenPCB: everything the built-in Assistant does (find parts, build and wire
+ schematics, review, ERC, DRC, BOM), plus work the built-in Assistant cannot do yet: **PCB
+ footprint placement, trace and via routing, board outline, design rules and net classes, copper
+ zones, keepouts, DRC waivers, renaming, deleting and focusing designs, and undo/redo.** Claude
+ Desktop and other MCP clients work too.
+- **One-click setup.** Settings → Assistant → *MCP server · Claude Code* → **Connect Claude Code**
+ installs an OpenPCB plugin for Claude Code (the tools plus workflow skills: build a circuit,
+ review a schematic, DRC triage, BOM check, PCB layout, connection help). Commands for setting it
+ up by hand are in the same panel.
+- **You stay in control.**
+ - The MCP server and "Allow writes" are both **off** until you turn them on.
+ - With writes on, edits apply immediately and land in your normal undo history (Ctrl+Z).
+ - Deletions, design-rule and net-class changes, DRC waivers, ignoring DRC rule classes and
+ deleting a design wait for your approval in the assistant panel; Claude waits for your
+ decision. An approval card for a design that has changed since is refused, never applied.
+ - DRC results Claude reports always include waived and hidden violations — it cannot present a
+ board with suppressed problems as clean.
+ - Each Claude Code session is kept apart: it can undo only its own most recent change (never
+ yours or another session's), and only it can follow its proposals.
+ - Every call Claude makes is logged in a per-session "MCP · Claude Code" chat on the design, and
+ the canvas updates live as it works.
+ - OpenPCB only ever updates or removes the Claude Code registration it created itself.
+- **Robust connection.** Claude Code connects through a launcher OpenPCB keeps up to date, so the
+ setup survives app updates, moves and restarts; while OpenPCB is closed, Claude Code is told to
+ ask you to start it instead of failing.
+
+## Fixes
+
+- Pressing Ctrl+Z right after the Assistant edited a design could undo one of your own earlier
+ changes instead of the Assistant's edit. Undo now always reverses the most recent change,
+ whoever made it.
+
+## Known issues (MCP)
+
+- On Windows, Claude Code runs OpenPCB's own executable for the connection. If you move or
+ reinstall OpenPCB elsewhere, Settings → Assistant → MCP shows **Update connection** (or **Update
+ plugin**); click it once.
+- The connection runs OpenPCB's own binary as Node. On the Linux **AppImage** and the Windows
+ **portable** build this was not verified on real hardware before release. If Claude Code cannot
+ connect: on Linux, installing Node.js is a workaround (the launcher falls back to it); on Windows,
+ use the installer build.
+- On macOS, run OpenPCB from the Applications folder. Opened straight from the disk image or
+ Downloads, macOS gives it a temporary path; Settings shows a warning and Claude Code may lose it
+ after you quit.
+- After updating OpenPCB, click **Update plugin** in Settings, then run `/reload-plugins` in open
+ Claude Code sessions, so Claude Code picks up new tools and skills.
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
index cf101c22..283c80c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -323,37 +323,98 @@ OpenPCB is a **single-user desktop app with no auth layer**. Loopback is the sec
## MCP server
-OpenPCB exposes its assistant tool registry over **MCP** so external agents (Claude Code, Claude
-Desktop, Codex) can drive the design the user has open. It lives **inside the assistant module**,
-which owns the registry, `ContextResolver`, proposals and the write policy.
-
-- **Endpoint:** Streamable HTTP at `/api/modules/assistant/mcp` (POST/GET/DELETE), built on
- `@modelcontextprotocol/server` v2's `createMcpHandler`, whose `fetch(Request) → Response` matches
- the module router natively. Code under `src/modules/assistant/backend/mcp/`.
-- **Sessions are backed by a real assistant chat**, one per client, matched on
- `metadata.mcp.clientKey` derived from the `X-OpenPCB-MCP-Client` header or User-Agent — it must be
- **header-stable, never the display name**. This is what lets every existing designer tool work
- unchanged: they resolve their design through `contextResolver.getPrimaryDesign(chatId)`. It also
- means MCP tool calls and pending proposals render in the assistant panel.
-- **Tools:** the 15 in-app `AiTool`s projected 1:1 (`AiToolDefinition` is already MCP-shaped;
- `fromJsonSchema` takes `inputSchema` verbatim), plus MCP-only extended reads in
- `tools/read-tools.ts` (`designer_list_designs`, `get_pcb_state`, `run_erc`, `run_drc`, `get_bom`,
- `export_manufacturing`) and the session-scoped `designer_use_design`. **Do not add the extended
- reads to the in-app registry** — its prompt and DoD harness are tuned against the current 15.
-- **Design targeting:** explicit `designId` → session pin (`designer_use_design`) → UI-active
- design. The frontend pushes the focused tab to `PUT /api/modules/designer/active-design`
- (in-memory).
+OpenPCB exposes its tools over **MCP** so external agents — above all **Claude Code**, on the user's
+own Claude subscription — can drive the designs in the running app. It lives **inside the assistant
+module**, which owns the registry, `ContextResolver`, proposals and the write policy. Long-form
+review, architecture and user guide: `docs/assistant/mcp-claude-code.md`.
+
+- **Endpoint:** Streamable HTTP at `/api/modules/assistant/mcp`, built on
+ `@modelcontextprotocol/server` v2's `createMcpHandler`. It is **stateless per request** for
+ 2025-era clients (fresh server per POST, no `Mcp-Session-Id`, GET/DELETE → 405), so nothing about a
+ client survives inside the SDK. Code under `src/modules/assistant/backend/mcp/`.
+- **Identity comes from headers**, handed to the server factory as `authInfo`:
+ `X-OpenPCB-MCP-Client` (client key — **header-stable, never the display name**; falls back to
+ User-Agent), `X-OpenPCB-MCP-Client-Name` (display), `X-OpenPCB-MCP-Instance` (the **session**: the
+ bridge sends a hash of Claude Code's `CLAUDE_CODE_SESSION_ID`, stable across `/mcp` reconnects;
+ `OPENPCB_MCP_INSTANCE` overrides; else random per process). **The actor is `clientKey +
+ instanceId`** — two Claude Code sessions of one client are two actors. `McpConnectionRegistry`
+ holds pins and last-used designs per actor, evicted after 30 min idle.
+- **Ownership is the actor, never the chat.** Proposals persist `actor_client_key` /
+ `actor_instance_id` (migration 0015); `assistant_{get,list_pending,await}_proposal` and
+ `designer_undo`/`redo` (`commandIdsAppliedByActor`) compare them. Chats are presentation:
+ **one unbound home chat per session plus one chat per session per design**, bound once and never
+ rebound (metadata `{scope:"designer", designId, mcp:{clientKey, instanceId, role}}` — the shape
+ `listChatsForDesign` filters on). Because chats are per session, chat-keyed session allowances
+ cannot cross sessions. A tool that binds the home chat (create/resolve design) turns it into that
+ design's chat; writes and binding tools are serialized per session (`serialize`), and every
+ auto-bind path uses `ContextResolver.bindDesignIfUnbound` (synchronous check+insert). Every call is
+ recorded (`call-recorder.ts`) as a tool event on a visible activity message — that is what renders
+ proposal cards. **Never** write MCP calls through the run-service path: its `role:"tool"` replay
+ messages would corrupt in-app history.
+- **Idempotency** (migration 0016): `UNIQUE(design_id, idempotency_scope, action_id)` where the scope
+ is `mcp::` or, in-app, `chat:`; the lookup uses the same key. When the
+ store returns an existing proposal, callers must **not** apply (`finalizeAndMaybeApply` / the
+ placement tool check the returned id). A rejected or failed `action_id` is blocked on re-send.
+- **Staleness:** approving a proposal whose `baseRevision` is no longer the head throws
+ `ProposalStaleError` (`STALE_PROPOSAL`), persisted as the failed apply result. Design deletion
+ checks it too. There is no apply-anyway for stale proposals.
+- **Results** (`result-envelope.ts`): `structuredContent` = `{ok, status, summary, warnings, error,
+ proposal, data}` and the text block repeats it, data capped at 24k chars (cut on a code point).
+ Clients disagree on which half reaches the model (Claude Code: structuredContent only; Claude
+ Desktop: content only) — keep readable text in both. The recorder stores full results only where
+ `MessageCard` renders them (library search, BOM, placement) or when ≤ 8k chars; proposal results as
+ `{id, kind, designId, baseRevision}`; the rest as a digest. Big reads page
+ (`designer_get_pcb_layout` offset/limit).
+- **Tools:** the 15 in-app `AiTool`s projected 1:1, plus **MCP-only** tools — extended reads
+ (`tools/read-tools.ts`), Docs pages (`tools/knowledge-tools.ts`, via the core `MentionRegistry`),
+ PCB/board/rules (`tools/mcp-pcb-tools.ts`: one risk per tool — add/update and delete are separate
+ tools), design management + history (`tools/mcp-design-tools.ts`), and connection-scoped
+ `designer_use_design`, `designer_verify_build`, `assistant_{get,list_pending,await}_proposal`.
+ **Do not add MCP-only tools to the in-app registry** — its prompt and DoD harness are tuned against
+ the current 15. Every write tool needs an entry in `mcp/tool-policy.ts` (a test enforces it;
+ `uiSideEffect` marks read tools that change only what the UI shows); descriptions and
+ `mcp/instructions.ts` must stay ≤ 2,000 chars (Claude Code truncates at 2,048). MCP writes bypass
+ the HTTP route parsers, so the tools validate against the design themselves (layers vs the real
+ stack, sizes > 0, drill < pad, unique net-class ids/names — `tools/rules-validation.ts`).
+- **Writes** go through the proposal system (persisted, carded, `action_id` dedupe). Undoable edits
+ auto-apply; destructive ones and `APPROVAL_REQUIRED_KINDS` (non-undoable rule changes, DRC waivers,
+ rule-class ignores, design deletion) wait for the user's approval in the panel;
+ `NEVER_SESSION_ALLOWED_KINDS` (rule-class ignores, design deletion) are never covered by "allow this
+ tool this session". `assistant_await_proposal` lets the agent wait for the decision.
+ `designer_undo`/`redo` share the UI's `designer-ui-session` and only act on an entry this actor
+ landed (apply results record `commandIds`; history exposes `nextUndo`).
+- **DRC suppression is always reported:** `DrcReport.suppressed` counts violations hidden by ignored
+ rule classes / "ignore" overrides (absent when none); MCP summaries give active, waived, hidden and
+ raw counts (`tools/drc-counts.ts`) and never call a board clean while anything is suppressed.
+- **Live UI:** `GET /api/modules/assistant/events` and `GET /api/modules/designer/events` (SSE, ids
+ only) let the panel and canvas refresh on MCP activity. The designer's undo histories are shared
+ per database across its two store instances (routes + SDK).
+- **Design targeting:** explicit `designId` → session pin → UI-active design (pushed by the frontend
+ to `PUT /api/modules/designer/active-design`, cleared when the Designer screen unmounts) →
+ the connection's last design (with a warning).
- **Two settings, both default off** (`assistant_settings.mcp_enabled` / `mcp_allow_writes`). Writes
are forced off whenever the server is off; when writes are off, write tools are **not registered
- at all**. Otherwise the in-app policy applies — non-destructive edits auto-apply, deletions pend
- for approval.
-- **Security:** bearer token from `OPENPCB_MCP_TOKEN` (generated per launch by Electron main) plus a
- loopback-only Origin check. Discovery via `/mcp.json` at mode 0600; the backend port
- is ephemeral, so there is nothing to hardcode.
-- **stdio clients** use the bundled shim (`electron/src/mcp-shim/`), launched by
- `build/mcp/openpcb-mcp{,.cmd}` via `ELECTRON_RUN_AS_NODE` on the app's own Electron binary — no
- system Node needed. It ships through `extraResources` because nothing inside `app.asar` is
- spawnable. The app must be running; there is no headless fallback (one SQLite writer).
+ at all**. Toggling notifies 2026-era clients (`handler.notify`); the bridge covers 2025-era ones.
+- **Security:** bearer token from `OPENPCB_MCP_TOKEN` (generated per launch by Electron main,
+ compared by SHA-256 digest) plus a loopback-only Origin check. Discovery via
+ `/mcp.json` at mode 0600, written atomically, never over a live instance's file.
+ `/mcp-state` (bearer) is the bridge's cheap state probe: a SHA-256 of the full tool contracts plus a
+ per-boot `generation`.
+- **stdio bridge** (`electron/src/mcp-shim/`, Electron-free modules): answers `initialize`/`ping`
+ itself (works while the app is closed), re-reads the portfile on failure (survives restarts),
+ synthesizes `list_changed` (state fingerprint or endpoint epoch changed), frames SSE with
+ `eventsource-parser`, aborts on `notifications/cancelled`, and answers `server/discover` with
+ method-not-found so 2026-era clients fall back to the 2025 handshake it speaks. Electron main
+ installs a **stable launcher** at `/mcp/openpcb-mcp{,.cmd}` on every launch
+ (`ELECTRON_RUN_AS_NODE` on the current binary; AppImage / portable / translocation aware). macOS /
+ Linux clients point at the launcher; **Windows clients point at the app exe + the copied shim with
+ `ELECTRON_RUN_AS_NODE` in their env** (no cmd.exe in the transport). Main also generates a **local
+ Claude Code plugin marketplace** at `/claude-code/marketplace` (MCP server + skills
+ from `electron/resources/claude-plugin/`). Settings → Assistant → MCP can install it via the user's
+ `claude` CLI; `/claude-code/registration.json` records exactly what was registered,
+ and only registrations matching it (or what the app would register now) are ever updated or
+ removed. The app must be running for tool calls; there is no headless fallback (one SQLite
+ writer).
## Commands — agent-relevant deltas
@@ -414,7 +475,7 @@ any id below.** The exact ids were not confirmed when this file was written.
| `pcb.lengthTuning` | the length-tuning (Tune) tool |
| `pcb.bundleRouting` | the Bundle tool (toolbar-only surface) |
| `dataset.capture` | designer dataset capture — see `src/modules/designer/AGENTS.md` |
-| `mcp.server` | the MCP endpoint route |
+| `mcp.server` | the MCP endpoint route and the Settings MCP section (graduated to `all`) |
| `cloud.auth` | cloud foundation, wired at the `readCloudConfig().enabled` chokepoint |
| `cloud.sync` | cloud design sync |
| `cloud.designBrowser` | cloud design browser |
diff --git a/DEVELOPER.md b/DEVELOPER.md
index 018877a9..88ad8788 100644
--- a/DEVELOPER.md
+++ b/DEVELOPER.md
@@ -365,6 +365,10 @@ database at `/tmp/openpcb-e2e.sqlite*` through `OPENPCB_DB_PATH`.
| `OPENPCB_ALLOWED_ORIGINS` | localhost:1420, :3000 | Comma-separated CORS allowlist |
| `OPENPCB_DEBUG_DIAGNOSTICS` | `false` | Enables `/api/diagnostics/debug/modules` |
| `OPENPCB_MCP_TOKEN` | generated per launch | Bearer token for the MCP endpoint |
+| `OPENPCB_MCP_PORTFILE` | `/mcp.json` | stdio bridge: read this discovery file instead of the per-OS default (tests, multiple installs) |
+| `OPENPCB_MCP_CLIENT` | client's `clientInfo.name` | stdio bridge: override the client key (half of the MCP actor); must be stable |
+| `OPENPCB_MCP_INSTANCE` | hash of `CLAUDE_CODE_SESSION_ID`, else random | stdio bridge: override the session id (the other half of the actor: chats, proposal ownership, undo rights, idempotency) |
+| `OPENPCB_MCP_POLL_MS` | `3000` | stdio bridge: how often it re-checks the app's MCP state for `list_changed` |
| `OPENPCB_E2E_NO_WEBSERVER` | unset | Set to `1` to stop Playwright starting its own servers |
| `AUTO_LAYOUT_URL` | dev `http://localhost:3002`, packaged `https://autolayout.cloud.openpcb.app` | Cloud Auto Layout / Route Board service base URL. Legacy `AUTO_ROUTER_URL` / `AUTO_PLACE_URL` still honoured (same merged service). Point it at `cloud-infra/devstack` for local work |
| `NODE_ENV` | — | `development` / `test`; any non-prod value turns feature flags on |
diff --git a/ROADMAP.md b/ROADMAP.md
index 6ef93e3f..6b831833 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -91,9 +91,14 @@ renders and exports to Gerber region primitives today. Bounded zones exist only
from KiCad projects — there is no zone drawing tool, and keepout regions are not implemented. The
work is a zone authoring tool plus keepout support, not a fill engine.
-**MCP server.** Exposing OpenPCB's assistant tool registry over the Model Context Protocol so an
-external agent — Claude Code, Claude Desktop, Codex — can drive the design you have open, with
-writes off by default and destructive operations held for approval. In development.
+**MCP server and Claude Code.** OpenPCB's tools over the Model Context Protocol, so an external
+agent — above all Claude Code on the user's own Claude subscription, also Claude Desktop and Codex —
+can do everything the in-app Assistant does, plus PCB placement, routing, board outline, rules,
+zones, keepouts, waivers, design management and undo. Setup is one click in Settings (a local
+Claude Code plugin with workflow skills, or the MCP server alone); the server and writes are both
+off until the user enables them, and deletions and rule changes wait for approval in the app.
+Implemented; awaiting a packaged-build smoke test on each desktop platform before release (see
+`TODO.md` §3).
**Drill slot authoring**, project export and import for backup and portability, and mapping
manufacturer part numbers from KiCad symbol fields on import.
diff --git a/TODO.md b/TODO.md
index e8285f88..ad5c78f8 100644
--- a/TODO.md
+++ b/TODO.md
@@ -222,47 +222,49 @@ Same module, out of the Phases 0–4 scope that landed 2026-06-02. Not blocking.
---
-## 3. MCP integration — in flight
-
-The largest live workstream and the newest. Uncommitted WIP in the working tree as of the
-2026-07-28 verification pass; it is **not** unshipped and it is **not** finished.
-
-OpenPCB exposes its assistant tool registry over MCP so external agents (Claude Code, Claude
-Desktop, Codex) can drive whatever design the user has open. It lives inside the assistant module,
-which already owns the registry, the `ContextResolver`, proposals and the write policy.
-
-**Shape as designed** (full description in `CLAUDE.md` — only the load-bearing constraints repeat here):
-
-- Streamable HTTP at `/api/modules/assistant/mcp`; sessions are backed by a real assistant chat,
- one per client, keyed on `metadata.mcp.clientKey`. That key must be **header-stable and never the
- display name** — it is what lets every existing designer tool resolve its design unchanged via
- `contextResolver.getPrimaryDesign(chatId)`, and why MCP calls and proposals render in the panel.
-- The 15 in-app `AiTool`s projected 1:1, plus MCP-only extended reads and the session-scoped
- `designer_use_design`. **Do not add the extended reads to the in-app registry** — its prompt and
- DoD harness are tuned against the current 15.
-- Design targeting: explicit `designId` → session pin → UI-active design, pushed by the frontend to
- `PUT /api/modules/designer/active-design`.
-- Two settings, both default off (`mcp_enabled`, `mcp_allow_writes`). Writes are forced off when the
- server is off, and write tools are then not registered at all.
-- Bearer token plus loopback-only Origin check; discovery through a 0600 `mcp.json`. stdio clients
- use the bundled shim on the app's own Electron binary. The app must be running — there is no
- headless fallback, because there is one SQLite writer.
-
-**Working-tree surfaces:** `src/modules/assistant/backend/mcp/`, `electron/src/mcp-shim/`,
-`McpSection.tsx`, `0014_mcp_settings.sql`, `assistant-mcp-endpoint.test.ts`,
-`designer/backend/active-design.ts`, `useActiveDesignSync.ts`.
-
-- [ ] **Finish the WIP and commit it.** It is currently the only unversioned work in the repo.
-- [ ] **Test it.** `assistant-mcp-endpoint.test.ts` exists; establish what it covers and fill the
- gaps — auth rejection paths, session/client-key stability, tool projection fidelity, the
- write-policy matrix (server off, server on + writes off, server on + writes on), and the
- active-design targeting precedence chain.
-- [ ] **Exercise the stdio shim end-to-end** from a real external client against a running app,
- including the `mcp.json` discovery handoff.
-- [ ] **Decide `mcp.server` flag graduation.** It is a `dev` flag today. Graduating it means
- flipping the registry entry to `"all"`, and carries the same release-notes obligation as the
- route-tool flags.
-- [ ] Document the feature for users once the two settings are considered stable.
+## 3. MCP integration — Claude Code parity, pending desktop verification
+
+Implemented on `claude/focused-cori-s0xh0c` (draft PR to `master`): server correctness, resilient
+stdio bridge, approval round-trip, live UI sync, parity tools (build verifier, Docs pages), PCB /
+board / rules / design-management tools, stable launcher, one-click Claude Code connect, local
+plugin, and `mcp.server` graduated to `"all"` (both user settings still default off). Review round 1
+is addressed on the same branch (session isolation, stale approvals, DRC suppression, idempotency,
+validation, Windows transport, setup ownership — `docs/assistant/mcp-claude-code.md` §5). Contract:
+`CLAUDE.md` → *MCP server*; review, tool surface and user guide: `docs/assistant/mcp-claude-code.md`.
+Automated coverage: the `assistant-mcp-*`, `mcp-shim-bridge`, `mcp-claude-code-setup` and
+`designer-live-events` Bun suites; Vitest for `McpSection`, the live-event controller and
+`useDesignerEvents`; plus a real Claude Code CLI run (`claude mcp list` connected, `plugin validate`,
+marketplace add → install → update) in a Linux container.
+
+Still open — none of this could be exercised without a desktop:
+
+- [ ] **Desktop smoke matrix on packaged builds** — the release gate for this PR. macOS (from
+ Applications, after moving the app, *and* from the DMG, to see the translocation warning),
+ Windows installer, Windows portable, Linux AppImage, `.deb`:
+ Connect Claude Code → `claude mcp list` shows OpenPCB connected → build a circuit → place and
+ route → delete something and approve it in the panel → the canvas updates live → quit and
+ restart OpenPCB mid-session and confirm the session recovers → update the app and confirm
+ "Update plugin" + `/reload-plugins` → Disconnect.
+- [ ] **Windows specifics:** native `claude.exe` and npm `claude.cmd`; a user profile path with
+ spaces, `&`, `%`, `^`, parentheses and non-ASCII characters; the portable build (the exe path
+ is baked into the registration — confirm "Update connection" appears after moving it).
+- [ ] **Two concurrent Claude Code sessions** on one machine against one design: separate chats,
+ neither can await or undo the other's work, an "allow for session" in one does not affect
+ the other.
+- [ ] **CI:** `corelib:fetch` fails on `master` and this PR (the released `openpcb-core.pub` does not
+ match `resources/keys/openpcb-core-2026.pub`), so CI runs no tests. Fix the trust key / release
+ separately, then re-run this PR.
+- [ ] **Confirm `ELECTRON_RUN_AS_NODE` passes through the AppImage runtime and the portable
+ wrapper.** The launcher falls back to a system `node` if it does not; if it fails, point the
+ launcher at the extracted binary instead.
+- [ ] **Pin the `RunAsNode` fuse on.** No fuse configuration exists today, so it is on by default;
+ if fuses are ever hardened, the MCP launcher must be redesigned in the same change.
+- [ ] **Release notes at the flag flip.** Draft in `.github/release-notes/next-mcp-claude-code.md`;
+ fold it into the next version's notes at tag time.
+- [ ] **Net-class assignments are keyed by ephemeral net id** (`pcb_set_design_rules`, and the
+ in-app editor alike). A rename or re-extraction can orphan an assignment; decide whether the
+ board-settings blob should key by net name.
+- [ ] Later, only on demand: export to disk over MCP, library authoring tools, cloud features.
---
diff --git a/docs/assistant/architecture.md b/docs/assistant/architecture.md
index 0d4c4e7b..97ca4a8d 100644
--- a/docs/assistant/architecture.md
+++ b/docs/assistant/architecture.md
@@ -118,29 +118,44 @@ The reason recorded at the time was defensive: "v1 decisions becoming permanent"
a known risk, and the mitigation was to keep v1 constraints as adapter rules, never schema
rules.
-That bet has now paid. The MCP integration currently in flight retargets the design **per
-session**: an explicit `designId` wins, otherwise a session pin set by `designer_use_design`,
-otherwise the UI-active design pushed from the focused designer tab. Every existing designer
-tool works unchanged under MCP because it resolves its design through the context resolver
-rather than through a hardcoded one-design assumption. That is exactly the flexibility the
+That bet has now paid. The MCP integration retargets the design **per connection**: an
+explicit `designId` wins, otherwise the connection's pin set by `designer_use_design`, otherwise
+the UI-active design pushed from the focused designer tab, otherwise the connection's last design
+(with a warning). Every existing designer tool works unchanged under MCP because it resolves its
+design through the context resolver rather than through a hardcoded one-design assumption. That is exactly the flexibility the
generic binding bought, and it was bought years before there was a caller who needed it.
**Rule going forward:** product-level scoping rules (one design per chat, one chat per panel,
one active design per window) are adapter and UI concerns. They do not go into the schema.
-### 3.2 MCP sessions are real chats
-
-The MCP server sits inside the assistant module rather than beside it, and each MCP client
-gets a real assistant chat, matched on a header-stable client key. This is a direct consequence
-of §3.1: because context binding is generic and design resolution goes through the resolver,
-projecting the in-app tool registry over MCP required no changes to the tools themselves. It
-also means MCP tool calls and pending proposals surface in the assistant panel like any other
-run, so there is one audit trail rather than two.
-
-The MCP work is in flight and gated behind the dev-only `mcp.server` feature flag. Its
-operational detail — endpoint, auth, session keying, tool projection, discovery file, stdio
-shim — lives in `CLAUDE.md`; this document records only why the architecture accommodated it
-without a rewrite.
+### 3.2 MCP connections are real chats
+
+The MCP server sits inside the assistant module rather than beside it. Each MCP client (keyed on
+a header-stable client key, never the display name) gets real assistant chats: one **home** chat
+that is never bound to a design, and one chat **per design**, bound once when it is created and
+never rebound. A tool that binds the home chat (`designer_create_design`,
+`designer_resolve_design`) turns it into that design's chat and a fresh home chat is created on
+the next call. This is a direct consequence of §3.1: because context binding is generic and
+design resolution goes through the resolver, projecting the in-app tool registry over MCP
+required no changes to the tools themselves.
+
+Two further decisions follow from "one audit trail, not two":
+
+- **Every MCP call is recorded** as a visible assistant activity message plus a tool event on
+ the chat it ran in. Proposal cards render from tool events, so a pending deletion Claude Code
+ proposed shows an approval card in the panel exactly as an in-app one does. The recorder is
+ MCP-specific on purpose: the run service's `role: "tool"` replay messages would corrupt the
+ in-app model's history.
+- **Approval stays in the app.** Destructive writes, rule and net-class changes, and design
+ deletion wait for the user in the OpenPCB panel. The external agent can only observe the
+ decision (`assistant_await_proposal`), never make it.
+
+The transport is stateless per request (the MCP SDK serves 2025-era clients without an
+`Mcp-Session-Id`), so connection state lives in `McpConnectionRegistry`, keyed on
+client + per-process instance id, not in the transport. Operational detail — endpoint, auth,
+identity headers, tool inventory, approval tiers, the stdio bridge, launcher and plugin — lives
+in `CLAUDE.md` (MCP section); the review that produced this shape and the user guide are in
+`docs/assistant/mcp-claude-code.md`.
---
@@ -428,8 +443,9 @@ documented at the lowering step in the compiler, which is where the geometry is
- Phase plans, wave/track partitions, file-ownership tables and per-task checklists from the
two superseded specs. They described how the work was scheduled, not how the system behaves.
They are in git history.
-- MCP operational detail (endpoint, auth, session keying, discovery, stdio shim) — see
- `CLAUDE.md`.
+- MCP operational detail (endpoint, auth, connection keying, discovery, stdio bridge,
+ launcher, plugin) — see `CLAUDE.md`; review, tool surface and user guide — see
+ `docs/assistant/mcp-claude-code.md`.
- Chat and proposal presentation rules — see `docs/assistant/chat-ui-spec.md`.
- Designer data-model facts the assistant depends on (net-ID ephemerality, placement identity,
board-settings blob) — see `src/modules/designer/AGENTS.md`.
diff --git a/docs/assistant/mcp-claude-code.md b/docs/assistant/mcp-claude-code.md
new file mode 100644
index 00000000..d6a05b8b
--- /dev/null
+++ b/docs/assistant/mcp-claude-code.md
@@ -0,0 +1,328 @@
+# OpenPCB × Claude Code (MCP) — review, architecture and user guide
+
+> Goal: someone who installs OpenPCB can use **Claude Code** — and therefore the Claude
+> subscription they already pay for — as the agent for anything the in-app Assistant can do,
+> and for PCB work the in-app Assistant cannot do yet.
+>
+> This file records the review that started the hardening program (§1), the architecture it
+> produced (§2), the tool surface (§3), and the user guide (§4). Operational rules that code
+> must respect stay in `CLAUDE.md` (MCP section); this is the long-form reasoning and the
+> user-facing documentation.
+
+---
+
+## 1. Review (September 2026, before hardening)
+
+The review was done against `master` at `63e90ea`. Every finding below was verified in code.
+
+### 1.1 Blockers — Claude Code could not be used by an installed-app user
+
+| # | Finding | Evidence |
+|---|---|---|
+| B1 | `mcp.server` was `availability: "dev"`. Packaged builds run with `NODE_ENV=production`, so `/api/modules/assistant/mcp` was never registered — while Settings still showed the toggles and copy-paste commands, and Electron still wrote the portfile. Users got a 404 behind a working-looking UI. | `core/contracts/feature-flags/registry.ts`, `assistant/backend/routes.ts`, `AssistantPanel.tsx` |
+| B2 | Tool results lost information. `content` carried only the one-line `summary`; `structuredContent` carried only `modelData`, so warnings and error messages were dropped and a failing read returned the text `"null"`. Claude Code (≥ 2.1.27x) forwards **only** `structuredContent` to the model when both are present; Claude Desktop reads only `content`. | `mcp/tool-projection.ts` |
+| B3 | MCP tool calls were never persisted as chat messages or tool events. The panel renders proposal cards only from tool events, so a deletion Claude Code proposed had **no approval card anywhere**, and the proposal id was never returned to the client. | `mcp/tool-projection.ts`, `run-service.ts`, `MessageCard.tsx` |
+| B4 | There was no backend→frontend design change stream. The designer only refreshes after in-app assistant runs, so the canvas and history went stale while Claude Code edited. | `designer/frontend/Space.tsx` (`handleAssistantDesignChanged`) |
+| B5 | `designer_create_design` refuses once the chat is bound, and the projection re-bound the one MCP chat on every design-targeted call — so Claude Code could not create a design after its first design call. | `designer-tools.ts`, `mcp/session.ts` |
+| B6 | Setup was fragile: the advertised shim path lives inside the app bundle (moves for AppImage, the portable Windows build and translocated macOS apps); Windows `.cmd` files cannot be spawned without `cmd /c`; the snippet used `local` scope (one project only); the HTTP snippet embedded a per-launch token and port; the shim read the portfile once, exited if the app was down, went dead after an app restart, and reported upstream failures only on stderr so the client hung. | `electron/src/main/diagnostics-ipc.ts`, `McpSection.tsx`, `electron/src/mcp-shim/index.ts` |
+
+### 1.2 Hardening findings
+
+- **H1** Every shim client sent the same client key, so Claude Code, Claude Desktop and every
+ concurrent Claude Code session shared one chat, one pinned design and one run counter.
+- **H2** Tool annotations: the destructive set was hardcoded; `idempotentHint` was claimed for
+ every write although `action_id` is optional; `designer_use_design` claimed read-only while
+ re-binding the chat.
+- **H3** No progress notifications and no cancellation; Claude Code aborts idle HTTP tool calls
+ after 5 minutes.
+- **H4** `designer_export_manufacturing` pointed the model at a resource that does not exist.
+- **H5** The per-mode registry cache was never invalidated; `McpEndpoint.close()` was never
+ called; the session map was never evicted.
+- **H6** MCP chat creation required an in-app LLM provider row — irrelevant to an external agent.
+- **H7** The portfile was not written atomically and could overwrite a live instance's file;
+ the token comparison leaked length.
+- **H8** No `list_changed`: toggling "allow writes" never reached connected clients.
+
+### 1.3 Parity gaps with the in-app Assistant
+
+- **P1** The Definition-of-Done verifier (`verification/run-dod.ts`) and BuildIntent capture
+ run only inside the in-app loop.
+- **P2** The grounding rules reached MCP clients only through optional prompts — nothing in the
+ server `instructions`.
+- **P3** Knowledge pages are reachable in-app through @mentions, not over MCP.
+- **P4** Proposal approve/reject had no MCP counterpart.
+- **P5** Cancellation.
+
+The PCB side had **no** write tools at all (none of the ~40 `pcb_*` commands was projected), so
+"any action" was schematic-only.
+
+### 1.4 External facts that shaped the design
+
+- `@modelcontextprotocol/server` 2.0.0 implements the 2026-07-28 spec. `createMcpHandler`
+ serves 2025-era clients **statelessly** (a fresh server per POST, no `Mcp-Session-Id`, GET and
+ DELETE answer 405). Per-connection state therefore cannot come from the transport.
+- Claude Code truncates server `instructions` and each tool description at 2,048 characters,
+ caps MCP output at 25k tokens by default (per-tool `_meta["anthropic/maxResultSizeChars"]`
+ raises it), and supports `list_changed`.
+- `claude mcp add` defaults to `local` scope (one project); `--scope user` applies everywhere.
+- Claude Code copies installed plugins into its own cache; a local-directory marketplace must be
+ refreshed with `claude plugin marketplace update` + `claude plugin update`.
+
+---
+
+## 2. Architecture
+
+```
+Claude Code ──stdio──► openpcb-mcp launcher (/mcp/, rewritten on every app launch)
+ └─ resilient stdio proxy (shim)
+ · answers initialize/ping itself — works while OpenPCB is closed
+ · re-reads mcp.json on every failure → survives app restarts
+ · synthesizes notifications/*/list_changed
+ · x-openpcb-mcp-instance header per process
+ │ Streamable HTTP POST + per-launch bearer token
+ ▼
+OpenPCB backend /api/modules/assistant/mcp (stateless per request)
+ ├─ McpConnectionRegistry
+ │ client → one "home" chat (never bound) + one chat per design (bound once, never rebound)
+ │ instance→ pinned design, last design, lastSeen (idle eviction)
+ ├─ tool projection → result envelope · call recorder (message + tool event) · signal/progress
+ ├─ registries: in-app 15 (unchanged) + MCP-only reads + parity tools + expansion writes
+ └─ events: designer revision bus ─SSE─► canvas refresh
+ assistant chat bus ─SSE─► panel refresh (approval cards appear live)
+```
+
+The operational contract (endpoint, auth, identity headers, chat model, tool registration rules)
+is in `CLAUDE.md` → *MCP server*.
+
+---
+
+## 3. Tool surface
+
+48 tools with writes enabled, 23 with writes disabled (write tools are not registered at all when
+"Allow writes" is off; `designer_focus_design` changes only what the window shows, so it stays). The authoritative list is `tools/list` against a running app; the
+`assistant-mcp-parity.test.ts` and `mcp-claude-code-setup.test.ts` suites pin it down (every tool
+a plugin skill names must exist).
+
+Conventions shared by every tool:
+
+- **Targeting.** Tools that act on a design accept an optional `designId`. Without one, the
+ design is the connection's pin (`designer_use_design`), else the design focused in the OpenPCB
+ window, else the connection's last design (the result carries a warning saying so).
+- **Units and addressing.** Millimetres at the tool boundary, stored as integer nanometres.
+ Parts by reference designator (`U1`), pins and pads as `REF.PIN` / `REF.PAD`, nets by name —
+ net ids are ephemeral and never cross the MCP boundary.
+- **Results.** Every result has `{ok, status, summary, warnings, error, proposal, data}` in
+ `structuredContent` and the same information as text, because Claude Code forwards only the
+ structured half and Claude Desktop only the text half.
+- **Idempotency.** Write tools take an optional `action_id`, unique per Claude Code session and
+ design. A repeat returns the original proposal instead of applying twice (also under concurrent
+ calls — the database enforces it); after a rejection or failure the id is spent and a new one is
+ required.
+- **Ownership.** Proposals, undo rights and idempotency belong to the session that made them
+ (client + session id), not to a chat: another Claude Code session cannot await, see or undo them.
+
+### 3.1 The in-app Assistant's 15 tools (parity, projected 1:1)
+
+| Tool | Kind |
+|---|---|
+| `library_search_components`, `library_get_component_detail`, `library_resolve_bom` | read |
+| `designer_resolve_design`, `designer_get_design_summary`, `designer_get_part_detail`, `designer_get_schematic_connectivity` | read |
+| `designer_create_design` | write |
+| `compile_circuit`, `designer_place_components` | write (auto-applies, undoable) |
+| `designer_propose_schematic_edits`, `designer_propose_schematic_wires`, `designer_propose_schematic_updates`, `designer_arrange_schematic` | write (auto-applies, undoable) |
+| `designer_propose_schematic_deletions` | write — **waits for approval** |
+
+### 3.2 MCP-only reads and parity tools
+
+| Tool | Purpose |
+|---|---|
+| `designer_list_designs`, `designer_use_design` | discover designs; pin this session to one |
+| `designer_get_pcb_state`, `designer_get_pcb_layout` | PCB summary; full geometry for placement and routing |
+| `designer_run_erc`, `designer_run_drc`, `designer_get_bom` | checks and bill of materials |
+| `designer_export_manufacturing` | manufacturing bundle manifest (nothing is written to disk) |
+| `designer_get_history` | shared undo/redo state |
+| `designer_verify_build` | the in-app Definition-of-Done verifier, run against the intent captured from the last `library_resolve_bom` / `compile_circuit` |
+| `knowledge_search_pages`, `knowledge_get_page` | the user's OpenPCB Docs pages (same content an @mention gives the in-app assistant) |
+| `assistant_get_proposal`, `assistant_list_pending_proposals`, `assistant_await_proposal` | follow a proposal waiting in the panel; `await` long-polls up to 240 s and returns `pending` on timeout |
+
+### 3.3 MCP-only writes (PCB, board, rules, design management)
+
+| Tool | Approval tier |
+|---|---|
+| `pcb_place_footprints` — move / rotate / flip by reference | auto-applies, undoable |
+| `pcb_route` — traces through waypoints + vias, per net, one atomic commit, refused if it breaks legality | auto-applies, undoable |
+| `pcb_set_board_outline` — rect, roundrect (radius required), circle (diameter), oval, simple polygon | auto-applies, undoable |
+| `pcb_add_zone`, `pcb_update_zone`, `pcb_add_keepout`, `pcb_update_keepout` | auto-applies, undoable |
+| `pcb_delete_zone`, `pcb_delete_keepout`, `pcb_delete_routing` | **waits for approval** |
+| `pcb_set_design_rules` — clearances, net classes, net → class | **waits for approval** (not undoable) |
+| `pcb_waive_drc_violations` — waive (with a reason) / un-waive by violation id | waiving **waits for approval**; un-waiving applies |
+| `pcb_set_drc_rule_class_ignores` — hide / restore whole rule classes | ignoring **waits for approval, every time** (never on a session allowance); restoring applies |
+| `designer_rename_design` | applies immediately |
+| `designer_focus_design` | UI only — available with writes off |
+| `designer_delete_design` | **waits for approval, every time** (irreversible; refused if the design changed since) |
+| `designer_undo`, `designer_redo` | applies; refuses unless the entry on top of the stack was made by this session, and — when `expectedRevision` is given — unless the design is still at that revision |
+
+Every applied PCB write reports the DRC counts afterwards — active, waived, hidden by ignored
+classes, and raw. Inputs are validated against the design (layers on the real stack, sizes > 0,
+drill < pad, unique net-class ids and names); nothing physical is defaulted.
+
+### 3.4 Also exposed
+
+- **Server instructions** (≤ 2,000 chars): targeting, write rules, approvals, "verify after
+ every build".
+- **Prompts:** `openpcb-review-schematic`, `openpcb-drc-triage`, `openpcb-bom-check`, and — only
+ with writes on — `openpcb-build-circuit` (for clients without the plugin's skills).
+- **Resources:** `openpcb://design/{designId}/{schematic|pcb|bom|erc|drc}` and
+ `openpcb://knowledge/{pageId}` Docs pages.
+
+### 3.5 Deliberately not exposed
+
+Writing export files to disk, library authoring (component wizard, symbol/footprint editors,
+KiCad import), cloud features (sync, auto-layout, cloud library) and app settings. Approving or
+rejecting a proposal is never an MCP operation.
+
+---
+
+## 4. User guide
+
+### 4.1 Requirements
+
+- OpenPCB desktop, running. The agent talks to the open app; tool calls fail with
+ "OpenPCB is not running" while it is closed (the connection itself stays up and recovers when
+ the app starts).
+- Claude Code () signed in with your Claude subscription. Claude Desktop
+ and other MCP clients work through the same server; only the one-click setup is Claude
+ Code-specific.
+
+### 4.2 Connect (one click)
+
+1. OpenPCB → **Settings → Assistant → MCP server · Claude Code**.
+2. Tick **Enable MCP server**. Tick **Allow writes from MCP clients** too if the agent should
+ edit designs; leave it off for read-only use (inspect, ERC/DRC, BOM, Docs).
+3. Click **Connect Claude Code**. OpenPCB finds your `claude` CLI and installs the **OpenPCB
+ plugin** for every project: the MCP server plus workflow skills. **MCP server only**
+ registers just the tools. **Details** under the result shows every `claude` command that ran.
+4. In open Claude Code sessions run `/reload-plugins` (server only: `/mcp` → reconnect), or start a
+ new session. `claude mcp list` should show OpenPCB as
+ connected.
+
+The plugin's skills (invoke with `/openpcb:` or let Claude pick them):
+
+| Skill | Use it to |
+|---|---|
+| `openpcb-build-circuit` | design a circuit from a description — resolve parts, place, wire, verify |
+| `openpcb-review-schematic` | review connectivity and ERC without changing anything |
+| `openpcb-drc-triage` | triage DRC violations by root cause, most severe first |
+| `openpcb-bom-check` | find BOM rows that would block ordering or assembly |
+| `openpcb-pcb-layout` | outline → placement → routing → pours → DRC |
+| `openpcb-connection-help` | troubleshoot a missing or failing connection |
+
+After an OpenPCB update — or when OpenPCB moved — the panel shows **Update plugin** (or **Update
+connection** for a server-only setup); click it, then run `/reload-plugins` (or `/mcp` → reconnect)
+in open Claude Code sessions. Claude Code keeps its own copy of installed plugins. OpenPCB records
+exactly what it registered (`/claude-code/registration.json`) and only ever updates or
+removes that — never another server that happens to be called `openpcb`.
+
+### 4.3 Connect by hand
+
+**Set up by hand** in the same panel shows the exact commands for your machine. They point at a
+launcher OpenPCB rewrites on every start, so they stay valid when the app moves or updates:
+
+```sh
+# plugin (recommended)
+claude plugin marketplace add "/claude-code/marketplace"
+claude plugin install openpcb@openpcb-desktop --scope user
+
+# or the MCP server only — macOS / Linux (terminal)
+claude mcp add --scope user openpcb -- "/mcp/openpcb-mcp"
+# Windows (PowerShell): the app itself runs the bridge; no cmd.exe involved
+claude mcp add --scope user openpcb -e ELECTRON_RUN_AS_NODE=1 -- '\OpenPCB.exe' '\mcp\shim.js'
+```
+
+`--scope user` matters: without it Claude Code registers the server for the current directory
+only. Claude Desktop gets a JSON block for `claude_desktop_config.json`. **Advanced: direct HTTP
+endpoint** exists for clients that cannot spawn a process; its port and token change on every
+launch, so prefer the launcher.
+
+### 4.4 Working with it
+
+- **Which design?** Claude works on the design focused in OpenPCB unless you name one, or it pins
+ one with `designer_use_design`. Each Claude Code session keeps its own pin.
+- **Seeing the work.** The canvas refreshes as Claude edits. Each Claude Code session gets its own
+ "MCP · Claude Code · · " chat in the design's assistant dock that logs
+ every call.
+- **Approvals.** Deletions, design-rule and net-class changes, DRC waivers and rule-class ignores,
+ and deleting a design stop at an approval card in the assistant panel. Claude waits
+ (`assistant_await_proposal`) and continues after you approve or reject. "Allow this tool this
+ session" on a card applies to that Claude Code session only, and is not offered for deleting a
+ design or ignoring a rule class. A card approved after the design changed is refused ("the design
+ changed after this was proposed"): ask Claude to propose again.
+- **Undo.** Claude's edits land in the same undo history as yours: Ctrl+Z in OpenPCB undoes them.
+ A Claude Code session can undo only its own most recent change — never yours, and never another
+ session's.
+- **Not supported over MCP:** see §3.5.
+
+### 4.5 Troubleshooting
+
+| Symptom | Cause and fix |
+|---|---|
+| Tools say "OpenPCB is not running" | Start OpenPCB; the connection recovers by itself. |
+| "The MCP server is disabled" | Settings → Assistant → **Enable MCP server**. |
+| Write tools missing | **Allow writes from MCP clients** is off. Clients are told the tool list changed; restart the session if yours does not refresh. |
+| "Connect" says Claude Code was not found | Install Claude Code, or run the **Set up by hand** commands in a terminal where `claude` works. |
+| A macOS warning about a temporary location | OpenPCB is running from the disk image or Downloads. Move it to Applications and reopen it. |
+| Tools listed twice | Both the plugin and a plain `openpcb` server are registered. **Connect Claude Code** removes the plain one if OpenPCB registered it; otherwise run `claude mcp remove openpcb --scope user`. |
+| Stale skills or tools after an update | Click **Update plugin**, then `/reload-plugins`. |
+| "not made by this session" on undo / "No proposal … from this session" | That change or proposal belongs to another Claude Code session; only it (or you, in OpenPCB) can act on it. |
+| Connect says a server "OpenPCB did not register" exists | Someone else's `openpcb` server; OpenPCB will not touch it. Remove or rename it yourself. |
+
+Diagnostics: `claude mcp get openpcb`, the **Connected clients** list in Settings, and Claude
+Code's debug output (`claude --debug`) for MCP connection errors.
+
+### 4.6 Security
+
+The server listens on loopback only, requires a per-launch bearer token that only processes
+running as you can read (`/mcp.json`, mode 0600), and is off until you enable it.
+Treat enabling writes like giving a local program edit access to your designs: anything that can
+run as your user can use it. Destructive changes still need your approval in the app.
+
+### 4.7 Platform notes and known limits
+
+- The launcher runs OpenPCB's own binary as Node (`ELECTRON_RUN_AS_NODE`), so no system Node is
+ needed. That relies on Electron's `RunAsNode` fuse staying enabled; if it is ever turned off,
+ the launcher falls back to a system `node`.
+- AppImage and the portable Windows build are handled by pointing the launcher (Windows: the
+ registration) at `$APPIMAGE` / `%PORTABLE_EXECUTABLE_FILE%`. Whether those wrappers pass
+ `ELECTRON_RUN_AS_NODE` through was not verified on real builds at the time of writing; on Linux the
+ launcher's `node` fallback covers a failure, on Windows use the installer build.
+- A Claude Code session is identified by Claude Code's own session id, so it keeps its proposals and
+ undo rights across `/mcp` reconnects. Other clients get a new identity per bridge process.
+- Very long operations are kept alive with progress heartbeats every 10 s; Claude Code's own
+ per-call limits still apply.
+
+---
+
+## 5. Review round 1 (PR #7) — findings and fixes
+
+An external review of the draft PR kept the architecture and asked for hardening. Every finding was
+verified in code; two more surfaced while fixing them (marked ✱).
+
+| # | Finding | Fix |
+|---|---|---|
+| 1 | Ownership collapsed to the client key: two Claude Code sessions could undo each other's changes, see and await each other's proposals, share session allowances and idempotency. | Actor = client key + session id, persisted on proposals (migration 0015); every ownership check compares it. Chats per session, so chat-keyed allowances cannot cross. |
+| 2 | Approving an old "delete design" card ignored its revision and deleted newer work. | Revision check before delete; typed `STALE_PROPOSAL` for every stale apply, persisted and shown on the card and to the agent. No apply-anyway. |
+| 3 | A shared home chat could be bound twice by concurrent sessions. | Home chat per session; writes and binding tools serialized per session; `bindDesignIfUnbound` for every auto-bind. |
+| 4 | DRC waivers auto-applied and could ignore whole rule classes, making a board look clean. | Split tools; waiving needs approval and a reason; ignoring a class needs approval every time; `DrcReport.suppressed` + active / waived / hidden / raw counts in every summary. |
+| 5 | Flag graduated while desktop validation and CI were outstanding. | Kept at `all` by maintainer decision; the PR stays draft until the platform matrix passes (`TODO.md` §3). CI's CoreLibrary key mismatch is tracked separately. |
+| 6 | Windows relied on cmd.exe parsing (`cmd /c` launcher, `claude.cmd` + JSON argument). | Windows clients run the app exe + shim with `ELECTRON_RUN_AS_NODE`; `claude.cmd` resolved to node + cli.js; cross-spawn escaping only as a fallback; `mcp add -e` instead of `add-json`. |
+| 7 | Rule validation accepted zero sizes, drill ≥ pad, colliding net-class ids. | `rules-validation.ts`: positive sizes, drill < pad, unique ids/names, one patch per class, no inherited electrical metadata. |
+| 8 | The bridge fingerprint hashed tool names only, so a schema change after an update went unnoticed. | Hash of full contracts + per-boot generation; re-list on endpoint change too. |
+| 9 | Idempotency depended on the chat, so sessions could collide. ✱ Worse: a key conflict made the write path apply the new envelope anyway and then throw. | Scope-aware unique index (migration 0016) used by both lookup and backstop; a conflict never applies; rejected ids are spent. |
+| 10 | Results duplicated into text, structuredContent and SQLite. | 24k text cap, layout paging, audit digests for large results. |
+| 11 | The hand-written SSE parser mishandled CR/CRLF across chunks. | `eventsource-parser`; seeded fuzz tests over chunk boundaries. |
+| 12 | Setup ownership was a path regex. | Registration record + exact command/args match against real `claude mcp get` output. |
+| 13 | Skills invited assuming 5 V / 1 Hz / 0603 and "finish in one go". | Ask before any electrical or manufacturing-critical assumption; review skill separates ERC facts from heuristic observations. |
+| 14 | Outline defaults invented a 1 mm radius; "circle" accepted unequal sides. | Radius required; `circle` takes a diameter; `oval` explicit; polygons must be simple. |
+| 15 | Layers were not checked against the board's stack. | Checked for routes, zones and keepouts. |
+| 16 | Mixed-risk tools (`pcb_manage_*`). | Separate add / update / delete tools; `designer_focus_design` is a UI side effect. |
+| ✱ | `pcb_delete_routing` dropped unknown ids silently; operation failures surfaced only a code. | Skipped ids reported; the executor's detail is passed to the agent. |
diff --git a/electron/AGENTS.md b/electron/AGENTS.md
index 77b0874c..d3520e46 100644
--- a/electron/AGENTS.md
+++ b/electron/AGENTS.md
@@ -16,15 +16,22 @@ electron/
├── src/main/
│ ├── index.ts # Main entry: boot order, window, app lifecycle
│ ├── backend-server.ts # Starts the backend runtime IN-PROCESS; env setup
-│ ├── mcp-portfile.ts # MCP token + /mcp.json (0600)
+│ ├── mcp-portfile.ts # MCP token + /mcp.json (0600, atomic, never over a live owner)
+│ ├── mcp-launcher{,-content}.ts # stable /mcp/openpcb-mcp{,.cmd} launcher + snippets
+│ ├── claude-plugin{,-content}.ts # local Claude Code plugin marketplace in /claude-code/
+│ ├── claude-code-cli.ts # one-click connect: drives the user's `claude` CLI (execFile, fixed argv)
+│ ├── claude-registration.ts # what OpenPCB registered with Claude Code (exact ownership)
+│ ├── win-cmd.ts # cmd-shim resolution + cmd.exe escaping (fallback only)
│ ├── deep-link.ts # openpcb:// scheme + single-instance lock
-│ ├── diagnostics-ipc.ts # diagnostics:* / app:get-versions / mcp:config
+│ ├── diagnostics-ipc.ts # diagnostics:* / app:get-versions / mcp:config / mcp:claude-code:*
│ ├── secure-storage.ts # safeStorage-backed secure-store.json
│ ├── preferences.ts # preferences.json (telemetry opt-in)
│ ├── updater.ts, logger.ts, crash.ts, sentry.ts
├── src/preload/index.ts # contextBridge: window.electronAPI + window.updater
-├── src/mcp-shim/index.ts # stdio ⇄ Streamable HTTP bridge (own tsup entry)
-├── build/mcp/ # openpcb-mcp launcher scripts (extraResources)
+├── src/mcp-shim/ # stdio ⇄ Streamable HTTP bridge (own tsup entry): index.ts wiring +
+│ # Electron-free bridge.ts / upstream.ts / portfile.ts (Bun-tested)
+├── build/mcp/ # in-bundle openpcb-mcp launchers (extraResources; not what users register)
+├── resources/claude-plugin/ # plugin template: README + skills (extraResources → claude-plugin/)
├── tsup.config.ts # 4 CJS bundles: main, main/drc-worker, mcp/shim, preload (no `clean` — `npm run build` cleans once)
└── electron-builder.cjs # Packaging (NOT Electron Forge)
```
@@ -39,6 +46,7 @@ electron/
| Window config / security | `src/main/index.ts` |
| Packaging, extraResources | `electron-builder.cjs` |
| MCP discovery for clients | `src/main/mcp-portfile.ts`, `src/mcp-shim/` |
+| MCP launcher / Claude Code | `src/main/mcp-launcher*.ts`, `src/main/claude-plugin*.ts`, `src/main/claude-code-cli.ts` |
## CONVENTIONS
@@ -58,6 +66,57 @@ electron/
`app.asar.unpacked/dist/main/…`); `dev:electron` waits for the file. `closeCurrentRuntime`
terminates it — `unref()` is not relied on.
+## MCP LAUNCHER, PLUGIN AND CLAUDE CODE
+
+Operational contract in root `CLAUDE.md` (MCP section); user guide in
+`docs/assistant/mcp-claude-code.md`.
+
+- **Users register a stable entry point, never a bundle path.** On every launch, after the
+ portfile, `backend-server.ts` calls `installMcpLauncher` (copies `resources/mcp/shim.js` to
+ `/mcp/` and rewrites `openpcb-mcp{,.cmd}` + `launcher.json` atomically) and
+ `writeClaudePluginMarketplace` (stages, then swaps `/claude-code/marketplace`).
+ Both are non-fatal. Bundle paths move: an AppImage mounts at a new `/tmp/.mount_*` each run, the
+ portable Windows build extracts to temp, an unsigned macOS app runs translocated from the DMG or
+ Downloads.
+- **Exec resolution** (`resolveLauncherExec`): `$APPIMAGE` → `$PORTABLE_EXECUTABLE_FILE` → the
+ previous good exec when macOS is translocated/`/Volumes/` (and a Settings warning) →
+ `process.execPath`. The launcher runs that binary with `ELECTRON_RUN_AS_NODE=1`, falling back to
+ a system `node`.
+- **`RunAsNode` fuse dependency.** The launcher, and the in-bundle `build/mcp/` scripts, need
+ Electron's `RunAsNode` fuse **enabled** (the default; no fuse config exists today). Flipping it
+ off — a common hardening step — silently degrades MCP to "needs a system Node". Change both
+ together or not at all.
+- **Unverified on real builds:** whether the AppImage runtime and the portable wrapper pass
+ `ELECTRON_RUN_AS_NODE` and the script argument through to Electron. Smoke-test before a release
+ that changes packaging.
+- **Windows keeps cmd.exe out of the MCP transport** (`stdioServerConfig`): clients run the app
+ exe on `\mcp\shim.js` with `ELECTRON_RUN_AS_NODE=1` in their env. (Node cannot
+ spawn a `.cmd` without a shell since CVE-2024-27980, and `cmd /c` would put cmd's quoting rules
+ between every path and the client.) The exe path is baked into the registration, so a moved app
+ is detected against `claude-code/registration.json` and Settings offers "Update". The `.cmd`
+ launcher is still written as a manual fallback (`%` doubled, UTF-8 via `chcp 65001`).
+- **The plugin's `.mcp.json` holds `stdioServerConfig`** — Claude Code copies installed plugins into
+ its cache, so anything version-specific must be refreshed through "Update plugin"
+ (`marketplace update` + `plugin update`). `plugin.json` carries the app version for that check.
+- **`claude-code-cli.ts` security posture:** runs only on an explicit Settings click, `execFile`
+ with a fixed argv (never user text), a timeout on every run. A Windows npm `claude.cmd` is
+ resolved to the `node` + `cli.js` it wraps (`win-cmd.ts` `cmdShimTarget`) and spawned directly;
+ only an unreadable `.cmd` goes through `cmd.exe /d /s /c` with cross-spawn's escaping (ported,
+ tested against cross-spawn). Registration uses `claude mcp add … -e K=V -- cmd args` — no JSON
+ argument. A macOS GUI app does not inherit the shell PATH, hence the login-shell probe and known
+ install paths.
+- **Ownership is exact** (`claude-registration.ts`): `/claude-code/registration.json`
+ records mode, server entry, plugin, marketplace and a stable installation id. A registration is
+ OpenPCB's only when `claude mcp get` shows user scope and the same command + args (+ env) as the
+ record or as what the app would register now; a same-named marketplace must be at our path.
+ Nothing else is ever updated or removed.
+- The `*-content.ts` files, `win-cmd.ts`, `claude-code-cli.ts`, `claude-registration.ts` and
+ `src/mcp-shim/{bridge,upstream,portfile,instance}.ts` must stay free of `electron` imports:
+ `mcp-claude-code-setup.test.ts` and `mcp-shim-bridge.test.ts` run them under Bun.
+- **Bridge instance id** (`mcp-shim/instance.ts`): `OPENPCB_MCP_INSTANCE`, else a hash of Claude
+ Code's `CLAUDE_CODE_SESSION_ID` (Claude Code sets it on stdio MCP servers), else random. It is
+ the session half of the MCP actor — ownership of proposals, undo and idempotency.
+
## ANTI-PATTERNS
- Do NOT reintroduce a spawned backend or a `backend-manager.ts`.
diff --git a/electron/electron-builder.cjs b/electron/electron-builder.cjs
index a4b33fd2..3971a6dc 100644
--- a/electron/electron-builder.cjs
+++ b/electron/electron-builder.cjs
@@ -133,6 +133,13 @@ module.exports = {
to: "mcp",
filter: ["openpcb-mcp", "openpcb-mcp.cmd"],
},
+ // Claude Code plugin template (skills). Electron main turns it into a local
+ // plugin marketplace in the user-data dir on every launch
+ // (src/main/claude-plugin.ts).
+ {
+ from: "resources/claude-plugin",
+ to: "claude-plugin",
+ },
{
from: path.relative(__dirname, path.join(repoRoot, "src")),
to: "src",
diff --git a/electron/resources/claude-plugin/openpcb/README.md b/electron/resources/claude-plugin/openpcb/README.md
new file mode 100644
index 00000000..9c4b76c6
--- /dev/null
+++ b/electron/resources/claude-plugin/openpcb/README.md
@@ -0,0 +1,12 @@
+# OpenPCB plugin for Claude Code (template)
+
+This directory is the template OpenPCB turns into a local Claude Code plugin
+marketplace at `/claude-code/marketplace/` on every launch
+(`electron/src/main/claude-plugin.ts`). The generated copy adds
+`.claude-plugin/plugin.json` (version = the app version) and `.mcp.json`
+(pointing at this machine's stable `openpcb-mcp` launcher), so the skills here
+always ship with — and describe — the tool set of the installed app.
+
+Tool names in the skills are the MCP tool names the OpenPCB server lists;
+`assistant-mcp-plugin.test.ts` fails if a skill names a tool that does not
+exist.
diff --git a/electron/resources/claude-plugin/openpcb/skills/openpcb-bom-check/SKILL.md b/electron/resources/claude-plugin/openpcb/skills/openpcb-bom-check/SKILL.md
new file mode 100644
index 00000000..4ac035f4
--- /dev/null
+++ b/electron/resources/claude-plugin/openpcb/skills/openpcb-bom-check/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: openpcb-bom-check
+description: Check the OpenPCB bill of materials for rows that would block ordering or assembly. Use when the user asks about the BOM, part numbers, or ordering readiness.
+---
+
+# Check an OpenPCB BOM
+
+1. `designer_get_bom`.
+2. Flag: rows with no MPN, duplicate reference designators, and any row the projection warned about
+ (facts from the BOM). Separately, as observations with their evidence: DNP parts that look
+ required, and values that seem not to fit the component's package or rating — only where the
+ library data shows the rating; otherwise say it cannot be checked.
+3. Finish with the count of orderable versus blocked lines.
+4. Report only what the BOM data supports — never invent part numbers or suppliers.
+5. `designer_export_manufacturing` checks whether the board can be exported and lists the bundle; the
+ user exports the files from OpenPCB's PCB toolbar.
diff --git a/electron/resources/claude-plugin/openpcb/skills/openpcb-build-circuit/SKILL.md b/electron/resources/claude-plugin/openpcb/skills/openpcb-build-circuit/SKILL.md
new file mode 100644
index 00000000..fadf25e1
--- /dev/null
+++ b/electron/resources/claude-plugin/openpcb/skills/openpcb-build-circuit/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: openpcb-build-circuit
+description: Build a circuit in the OpenPCB schematic from a description — resolve parts from the installed library, create or pick a design, place, wire, and verify. Use when the user asks to design, build, create or wire a circuit in OpenPCB.
+---
+
+# Build a circuit in OpenPCB
+
+OpenPCB must be running with Settings → Assistant → MCP enabled and **Allow writes** on.
+If no write tools are listed, ask the user to turn on Allow writes.
+
+## Before you build: separate what you may choose from what you must ask
+
+- **Ask the user** when the request does not fix something electrical or manufacturing-critical:
+ supply and logic voltage, currents and power / voltage ratings, a package or footprint the
+ assembly depends on, connector pinouts, isolation or safety requirements, fab limits. A wrong
+ guess here can destroy a part or make the board unbuildable — never fill it in silently.
+- **Choose and report** only reversible, low-stakes details: placement, spacing, label positions,
+ reference designator order. Say what you chose.
+- If the library has no part that meets a stated requirement, say so; do not substitute silently.
+
+Once the requirements are settled, carry the build through — placed, wired and verified — before
+summarising. Stop and ask if a tool result contradicts the plan.
+
+## Steps
+
+1. **Pick the design.** `designer_list_designs`; use the one the user means (`designer_use_design`
+ to pin it), or `designer_create_design` for a new one. Never guess ids.
+2. **Resolve the BOM** with `library_resolve_bom` (one call for the whole circuit). Search by generic
+ family and treat color/value/package as requirements (`LED` with color red, not "LED red").
+ Never invent parts; if something is missing after a broad search, say so and suggest an import.
+3. **Standard blocks** (indicator LEDs, …): prefer `compile_circuit` — one call resolves parts,
+ computes values, places, wires, adds power rails and runs ERC.
+4. **Otherwise place** with `designer_propose_schematic_edits` (parts, labels, power ports), then read
+ `designer_get_schematic_connectivity` for the new references and pin names.
+5. **Wire** in ONE `designer_propose_schematic_wires` call: pins as `REF.PIN` (`"U1.4"`), rails as
+ `{ "net": "GND" }` / `{ "net": "" }`. Prefer pin numbers over decorated
+ names.
+6. **Verify** with `designer_verify_build`; fix what it reports (missing parts, unwired rails,
+ dangling power pins, ERC errors) and verify again.
+7. **Summarise** only what tool results confirmed.
+
+## Rules
+
+- Pass a stable `action_id` on every write (e.g. `wire_U1.OUT__R1.1_`) so retries are
+ no-ops. After a rejection or a failure, use a NEW id — re-sending the old one is refused.
+- Non-destructive edits apply immediately and are undoable in OpenPCB (the user sees them live).
+- Deletions (`designer_propose_schematic_deletions`) wait for the user's approval in OpenPCB's
+ assistant panel: tell the user, then `assistant_await_proposal` with the proposal id. Never re-send.
+- Ids of nets, wires and parts can change after edits — re-read before reusing them.
+- Do not touch the PCB unless the user asks (see the openpcb-pcb-layout skill).
diff --git a/electron/resources/claude-plugin/openpcb/skills/openpcb-connection-help/SKILL.md b/electron/resources/claude-plugin/openpcb/skills/openpcb-connection-help/SKILL.md
new file mode 100644
index 00000000..44976643
--- /dev/null
+++ b/electron/resources/claude-plugin/openpcb/skills/openpcb-connection-help/SKILL.md
@@ -0,0 +1,27 @@
+---
+name: openpcb-connection-help
+description: Troubleshoot the connection between Claude Code and the OpenPCB desktop app — tools missing, "OpenPCB is not running", MCP disabled, writes unavailable, stale results. Use when OpenPCB tools fail or are not listed.
+---
+
+# OpenPCB connection help
+
+The `openpcb` MCP server is a bridge to the OpenPCB desktop app on this computer. It stays connected
+even while the app is closed and picks the app up again when it starts.
+
+| Symptom | Cause | What the user should do |
+|---|---|---|
+| Tool results say "OpenPCB is not running" | The app is closed | Start OpenPCB; the tool list refreshes by itself |
+| "MCP server is disabled" | Setting off | OpenPCB → Settings → Assistant → MCP → Enable MCP server |
+| Only read tools are listed | Writes off | Settings → Assistant → MCP → Allow writes |
+| "does not serve MCP" / update message | Old OpenPCB build | Update OpenPCB |
+| "OpenPCB is no longer at …", or the server fails to start after the app moved | App moved or reinstalled | Start OpenPCB, then Settings → Assistant → MCP → **Update connection** / **Update plugin** |
+| A proposal stays pending | Waiting for approval | Approve or reject it in OpenPCB's assistant panel (design dock) |
+| "not made by this session" / "No proposal … from this session" | Another Claude Code session made it | Only the session that proposed or applied a change can await or undo it |
+| New tools or skills missing after an OpenPCB update | Claude Code caches plugins | Settings → **Update plugin**, then `/reload-plugins` |
+
+Other facts:
+
+- Every tool call appears in OpenPCB's assistant panel, in a chat per Claude Code session and design
+ ("MCP · · · ").
+- The user can undo any change you applied with Ctrl+Z in OpenPCB.
+- In OpenPCB's Settings → Assistant → MCP the user can see connected clients and reconnect Claude Code.
diff --git a/electron/resources/claude-plugin/openpcb/skills/openpcb-drc-triage/SKILL.md b/electron/resources/claude-plugin/openpcb/skills/openpcb-drc-triage/SKILL.md
new file mode 100644
index 00000000..d45da6e8
--- /dev/null
+++ b/electron/resources/claude-plugin/openpcb/skills/openpcb-drc-triage/SKILL.md
@@ -0,0 +1,26 @@
+---
+name: openpcb-drc-triage
+description: Run DRC on the OpenPCB board and triage violations by root cause, most severe first, with the fix for each. Use when the user asks about DRC errors, clearance problems or whether the board is ready to manufacture.
+---
+
+# Triage OpenPCB DRC
+
+OpenPCB is the DRC authority — never compute clearances yourself.
+
+1. `designer_get_pcb_state` for board setup, net classes and `drcSuppression` (waived violations,
+ ignored rule classes); `designer_get_pcb_layout` (detail `summary`) if you need placement context.
+2. `designer_run_drc`. Read `counts`: active, waived, and hidden (ignored rule classes / severity
+ overrides). Report all three — **never call the board clean while anything is waived or hidden**;
+ say what is suppressed and that the user chose to.
+3. Group violations by root cause instead of listing them one by one — e.g. "clearance too tight for
+ the default net class", "unrouted power net", "annular ring below fab minimum". Order by severity.
+4. For each group say what would fix it and whether that is a **rule change** (`pcb_set_design_rules`,
+ which waits for the user's approval) or a **layout change** (`pcb_place_footprints`, `pcb_route`,
+ `pcb_delete_routing`).
+5. Waiving is the user's decision, not a fix. Only when the user explicitly asks: waive specific
+ violations with `pcb_waive_drc_violations` (ids from the current report, with the user's reason).
+ Ignoring a whole rule class (`pcb_set_drc_rule_class_ignores`) hides every present and future
+ violation of that class — propose it only on an explicit request. Both wait for the user's
+ approval in OpenPCB; tell them, then `assistant_await_proposal`. Safety-critical codes (shorts,
+ layer-invalid items) can never be waived.
+6. After any change, run `designer_run_drc` again and report the new counts.
diff --git a/electron/resources/claude-plugin/openpcb/skills/openpcb-pcb-layout/SKILL.md b/electron/resources/claude-plugin/openpcb/skills/openpcb-pcb-layout/SKILL.md
new file mode 100644
index 00000000..a06f9cdd
--- /dev/null
+++ b/electron/resources/claude-plugin/openpcb/skills/openpcb-pcb-layout/SKILL.md
@@ -0,0 +1,42 @@
+---
+name: openpcb-pcb-layout
+description: Lay out the OpenPCB board — outline, footprint placement, routing, pours — then check it with DRC. Use when the user asks to place footprints, route traces, draw the board outline, add a ground pour or otherwise work on the PCB.
+---
+
+# Lay out an OpenPCB board
+
+Work on the PCB only when the user asks. OpenPCB must have **Allow writes** enabled.
+All coordinates are millimetres in board space.
+
+## Read first
+
+`designer_get_pcb_layout` returns the outline, clearances, net classes, every footprint (reference,
+position, rotation, side) with pad positions and nets, existing copper per net, and the unrouted
+connections as pad pairs (`"U1.3"` → `"R1.1"`). Filter with `refs` / `nets` on big boards.
+
+## Workflow
+
+1. **Outline**: `pcb_set_board_outline` — rect (width × height), roundrect (+ `cornerRadiusMm`,
+ required), circle (`diameterMm`), oval (width × height) or a simple polygon. Use the size the user
+ gives; if they gave none, ask — board size and corner radius are mechanical constraints.
+2. **Place**: `pcb_place_footprints` by reference designator (position, rotation 0/90/180/270,
+ side top/bottom). Keep parts inside the outline; group by function; decoupling caps next to
+ their IC's power pins.
+3. **Route**: `pcb_route` per net — `from` / `to` pads as `REF.PAD`, optional `waypointsMm`; the
+ path gets 45° elbows automatically, width and vias default to the net's class. It is one atomic,
+ undoable change. If OpenPCB answers `PCB_COPPER_ILLEGAL`, the path would violate DRC — move the
+ waypoints or change layer (add a via) and try again; do not force it.
+4. **Pours**: `pcb_add_zone` (e.g. GND on B.Cu over the whole board), `pcb_update_zone`; keep areas
+ clear with `pcb_add_keepout` / `pcb_update_keepout`. Layers must exist on the board — read
+ `layerCount` from the layout; a 2-layer board has only F.Cu and B.Cu.
+5. **Check**: `designer_run_drc` after every batch; the write tools also report the DRC count.
+
+## Approval and undo
+
+- Deleting copper (`pcb_delete_routing`), deleting zones/keepouts (`pcb_delete_zone`,
+ `pcb_delete_keepout`), and rule changes (`pcb_set_design_rules`, not undoable) wait for the user's
+ approval in OpenPCB. Tell the user, then call `assistant_await_proposal`.
+- Never guess manufacturing values: widths, clearances and drill sizes come from the design's net
+ classes or from the user / their fab.
+- `designer_undo` / `designer_redo` only act on changes this session made; the user undoes their own
+ edits in OpenPCB. Use `designer_get_history` to see what the next undo would affect.
diff --git a/electron/resources/claude-plugin/openpcb/skills/openpcb-review-schematic/SKILL.md b/electron/resources/claude-plugin/openpcb/skills/openpcb-review-schematic/SKILL.md
new file mode 100644
index 00000000..c6617b95
--- /dev/null
+++ b/electron/resources/claude-plugin/openpcb/skills/openpcb-review-schematic/SKILL.md
@@ -0,0 +1,23 @@
+---
+name: openpcb-review-schematic
+description: Review the schematic open in OpenPCB — connectivity, ERC, and concrete design problems — without changing it. Use when the user asks to review, check or audit a schematic.
+---
+
+# Review an OpenPCB schematic
+
+This is a read-only review: do not propose edits unless the user asks.
+
+1. `designer_get_design_summary`, then `designer_get_schematic_connectivity` (parts, pins, nets, wires).
+2. `designer_run_erc`.
+3. If the user keeps specs or notes in OpenPCB Docs, look for them with `knowledge_search_pages` /
+ `knowledge_get_page` and check the design against them.
+4. Report in two clearly separate groups:
+ - **ERC findings** — exactly what `designer_run_erc` reported (unconnected pins, undriven rails,
+ conflicting drivers…), with reference designators and net names. These are facts.
+ - **Engineering observations** — heuristics such as missing decoupling, missing pull-ups on
+ open-drain / reset lines, or a value that seems wrong for its role. For each, give the evidence
+ (part, pins, nets, and the component data from `library_get_component_detail`) and say when the
+ data is not enough to be sure — e.g. no datasheet values in the library entry. Never present an
+ observation as a rule violation.
+5. Order each group by severity. Ask the user before assuming operating conditions (voltages,
+ currents) that a finding depends on.
diff --git a/electron/src/main/backend-server.ts b/electron/src/main/backend-server.ts
index 8ef5b7de..4a1a50a5 100644
--- a/electron/src/main/backend-server.ts
+++ b/electron/src/main/backend-server.ts
@@ -18,6 +18,17 @@ import {
removeMcpPortfile,
writeMcpPortfile,
} from "./mcp-portfile.js";
+import { installMcpLauncher } from "./mcp-launcher.js";
+import { writeClaudePluginMarketplace } from "./claude-plugin.js";
+import { stdioServerConfig, type LauncherPlatform } from "./mcp-launcher-content.js";
+
+function launcherPlatform(): LauncherPlatform {
+ return process.platform === "win32"
+ ? "win32"
+ : process.platform === "darwin"
+ ? "darwin"
+ : "linux";
+}
const log = electronLog.scope("backend");
@@ -33,7 +44,7 @@ let runtime: StartedBackendRuntime | null = null;
let backendPayload: BackendReadyPayload | null = null;
const REQUIRED_DESKTOP_MODULES = ["library", "designer", "assistant"] as const;
-function getAppDataDir(): string {
+export function getAppDataDir(): string {
const base = app.getPath("userData");
return app.isPackaged ? base : join(base, "dev");
}
@@ -213,6 +224,20 @@ export async function startBackendServer(): Promise {
url: startedRuntime.url,
port: startedRuntime.port,
});
+ // Stable entry points for Claude Code & co.: the launcher in the user-data
+ // dir (never the bundle path, which moves) and the local plugin
+ // marketplace that points at it. Both are rewritten on every launch.
+ const launcher = installMcpLauncher(appDataDir);
+ if (launcher) {
+ writeClaudePluginMarketplace({
+ appDataDir,
+ server: stdioServerConfig(launcherPlatform(), {
+ launcherPath: launcher.launcherPath,
+ exec: launcher.exec.exec,
+ shimPath: launcher.shimPath,
+ }),
+ });
+ }
log.info(`Backend ready at ${runtime.url}`);
return backendPayload;
@@ -243,3 +268,8 @@ export async function stopBackendServer(): Promise {
export function getMcpPortfilePath(): string {
return join(getAppDataDir(), "mcp.json");
}
+
+/** Where the local Claude Code plugin marketplace is written. */
+export function getClaudeMarketplaceDir(): string {
+ return join(getAppDataDir(), "claude-code", "marketplace");
+}
diff --git a/electron/src/main/claude-code-cli.ts b/electron/src/main/claude-code-cli.ts
new file mode 100644
index 00000000..10733d2b
--- /dev/null
+++ b/electron/src/main/claude-code-cli.ts
@@ -0,0 +1,519 @@
+/**
+ * One-click "Connect Claude Code": drive the user's own `claude` CLI to
+ * register OpenPCB, report the connection state, update and disconnect.
+ *
+ * No `electron` import, and the process runner and file reads are injected,
+ * so Bun tests exercise the whole flow against a fake CLI on any OS.
+ *
+ * Security posture:
+ * - Runs only on an explicit click in Settings.
+ * - The CLI is spawned with a fixed argv, never a shell string and never user
+ * text. An npm-installed `claude.cmd` on Windows is resolved to the script
+ * or exe it wraps and spawned directly; only an unrecognisable .cmd goes
+ * through cmd.exe, escaped for it (win-cmd.ts).
+ * - A timeout on every run.
+ * - Ownership is exact: it only replaces or removes a registration whose
+ * command and arguments match what this installation registered
+ * (claude-registration.ts) or would register now — never one that merely
+ * shares the name.
+ */
+
+import { execFile } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { homedir } from "node:os";
+import path from "node:path";
+import {
+ MARKETPLACE_NAME,
+ MCP_SERVER_NAME,
+ PLUGIN_NAME,
+} from "./claude-plugin-content.js";
+import type { ClaudeRegistration } from "./claude-registration.js";
+import type { StdioServerConfig } from "./mcp-launcher-content.js";
+import { cmdCommandLine, cmdShimTarget, reparsesArguments } from "./win-cmd.js";
+
+export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
+
+export interface RunResult {
+ code: number;
+ stdout: string;
+ stderr: string;
+}
+
+export type Runner = (
+ file: string,
+ args: string[],
+ timeoutMs: number,
+ options?: { verbatim?: boolean },
+) => Promise;
+
+export const defaultRunner: Runner = (file, args, timeoutMs, options) =>
+ new Promise((resolve) => {
+ execFile(
+ file,
+ args,
+ {
+ timeout: timeoutMs,
+ windowsHide: true,
+ maxBuffer: 4 * 1024 * 1024,
+ windowsVerbatimArguments: options?.verbatim === true,
+ },
+ (error, stdout, stderr) => {
+ const code =
+ error && typeof (error as { code?: unknown }).code === "number"
+ ? ((error as { code: number }).code)
+ : error
+ ? 1
+ : 0;
+ resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
+ },
+ );
+ });
+
+export interface CliEnv {
+ platform: NodeJS.Platform;
+ env: Record;
+ homedir: string;
+ exists(path: string): boolean;
+ run: Runner;
+ /** File contents, or null — used to read an npm cmd-shim on Windows. */
+ readFile?(path: string): string | null;
+}
+
+export function defaultCliEnv(): CliEnv {
+ return {
+ platform: process.platform,
+ env: process.env,
+ homedir: homedir(),
+ exists: existsSync,
+ run: defaultRunner,
+ readFile: (file) => {
+ try {
+ return readFileSync(file, "utf8");
+ } catch {
+ return null;
+ }
+ },
+ };
+}
+
+function pathApi(env: CliEnv): typeof path.posix {
+ return env.platform === "win32" ? path.win32 : path.posix;
+}
+
+function candidateNames(platform: NodeJS.Platform): string[] {
+ return platform === "win32" ? ["claude.exe", "claude.cmd"] : ["claude"];
+}
+
+function pathDirs(env: CliEnv): string[] {
+ return (env.env.PATH ?? env.env.Path ?? "")
+ .split(env.platform === "win32" ? ";" : ":")
+ .filter(Boolean);
+}
+
+/**
+ * Find the `claude` binary. A GUI app on macOS does not inherit the user's
+ * shell PATH, so after PATH, ask the login shell, then try the places the
+ * native installer and npm put it.
+ */
+export async function locateClaudeCli(env: CliEnv): Promise {
+ const p = pathApi(env);
+ const names = candidateNames(env.platform);
+ for (const dir of pathDirs(env)) {
+ for (const name of names) {
+ const candidate = p.join(dir, name);
+ if (env.exists(candidate)) return candidate;
+ }
+ }
+ if (env.platform !== "win32") {
+ const shell = env.env.SHELL || "/bin/sh";
+ const probe = await env.run(shell, ["-ilc", "command -v claude"], 3_000);
+ const found = probe.stdout
+ .split("\n")
+ .map((line) => line.trim())
+ .reverse()
+ .find((line) => line.startsWith("/"));
+ if (probe.code === 0 && found && env.exists(found)) return found;
+ }
+ const home = env.homedir;
+ const known =
+ env.platform === "win32"
+ ? [
+ p.join(home, ".local", "bin", "claude.exe"),
+ p.join(env.env.APPDATA ?? p.join(home, "AppData", "Roaming"), "npm", "claude.cmd"),
+ ]
+ : [
+ p.join(home, ".local", "bin", "claude"),
+ p.join(home, ".claude", "local", "claude"),
+ "/opt/homebrew/bin/claude",
+ "/usr/local/bin/claude",
+ p.join(home, ".npm-global", "bin", "claude"),
+ ];
+ return known.find((candidate) => env.exists(candidate)) ?? null;
+}
+
+interface Invocation {
+ file: string;
+ prefix: string[];
+ /** Run `file` as cmd.exe with this pre-escaped /c payload. */
+ cmdLine?: (args: string[]) => string;
+}
+
+/**
+ * How to run the CLI. A Windows `.cmd` is an npm cmd-shim around
+ * `node …\cli.js` (or an .exe): run that directly, so no argument ever
+ * crosses cmd.exe's parser. Only a .cmd we cannot read falls back to cmd.exe,
+ * with every argument escaped for it.
+ */
+export function claudeInvocation(env: CliEnv, cli: string): Invocation {
+ if (env.platform !== "win32" || !/\.cmd$/i.test(cli)) return { file: cli, prefix: [] };
+ const p = path.win32;
+ const content = env.readFile?.(cli) ?? null;
+ const target = content ? cmdShimTarget(content) : null;
+ if (target) {
+ const resolved = p.join(p.dirname(cli), target.path);
+ if (env.exists(resolved)) {
+ if (target.kind === "exe") return { file: resolved, prefix: [] };
+ const bundledNode = p.join(p.dirname(cli), "node.exe");
+ const node = env.exists(bundledNode)
+ ? bundledNode
+ : pathDirs(env)
+ .map((dir) => p.join(dir, "node.exe"))
+ .find((candidate) => env.exists(candidate));
+ if (node) return { file: node, prefix: [resolved] };
+ }
+ }
+ const doubleEscape = reparsesArguments(content);
+ return {
+ file: env.env.ComSpec || env.env.COMSPEC || "cmd.exe",
+ prefix: [],
+ cmdLine: (args) => cmdCommandLine(cli, args, doubleEscape),
+ };
+}
+
+function runClaude(env: CliEnv, cli: string, args: string[], timeoutMs = 60_000): Promise {
+ const invocation = claudeInvocation(env, cli);
+ if (invocation.cmdLine) {
+ return env.run(invocation.file, ["/d", "/s", "/c", invocation.cmdLine(args)], timeoutMs, {
+ verbatim: true,
+ });
+ }
+ return env.run(invocation.file, [...invocation.prefix, ...args], timeoutMs);
+}
+
+function parseJson(text: string): T | null {
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ return null;
+ }
+}
+
+/** What `claude mcp get ` prints (2.1.x), as far as ownership needs it. */
+export interface McpGetInfo {
+ scope: string | null;
+ type: string | null;
+ command: string | null;
+ /** The `Args:` line — the CLI joins arguments with single spaces. */
+ args: string;
+ env: Record;
+}
+
+export function parseMcpGet(text: string): McpGetInfo {
+ const info: McpGetInfo = { scope: null, type: null, command: null, args: "", env: {} };
+ let inEnv = false;
+ for (const raw of text.split(/\r?\n/)) {
+ const line = raw.trimEnd();
+ const field = /^\s{2}([A-Za-z]+):\s?(.*)$/.exec(line);
+ if (field) {
+ const [, key, value] = field;
+ inEnv = key === "Environment";
+ if (key === "Scope") info.scope = value!.trim();
+ else if (key === "Type") info.type = value!.trim();
+ else if (key === "Command") info.command = value!.trim();
+ else if (key === "Args") info.args = value!.trim();
+ continue;
+ }
+ const envLine = /^\s{4,}([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
+ if (inEnv && envLine) {
+ info.env[envLine[1]!] = envLine[2]!;
+ continue;
+ }
+ if (line.trim()) inEnv = false;
+ }
+ return info;
+}
+
+/** Does a registration printed by `mcp get` run exactly this server? */
+export function registrationMatches(
+ info: McpGetInfo,
+ config: StdioServerConfig,
+ platform: NodeJS.Platform,
+): boolean {
+ const norm =
+ platform === "win32"
+ ? (value: string) => value.replace(/\//g, "\\").toLowerCase()
+ : (value: string) => value;
+ if (info.command === null || norm(info.command) !== norm(config.command)) return false;
+ if (norm(info.args) !== norm(config.args.join(" "))) return false;
+ for (const [key, value] of Object.entries(config.env ?? {})) {
+ if (info.env[key] !== value) return false;
+ }
+ return true;
+}
+
+function sameConfig(a: StdioServerConfig, b: StdioServerConfig, platform: NodeJS.Platform): boolean {
+ const norm = platform === "win32" ? (v: string) => v.toLowerCase() : (v: string) => v;
+ return (
+ norm(a.command) === norm(b.command) &&
+ a.args.length === b.args.length &&
+ a.args.every((arg, i) => norm(arg) === norm(b.args[i]!)) &&
+ JSON.stringify(a.env ?? {}) === JSON.stringify(b.env ?? {})
+ );
+}
+
+export interface ClaudeCodeStatus {
+ cliPath: string | null;
+ cliVersion: string | null;
+ /** The OpenPCB plugin is installed (any scope). */
+ plugin: { installed: boolean; version: string | null; enabled: boolean };
+ /** The OpenPCB local marketplace is registered (and points at ours). */
+ marketplace: { registered: boolean; path: string | null; ours: boolean };
+ /**
+ * A plain `openpcb` MCP server: registered at all, and whether it is ours
+ * (its command + args match our record or what we would register now) —
+ * `outdated` when ours but not what we would register now (app moved).
+ */
+ server: { registered: boolean; ownedByOpenPcb: boolean; outdated: boolean };
+ /** How this installation connected Claude Code, per its record. */
+ registeredMode: "plugin" | "server" | null;
+ /** Something OpenPCB registered needs refreshing (plugin version or moved app). */
+ updateAvailable: boolean;
+}
+
+interface PluginListEntry {
+ id: string;
+ version?: string;
+ enabled?: boolean;
+}
+
+interface MarketplaceListEntry {
+ name: string;
+ path?: string;
+}
+
+export interface StatusInput {
+ appVersion: string;
+ /** The server entry this app would register now (null: launcher missing). */
+ expectedServer: StdioServerConfig | null;
+ /** This installation's record of what it registered. */
+ registration: ClaudeRegistration | null;
+ /** Our generated marketplace directory. */
+ marketplaceDir?: string | null;
+}
+
+export async function claudeCodeStatus(env: CliEnv, input: StatusInput): Promise {
+ const cliPath = await locateClaudeCli(env);
+ const empty: ClaudeCodeStatus = {
+ cliPath,
+ cliVersion: null,
+ plugin: { installed: false, version: null, enabled: false },
+ marketplace: { registered: false, path: null, ours: false },
+ server: { registered: false, ownedByOpenPcb: false, outdated: false },
+ registeredMode: input.registration?.mode ?? null,
+ updateAvailable: false,
+ };
+ if (!cliPath) return empty;
+ const [version, plugins, marketplaces, server] = await Promise.all([
+ runClaude(env, cliPath, ["--version"], 15_000),
+ runClaude(env, cliPath, ["plugin", "list", "--json"], 30_000),
+ runClaude(env, cliPath, ["plugin", "marketplace", "list", "--json"], 30_000),
+ runClaude(env, cliPath, ["mcp", "get", MCP_SERVER_NAME], 30_000),
+ ]);
+ const plugin = (parseJson(plugins.stdout) ?? []).find(
+ (entry) => entry.id === PLUGIN_ID,
+ );
+ const marketplace = (parseJson(marketplaces.stdout) ?? []).find(
+ (entry) => entry.name === MARKETPLACE_NAME,
+ );
+ const info = server.code === 0 ? parseMcpGet(server.stdout) : null;
+ const userScope = Boolean(info?.scope && /user/i.test(info.scope));
+ const matchesRecord = Boolean(
+ info && input.registration?.mode === "server" && registrationMatches(info, input.registration.config, env.platform),
+ );
+ const matchesExpected = Boolean(
+ info && input.expectedServer && registrationMatches(info, input.expectedServer, env.platform),
+ );
+ const owned = userScope && (matchesRecord || matchesExpected);
+ const serverOutdated = owned && !matchesExpected;
+ const pluginOutdated = Boolean(
+ plugin &&
+ ((plugin.version && plugin.version !== input.appVersion) ||
+ (input.registration?.mode === "plugin" &&
+ input.expectedServer &&
+ !sameConfig(input.registration.config, input.expectedServer, env.platform))),
+ );
+ const ours = Boolean(
+ marketplace &&
+ (!input.marketplaceDir || !marketplace.path || pathApi(env).resolve(marketplace.path) === pathApi(env).resolve(input.marketplaceDir)),
+ );
+ return {
+ cliPath,
+ cliVersion: version.code === 0 ? version.stdout.trim().split(/\s+/)[0] ?? null : null,
+ plugin: {
+ installed: Boolean(plugin),
+ version: plugin?.version ?? null,
+ enabled: plugin?.enabled !== false && Boolean(plugin),
+ },
+ marketplace: { registered: Boolean(marketplace), path: marketplace?.path ?? null, ours },
+ server: { registered: server.code === 0, ownedByOpenPcb: owned, outdated: serverOutdated },
+ registeredMode: input.registration?.mode ?? (plugin ? "plugin" : owned ? "server" : null),
+ updateAvailable: pluginOutdated || serverOutdated,
+ };
+}
+
+export interface ActionResult {
+ ok: boolean;
+ message: string;
+ /** Every CLI call made, for the Settings panel's "details". */
+ log: Array<{ args: string[]; code: number; output: string }>;
+ /** What to record as registered (connect), or that the record goes (disconnect). */
+ registration?: Omit;
+ clearRegistration?: boolean;
+}
+
+async function step(
+ env: CliEnv,
+ cli: string,
+ args: string[],
+ log: ActionResult["log"],
+): Promise {
+ const result = await runClaude(env, cli, args);
+ log.push({ args, code: result.code, output: `${result.stdout}${result.stderr}`.trim().slice(0, 2_000) });
+ return result;
+}
+
+const NO_CLI =
+ "Claude Code was not found. Install it (https://code.claude.com), then click Connect again — or copy the command below into a terminal.";
+
+/** `claude mcp add --scope user openpcb [-e K=V…] -- ` */
+export function mcpAddArgs(config: StdioServerConfig): string[] {
+ const envFlags = Object.entries(config.env ?? {}).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
+ return ["mcp", "add", "--scope", "user", MCP_SERVER_NAME, ...envFlags, "--", config.command, ...config.args];
+}
+
+export interface ConnectInput extends StatusInput {
+ mode: "plugin" | "server";
+ serverConfig: StdioServerConfig;
+}
+
+/**
+ * Register OpenPCB with Claude Code.
+ * - `plugin` (recommended): add/refresh the local marketplace, install or
+ * update the plugin (MCP server + skills). A plain `openpcb` server that is
+ * ours is removed so tools are not listed twice.
+ * - `server`: register only the MCP server, user scope.
+ */
+export async function connectClaudeCode(env: CliEnv, input: ConnectInput): Promise {
+ const log: ActionResult["log"] = [];
+ const cli = await locateClaudeCli(env);
+ if (!cli) return { ok: false, message: NO_CLI, log };
+ const status = await claudeCodeStatus(env, { ...input, expectedServer: input.serverConfig });
+
+ if (input.mode === "server") {
+ if (status.server.registered) {
+ if (!status.server.ownedByOpenPcb) {
+ return {
+ ok: false,
+ message: `Claude Code already has an MCP server named "${MCP_SERVER_NAME}" that OpenPCB did not register. Remove or rename it first (claude mcp remove ${MCP_SERVER_NAME}).`,
+ log,
+ };
+ }
+ await step(env, cli, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"], log);
+ }
+ const added = await step(env, cli, mcpAddArgs(input.serverConfig), log);
+ return added.code === 0
+ ? {
+ ok: true,
+ message: status.server.registered
+ ? "Updated the OpenPCB MCP server. In open Claude Code sessions run /mcp and reconnect openpcb (or start a new session)."
+ : "Connected: Claude Code can use OpenPCB in every project. In open Claude Code sessions run /mcp and reconnect openpcb (or start a new session).",
+ log,
+ registration: {
+ mode: "server",
+ serverName: MCP_SERVER_NAME,
+ scope: "user",
+ config: input.serverConfig,
+ appVersion: input.appVersion,
+ },
+ }
+ : { ok: false, message: "Claude Code refused to add the server — see details.", log };
+ }
+
+ if (!input.marketplaceDir) {
+ return { ok: false, message: "The OpenPCB plugin files are missing from this install; use “MCP server only”.", log };
+ }
+ if (status.marketplace.registered && !status.marketplace.ours) {
+ return {
+ ok: false,
+ message: `Claude Code has a different plugin marketplace named "${MARKETPLACE_NAME}" (${status.marketplace.path ?? "unknown path"}). Remove it first: claude plugin marketplace remove ${MARKETPLACE_NAME}.`,
+ log,
+ };
+ }
+ const market = status.marketplace.registered
+ ? await step(env, cli, ["plugin", "marketplace", "update", MARKETPLACE_NAME], log)
+ : await step(env, cli, ["plugin", "marketplace", "add", input.marketplaceDir], log);
+ if (market.code !== 0) {
+ return { ok: false, message: "Claude Code could not read OpenPCB's plugin marketplace — see details.", log };
+ }
+ const plugin = status.plugin.installed
+ ? await step(env, cli, ["plugin", "update", PLUGIN_ID, "--scope", "user"], log)
+ : await step(env, cli, ["plugin", "install", PLUGIN_ID, "--scope", "user"], log);
+ if (plugin.code !== 0) {
+ return { ok: false, message: "Claude Code could not install the OpenPCB plugin — see details.", log };
+ }
+ if (status.server.registered && status.server.ownedByOpenPcb) {
+ // The plugin brings its own server; drop our plain one so every tool is not listed twice.
+ await step(env, cli, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"], log);
+ }
+ return {
+ ok: true,
+ message: status.plugin.installed
+ ? "Updated the OpenPCB plugin. In open Claude Code sessions run /reload-plugins (or start a new session)."
+ : "Connected: the OpenPCB plugin (tools + skills) is installed for every project. In open Claude Code sessions run /reload-plugins (or start a new session).",
+ log,
+ registration: {
+ mode: "plugin",
+ serverName: MCP_SERVER_NAME,
+ scope: "user",
+ config: input.serverConfig,
+ pluginId: PLUGIN_ID,
+ marketplaceDir: input.marketplaceDir,
+ appVersion: input.appVersion,
+ },
+ };
+}
+
+export async function disconnectClaudeCode(env: CliEnv, input: StatusInput): Promise {
+ const log: ActionResult["log"] = [];
+ const cli = await locateClaudeCli(env);
+ if (!cli) return { ok: false, message: NO_CLI, log };
+ const status = await claudeCodeStatus(env, input);
+ if (status.plugin.installed) {
+ await step(env, cli, ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"], log);
+ }
+ if (status.marketplace.registered && status.marketplace.ours) {
+ await step(env, cli, ["plugin", "marketplace", "remove", MARKETPLACE_NAME], log);
+ }
+ if (status.server.registered && status.server.ownedByOpenPcb) {
+ await step(env, cli, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"], log);
+ }
+ const failed = log.filter((entry) => entry.code !== 0);
+ return failed.length === 0
+ ? {
+ ok: true,
+ message: log.length === 0 ? "OpenPCB was not registered with Claude Code." : "Disconnected OpenPCB from Claude Code.",
+ log,
+ clearRegistration: true,
+ }
+ : { ok: false, message: "Some Claude Code registrations could not be removed — see details.", log };
+}
diff --git a/electron/src/main/claude-plugin-content.ts b/electron/src/main/claude-plugin-content.ts
new file mode 100644
index 00000000..1f00e112
--- /dev/null
+++ b/electron/src/main/claude-plugin-content.ts
@@ -0,0 +1,92 @@
+/**
+ * The local Claude Code plugin marketplace OpenPCB generates — pure content,
+ * no `electron` import, so Bun tests can check it (and run the skills' tool
+ * names against the live MCP tool list).
+ *
+ * Layout written under `/claude-code/marketplace/`:
+ *
+ * .claude-plugin/marketplace.json name "openpcb-desktop"
+ * openpcb/.claude-plugin/plugin.json version = the app's version
+ * openpcb/.mcp.json the MCP server entry (stdioServerConfig)
+ * openpcb/skills//SKILL.md copied from the template shipped with the app
+ *
+ * Claude Code copies an installed plugin into its own cache, so what
+ * `.mcp.json` names must stay valid across app updates. On macOS / Linux that
+ * is the stable launcher the app rewrites every launch. On Windows it is the
+ * app binary + ELECTRON_RUN_AS_NODE (no cmd.exe in the transport); when that
+ * binary moves, the app sees the drift against its registration record and
+ * Settings offers "Update plugin".
+ */
+
+export const MARKETPLACE_NAME = "openpcb-desktop";
+export const PLUGIN_NAME = "openpcb";
+export const MCP_SERVER_NAME = "openpcb";
+
+export interface PluginBuildInput {
+ appVersion: string;
+ /** Template files, relative to the template's plugin root (e.g. "skills/x/SKILL.md"). */
+ templateFiles: Record;
+ server: { command: string; args: string[]; env?: Record };
+}
+
+/** Relative path → file contents for the whole marketplace directory. */
+export function buildPluginMarketplace(input: PluginBuildInput): Record {
+ const files: Record = {};
+ files[".claude-plugin/marketplace.json"] = `${JSON.stringify(
+ {
+ name: MARKETPLACE_NAME,
+ owner: { name: "OpenPCB" },
+ description: "The OpenPCB desktop app's own Claude Code plugin, generated by the installed app.",
+ plugins: [
+ {
+ name: PLUGIN_NAME,
+ source: `./${PLUGIN_NAME}`,
+ description:
+ "Drive the OpenPCB desktop app from Claude Code: schematic, PCB and library tools plus build, review, DRC, BOM and layout workflows.",
+ version: input.appVersion,
+ },
+ ],
+ },
+ null,
+ 2,
+ )}\n`;
+ files[`${PLUGIN_NAME}/.claude-plugin/plugin.json`] = `${JSON.stringify(
+ {
+ name: PLUGIN_NAME,
+ version: input.appVersion,
+ description:
+ "Drive the OpenPCB desktop app from Claude Code: schematic, PCB and library tools plus build, review, DRC, BOM and layout workflows.",
+ author: { name: "OpenPCB" },
+ },
+ null,
+ 2,
+ )}\n`;
+ files[`${PLUGIN_NAME}/.mcp.json`] = `${JSON.stringify(
+ {
+ mcpServers: {
+ [MCP_SERVER_NAME]: {
+ command: input.server.command,
+ args: input.server.args,
+ ...(input.server.env ? { env: input.server.env } : {}),
+ },
+ },
+ },
+ null,
+ 2,
+ )}\n`;
+ for (const [relative, contents] of Object.entries(input.templateFiles)) {
+ // The template README is for repo readers; plugin.json/.mcp.json are generated.
+ if (relative === "README.md") continue;
+ files[`${PLUGIN_NAME}/${relative}`] = contents;
+ }
+ return files;
+}
+
+/** Every MCP tool name a skill mentions (backticked snake_case identifiers). */
+export function toolNamesInSkill(markdown: string): string[] {
+ const names = new Set();
+ for (const match of markdown.matchAll(/`((?:designer|library|pcb|assistant|knowledge)_[a-z_]+|compile_circuit)`/g)) {
+ names.add(match[1]!);
+ }
+ return [...names];
+}
diff --git a/electron/src/main/claude-plugin.ts b/electron/src/main/claude-plugin.ts
new file mode 100644
index 00000000..be02360d
--- /dev/null
+++ b/electron/src/main/claude-plugin.ts
@@ -0,0 +1,79 @@
+import { app } from "electron";
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { log as electronLog } from "./logger.js";
+import { buildPluginMarketplace } from "./claude-plugin-content.js";
+
+const log = electronLog.scope("mcp");
+
+/**
+ * Write the local Claude Code plugin marketplace (see claude-plugin-content.ts)
+ * into `/claude-code/marketplace/`, replacing the previous copy
+ * atomically (build in a sibling temp dir, then swap), so a `claude plugin
+ * marketplace update` never reads a half-written tree.
+ */
+
+function templateDir(): string | null {
+ const candidates = app.isPackaged
+ ? [join(process.resourcesPath, "claude-plugin", "openpcb")]
+ : [join(app.getAppPath(), "resources", "claude-plugin", "openpcb")];
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
+}
+
+function readTree(root: string): Record {
+ const files: Record = {};
+ const walk = (dir: string) => {
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) walk(full);
+ else files[relative(root, full).split("\\").join("/")] = readFileSync(full, "utf8");
+ }
+ };
+ walk(root);
+ return files;
+}
+
+export function writeClaudePluginMarketplace(input: {
+ appDataDir: string;
+ server: { command: string; args: string[]; env?: Record };
+}): string | null {
+ const template = templateDir();
+ if (!template) {
+ log.warn("Claude Code plugin template not found; plugin marketplace not written.");
+ return null;
+ }
+ const target = join(input.appDataDir, "claude-code", "marketplace");
+ const staging = `${target}.${process.pid}.tmp`;
+ try {
+ const files = buildPluginMarketplace({
+ appVersion: app.getVersion(),
+ templateFiles: readTree(template),
+ server: input.server,
+ });
+ rmSync(staging, { recursive: true, force: true });
+ for (const [path, contents] of Object.entries(files)) {
+ const full = join(staging, path);
+ mkdirSync(dirname(full), { recursive: true });
+ writeFileSync(full, contents, "utf8");
+ }
+ const previous = `${target}.old`;
+ rmSync(previous, { recursive: true, force: true });
+ if (existsSync(target)) renameSync(target, previous);
+ renameSync(staging, target);
+ rmSync(previous, { recursive: true, force: true });
+ return target;
+ } catch (error) {
+ rmSync(staging, { recursive: true, force: true });
+ log.warn(`Failed to write the Claude Code plugin marketplace: ${String(error)}`);
+ return existsSync(target) ? target : null;
+ }
+}
diff --git a/electron/src/main/claude-registration.ts b/electron/src/main/claude-registration.ts
new file mode 100644
index 00000000..92356783
--- /dev/null
+++ b/electron/src/main/claude-registration.ts
@@ -0,0 +1,72 @@
+import { randomUUID } from "node:crypto";
+import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import type { StdioServerConfig } from "./mcp-launcher-content.js";
+
+/**
+ * What OpenPCB registered with Claude Code, kept next to the generated
+ * marketplace (`/claude-code/registration.json`).
+ *
+ * It is the ownership record for the one-click setup: Disconnect / Update
+ * only touch a Claude Code registration whose command and arguments match
+ * what this installation registered (or would register now) — never one that
+ * merely has the same name or a similar-looking path. It also carries the
+ * exact server entry, so an app that moved (Windows portable, reinstall
+ * elsewhere) sees that Claude Code still points at the old binary.
+ *
+ * No `electron` import; Bun tests use a temp dir.
+ */
+export interface ClaudeRegistration {
+ /** Stable per user-data dir, created on first connect. */
+ installationId: string;
+ mode: "plugin" | "server";
+ serverName: string;
+ scope: "user";
+ /** The server entry registered (server mode) or baked into the plugin. */
+ config: StdioServerConfig;
+ pluginId?: string;
+ marketplaceDir?: string;
+ appVersion: string;
+ registeredAt: string;
+}
+
+function dirOf(appDataDir: string): string {
+ return join(appDataDir, "claude-code");
+}
+
+function fileOf(appDataDir: string): string {
+ return join(dirOf(appDataDir), "registration.json");
+}
+
+export function readRegistration(appDataDir: string): ClaudeRegistration | null {
+ try {
+ const parsed = JSON.parse(readFileSync(fileOf(appDataDir), "utf8")) as ClaudeRegistration;
+ if (!parsed || typeof parsed.installationId !== "string" || !parsed.config) return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export function writeRegistration(
+ appDataDir: string,
+ record: Omit,
+ now: () => Date = () => new Date(),
+): ClaudeRegistration {
+ const previous = readRegistration(appDataDir);
+ const full: ClaudeRegistration = {
+ ...record,
+ installationId: previous?.installationId ?? randomUUID(),
+ registeredAt: now().toISOString(),
+ };
+ mkdirSync(dirOf(appDataDir), { recursive: true });
+ const target = fileOf(appDataDir);
+ const tmp = `${target}.${process.pid}.tmp`;
+ writeFileSync(tmp, `${JSON.stringify(full, null, 2)}\n`, "utf8");
+ renameSync(tmp, target);
+ return full;
+}
+
+export function clearRegistration(appDataDir: string): void {
+ rmSync(fileOf(appDataDir), { force: true });
+}
diff --git a/electron/src/main/diagnostics-ipc.ts b/electron/src/main/diagnostics-ipc.ts
index 640a7384..7f23c091 100644
--- a/electron/src/main/diagnostics-ipc.ts
+++ b/electron/src/main/diagnostics-ipc.ts
@@ -1,27 +1,51 @@
import os from "node:os";
import { existsSync } from "node:fs";
-import { join } from "node:path";
import { app, ipcMain, shell } from "electron";
import { getCrashDumpsDir } from "./crash.js";
-import { getBackendPayload, getMcpPortfilePath } from "./backend-server.js";
+import {
+ getAppDataDir,
+ getBackendPayload,
+ getClaudeMarketplaceDir,
+ getMcpPortfilePath,
+} from "./backend-server.js";
+import {
+ clearRegistration,
+ readRegistration,
+ writeRegistration,
+} from "./claude-registration.js";
import { ensureMcpToken } from "./mcp-portfile.js";
+import { getInstalledMcpLauncher } from "./mcp-launcher.js";
+import {
+ mcpSnippets,
+ stdioServerConfig,
+ type LauncherPlatform,
+ type McpServerTarget,
+} from "./mcp-launcher-content.js";
+import {
+ claudeCodeStatus,
+ connectClaudeCode,
+ defaultCliEnv,
+ disconnectClaudeCode,
+} from "./claude-code-cli.js";
-/**
- * Absolute path to the bundled stdio shim launcher, or null when running
- * unpackaged (where it lives in electron/dist and is not laid out as it will
- * be in the app bundle).
- */
-function getMcpShimPath(): string | null {
- if (!app.isPackaged) return null;
- return join(
- process.resourcesPath,
- "mcp",
- process.platform === "win32" ? "openpcb-mcp.cmd" : "openpcb-mcp",
- );
+function launcherPlatform(): LauncherPlatform {
+ return process.platform === "win32"
+ ? "win32"
+ : process.platform === "darwin"
+ ? "darwin"
+ : "linux";
}
let registered = false;
+/** The launcher this launch installed, as a server target (null if missing). */
+function serverTarget(): McpServerTarget | null {
+ const launcher = getInstalledMcpLauncher();
+ return launcher
+ ? { launcherPath: launcher.launcherPath, exec: launcher.exec.exec, shimPath: launcher.shimPath }
+ : null;
+}
+
export function registerDiagnosticsIpc(): void {
if (registered) return;
registered = true;
@@ -51,14 +75,24 @@ export function registerDiagnosticsIpc(): void {
appVersion: app.getVersion(),
}));
- // Everything the Settings panel needs to render a copy-pasteable MCP client
- // config. The shim path is resolved here because only main knows whether the
- // app is packaged and where its resources landed.
+ // Everything the Settings panel needs to connect an MCP client. The paths
+ // point at the stable launcher in the user-data dir (rewritten every launch),
+ // never into the app bundle, which moves (AppImage, portable, translocation).
ipcMain.handle("mcp:config", () => {
- const shimPath = getMcpShimPath();
+ const launcher = getInstalledMcpLauncher();
+ const marketplaceDir = getClaudeMarketplaceDir();
+ const hasMarketplace = existsSync(marketplaceDir);
return {
- shimPath,
- shimAvailable: shimPath !== null && existsSync(shimPath),
+ launcherPath: launcher?.launcherPath ?? null,
+ launcherWarning: launcher?.exec.warning ?? null,
+ marketplaceDir: hasMarketplace ? marketplaceDir : null,
+ snippets: launcher
+ ? mcpSnippets({
+ platform: launcherPlatform(),
+ target: serverTarget()!,
+ marketplaceDir: hasMarketplace ? marketplaceDir : null,
+ })
+ : [],
portfilePath: getMcpPortfilePath(),
url: getBackendPayload()
? `${getBackendPayload()?.url}/api/modules/assistant/mcp`
@@ -67,6 +101,52 @@ export function registerDiagnosticsIpc(): void {
};
});
+ // One-click Claude Code setup (claude-code-cli.ts). Runs the user's own
+ // `claude` CLI with fixed arguments, only on an explicit click. The
+ // registration record (claude-registration.ts) is what makes ownership
+ // exact: only what this installation registered is updated or removed.
+ const statusInput = () => {
+ const target = serverTarget();
+ const marketplaceDir = getClaudeMarketplaceDir();
+ return {
+ appVersion: app.getVersion(),
+ expectedServer: target ? stdioServerConfig(launcherPlatform(), target) : null,
+ registration: readRegistration(getAppDataDir()),
+ marketplaceDir: existsSync(marketplaceDir) ? marketplaceDir : null,
+ };
+ };
+ ipcMain.handle("mcp:claude-code:status", () =>
+ claudeCodeStatus(defaultCliEnv(), statusInput()),
+ );
+ ipcMain.handle(
+ "mcp:claude-code:connect",
+ async (_event, mode: unknown) => {
+ const target = serverTarget();
+ if (!target) {
+ return {
+ ok: false,
+ message: "The OpenPCB MCP launcher is not installed; restart OpenPCB.",
+ log: [],
+ };
+ }
+ const input = statusInput();
+ const result = await connectClaudeCode(defaultCliEnv(), {
+ ...input,
+ mode: mode === "server" ? "server" : "plugin",
+ serverConfig: stdioServerConfig(launcherPlatform(), target),
+ });
+ if (result.ok && result.registration) {
+ writeRegistration(getAppDataDir(), result.registration);
+ }
+ return { ok: result.ok, message: result.message, log: result.log };
+ },
+ );
+ ipcMain.handle("mcp:claude-code:disconnect", async () => {
+ const result = await disconnectClaudeCode(defaultCliEnv(), statusInput());
+ if (result.ok && result.clearRegistration) clearRegistration(getAppDataDir());
+ return { ok: result.ok, message: result.message, log: result.log };
+ });
+
ipcMain.handle("app:get-versions", () => ({
app: app.getVersion(),
electron: process.versions.electron,
diff --git a/electron/src/main/mcp-launcher-content.ts b/electron/src/main/mcp-launcher-content.ts
new file mode 100644
index 00000000..636e8ab8
--- /dev/null
+++ b/electron/src/main/mcp-launcher-content.ts
@@ -0,0 +1,272 @@
+/**
+ * What the stable MCP launcher looks like — pure functions, no `electron`
+ * import, so Bun tests can pin the scripts and snippets down.
+ *
+ * Why a launcher in the user-data dir at all: the bridge (`shim.js`) ships
+ * inside the app bundle, and a path into the bundle is not stable. An
+ * AppImage mounts at a new `/tmp/.mount_*` path on every launch, the portable
+ * Windows build extracts to a temp dir, an unsigned macOS app launched from
+ * the DMG or Downloads runs "translocated" from a random read-only path, and
+ * any app moves when the user reinstalls it elsewhere. Claude Code's config
+ * must not change when any of that happens, so it points at
+ * `/mcp/openpcb-mcp` — a launcher Electron main rewrites on every
+ * launch to exec whatever binary is current.
+ */
+
+export type LauncherPlatform = "darwin" | "win32" | "linux";
+
+export interface ExecResolution {
+ /** Binary that runs the bridge (the app itself, as Node via ELECTRON_RUN_AS_NODE). */
+ exec: string;
+ kind: "installed" | "appimage" | "portable" | "translocated";
+ /** Set when the launcher should warn the user (translocation). */
+ warning: string | null;
+}
+
+/**
+ * macOS Gatekeeper App Translocation (and running straight from the mounted
+ * DMG) hands an unsigned app a path that disappears when it quits.
+ */
+export function isEphemeralMacPath(path: string): boolean {
+ return path.includes("/AppTranslocation/") || path.startsWith("/Volumes/");
+}
+
+/**
+ * The binary the launcher should exec. `APPIMAGE` / `PORTABLE_EXECUTABLE_FILE`
+ * point at the file the user actually launched (stable); `execPath` inside
+ * them is a temp mount / extraction.
+ */
+export function resolveLauncherExec(input: {
+ platform: LauncherPlatform;
+ execPath: string;
+ env: Record;
+ /** exec recorded by a previous launch, reused when this one is ephemeral. */
+ previousExec?: string | null;
+}): ExecResolution {
+ if (input.platform === "linux" && input.env.APPIMAGE) {
+ return { exec: input.env.APPIMAGE, kind: "appimage", warning: null };
+ }
+ if (input.platform === "win32" && input.env.PORTABLE_EXECUTABLE_FILE) {
+ return {
+ exec: input.env.PORTABLE_EXECUTABLE_FILE,
+ kind: "portable",
+ warning: null,
+ };
+ }
+ if (input.platform === "darwin" && isEphemeralMacPath(input.execPath)) {
+ return {
+ exec: input.previousExec && !isEphemeralMacPath(input.previousExec)
+ ? input.previousExec
+ : input.execPath,
+ kind: "translocated",
+ warning:
+ "OpenPCB is running from a temporary location (opened from the disk image or Downloads). Move OpenPCB to the Applications folder and reopen it so Claude Code can keep finding it.",
+ };
+ }
+ return { exec: input.execPath, kind: "installed", warning: null };
+}
+
+export function launcherFileName(platform: LauncherPlatform): string {
+ return platform === "win32" ? "openpcb-mcp.cmd" : "openpcb-mcp";
+}
+
+function shQuote(value: string): string {
+ return `'${value.replace(/'/g, `'\\''`)}'`;
+}
+
+/** POSIX launcher: exec the app as Node on the bridge, falling back to a system node. */
+export function posixLauncher(input: { exec: string; shimPath: string }): string {
+ return [
+ "#!/bin/sh",
+ "# openpcb-mcp — stdio MCP bridge into the running OpenPCB app.",
+ "# Written by OpenPCB on every launch; do not edit. Point MCP clients here.",
+ "set -eu",
+ `exec_path=${shQuote(input.exec)}`,
+ `shim=${shQuote(input.shimPath)}`,
+ 'if [ ! -f "$shim" ]; then',
+ ' echo "openpcb-mcp: bridge missing at $shim — start OpenPCB once to reinstall it." >&2',
+ " exit 1",
+ "fi",
+ 'if [ -x "$exec_path" ]; then',
+ ' ELECTRON_RUN_AS_NODE=1 exec "$exec_path" "$shim" "$@"',
+ "fi",
+ "if command -v node >/dev/null 2>&1; then",
+ ' exec node "$shim" "$@"',
+ "fi",
+ 'echo "openpcb-mcp: OpenPCB is no longer at $exec_path and no system node was found. Start OpenPCB once so it can update this launcher." >&2',
+ "exit 1",
+ "",
+ ].join("\n");
+}
+
+function cmdQuote(value: string): string {
+ return `"${value.replace(/"/g, '""')}"`;
+}
+
+/**
+ * A path as a literal inside a .cmd file. Percent expansion runs before
+ * quotes are parsed, so `%` must be doubled even inside `set "…"`; other
+ * metacharacters (`&`, `^`, `(`, `)`) are safe inside the quotes.
+ */
+function batchLiteral(value: string): string {
+ return value.replace(/%/g, "%%");
+}
+
+/**
+ * Windows launcher. MCP clients on Windows are registered with the app's
+ * binary directly (`stdioServerConfig`), so cmd.exe is not in the MCP
+ * transport path; this script stays as a manual fallback. `chcp 65001` makes
+ * cmd read the rest of this UTF-8 file as UTF-8 (non-ASCII user names).
+ */
+export function windowsLauncher(input: { exec: string; shimPath: string }): string {
+ return [
+ "@echo off",
+ "chcp 65001 >nul 2>&1",
+ "rem openpcb-mcp - stdio MCP bridge into the running OpenPCB app.",
+ "rem Written by OpenPCB on every launch; do not edit.",
+ "setlocal",
+ `set "EXEC_PATH=${batchLiteral(input.exec)}"`,
+ `set "SHIM=${batchLiteral(input.shimPath)}"`,
+ 'if not exist "%SHIM%" (',
+ " echo openpcb-mcp: bridge missing - start OpenPCB once to reinstall it. 1>&2",
+ " exit /b 1",
+ ")",
+ 'if exist "%EXEC_PATH%" (',
+ ' set "ELECTRON_RUN_AS_NODE=1"',
+ ` ${cmdQuote("%EXEC_PATH%")} ${cmdQuote("%SHIM%")} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ "where node >nul 2>&1",
+ "if %ERRORLEVEL%==0 (",
+ ` node ${cmdQuote("%SHIM%")} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ 'echo openpcb-mcp: OpenPCB is no longer at "%EXEC_PATH%" and no system node was found. Start OpenPCB once. 1>&2',
+ "exit /b 1",
+ "",
+ ].join("\r\n");
+}
+
+export function launcherScript(
+ platform: LauncherPlatform,
+ input: { exec: string; shimPath: string },
+): string {
+ return platform === "win32" ? windowsLauncher(input) : posixLauncher(input);
+}
+
+export interface StdioServerConfig {
+ type: "stdio";
+ command: string;
+ args: string[];
+ env?: Record;
+}
+
+/** What a client can be pointed at: the launcher, and what it would exec. */
+export interface McpServerTarget {
+ launcherPath: string;
+ /** The app binary (resolved per launch; see resolveLauncherExec). */
+ exec: string;
+ /** The bridge bundle copied into the user-data dir. */
+ shimPath: string;
+}
+
+/**
+ * The stdio server entry an MCP client needs.
+ *
+ * - macOS / Linux: the stable POSIX launcher (it survives app moves and
+ * AppImage re-mounts, and falls back to a system `node`).
+ * - Windows: the app binary itself on the copied bridge, with
+ * ELECTRON_RUN_AS_NODE in the client's env. Node refuses to spawn a `.cmd`
+ * without a shell (CVE-2024-27980), and routing through `cmd /c` would put
+ * cmd.exe's quoting rules between every path and the client. The cost: the
+ * exe path is baked in, so if the app moves (portable build, reinstall
+ * elsewhere) OpenPCB detects the drift and Settings offers "Update".
+ */
+export function stdioServerConfig(
+ platform: LauncherPlatform,
+ target: McpServerTarget,
+): StdioServerConfig {
+ return platform === "win32"
+ ? {
+ type: "stdio",
+ command: target.exec,
+ args: [target.shimPath],
+ env: { ELECTRON_RUN_AS_NODE: "1" },
+ }
+ : { type: "stdio", command: target.launcherPath, args: [] };
+}
+
+export interface McpSnippet {
+ id: string;
+ label: string;
+ hint: string;
+ value: string;
+}
+
+function posixArg(value: string): string {
+ return /^[A-Za-z0-9_./:@%+=-]+$/.test(value) ? value : shQuote(value);
+}
+
+/** PowerShell literal: single quotes take everything verbatim ('' escapes '). */
+function powershellArg(value: string): string {
+ return /^[A-Za-z0-9_.:\\/@+=-]+$/.test(value) ? value : `'${value.replace(/'/g, "''")}'`;
+}
+
+/**
+ * Copy-paste setup for the Settings panel. `--scope user` registers the server
+ * for every project; the default `local` scope would tie it to whichever
+ * directory the user happened to run the command in. Windows snippets are for
+ * PowerShell (the default terminal), whose single-quoted strings take `&`,
+ * `%`, `^` and spaces literally.
+ */
+export function mcpSnippets(input: {
+ platform: LauncherPlatform;
+ target: McpServerTarget;
+ marketplaceDir: string | null;
+}): McpSnippet[] {
+ const config = stdioServerConfig(input.platform, input.target);
+ const windows = input.platform === "win32";
+ const quote = windows ? powershellArg : posixArg;
+ const terminal = windows ? "Run in PowerShell." : "Run in a terminal.";
+ const envFlags = Object.entries(config.env ?? {})
+ .map(([key, value]) => ` -e ${key}=${value}`)
+ .join("");
+ const command = [config.command, ...config.args].map(quote).join(" ");
+ const snippets: McpSnippet[] = [];
+ if (input.marketplaceDir) {
+ snippets.push({
+ id: "claude-code-plugin",
+ label: "Claude Code — plugin (recommended)",
+ hint: `Adds the OpenPCB tools plus workflow skills (/openpcb:… ). ${terminal}`,
+ value: [
+ `claude plugin marketplace add ${quote(input.marketplaceDir)}`,
+ "claude plugin install openpcb@openpcb-desktop --scope user",
+ ].join("\n"),
+ });
+ }
+ snippets.push({
+ id: "claude-code-server",
+ label: "Claude Code — MCP server only",
+ hint: `Registers just the tools, for every project. ${terminal}`,
+ value: `claude mcp add --scope user openpcb${envFlags} -- ${command}`,
+ });
+ snippets.push({
+ id: "claude-desktop",
+ label: "Claude Desktop",
+ hint: "Merge into claude_desktop_config.json, then restart Claude Desktop.",
+ value: JSON.stringify(
+ {
+ mcpServers: {
+ openpcb: {
+ command: config.command,
+ args: config.args,
+ ...(config.env ? { env: config.env } : {}),
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ });
+ return snippets;
+}
diff --git a/electron/src/main/mcp-launcher.ts b/electron/src/main/mcp-launcher.ts
new file mode 100644
index 00000000..9055da40
--- /dev/null
+++ b/electron/src/main/mcp-launcher.ts
@@ -0,0 +1,134 @@
+import { app } from "electron";
+import {
+ chmodSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ renameSync,
+ writeFileSync,
+} from "node:fs";
+import { join } from "node:path";
+import { log as electronLog } from "./logger.js";
+import {
+ launcherFileName,
+ launcherScript,
+ resolveLauncherExec,
+ type ExecResolution,
+ type LauncherPlatform,
+} from "./mcp-launcher-content.js";
+
+const log = electronLog.scope("mcp");
+
+/**
+ * Install the stable MCP launcher into `/mcp/` on every launch
+ * (see `mcp-launcher-content.ts` for why the bundle path cannot be used).
+ * Copies the bridge bundle next to it so an AppImage / portable build's temp
+ * mount is never referenced, and records what was installed in
+ * `launcher.json` so a translocated launch can keep the last good binary.
+ *
+ * Non-fatal: failure only costs the MCP launcher, never the app.
+ */
+
+export interface InstalledLauncher {
+ dir: string;
+ launcherPath: string;
+ shimPath: string;
+ exec: ExecResolution;
+}
+
+interface LauncherRecord {
+ exec: string;
+ kind: ExecResolution["kind"];
+ appVersion: string;
+ updatedAt: string;
+}
+
+let installed: InstalledLauncher | null = null;
+
+export function getInstalledMcpLauncher(): InstalledLauncher | null {
+ return installed;
+}
+
+function platform(): LauncherPlatform {
+ return process.platform === "win32"
+ ? "win32"
+ : process.platform === "darwin"
+ ? "darwin"
+ : "linux";
+}
+
+/** The bridge bundle this build ships: resources in a package, the tsup output in dev. */
+function bundledShimPath(): string | null {
+ const candidates = app.isPackaged
+ ? [join(process.resourcesPath, "mcp", "shim.js")]
+ : [join(app.getAppPath(), "dist", "mcp", "shim.js")];
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
+}
+
+function writeAtomic(path: string, contents: string, mode: number): void {
+ const tmp = `${path}.${process.pid}.tmp`;
+ writeFileSync(tmp, contents, { encoding: "utf8", mode });
+ renameSync(tmp, path);
+ // rename keeps the temp file's mode, but be explicit for pre-existing files
+ // on filesystems that ignore the create mode.
+ try {
+ chmodSync(path, mode);
+ } catch {
+ // Windows: modes are advisory.
+ }
+}
+
+function readRecord(dir: string): LauncherRecord | null {
+ try {
+ return JSON.parse(readFileSync(join(dir, "launcher.json"), "utf8")) as LauncherRecord;
+ } catch {
+ return null;
+ }
+}
+
+export function installMcpLauncher(appDataDir: string): InstalledLauncher | null {
+ const source = bundledShimPath();
+ if (!source) {
+ log.warn("MCP bridge bundle not found; launcher not installed (run `npm run build` in electron/).");
+ return null;
+ }
+ const dir = join(appDataDir, "mcp");
+ try {
+ mkdirSync(dir, { recursive: true });
+ const shimPath = join(dir, "shim.js");
+ const tmpShim = `${shimPath}.${process.pid}.tmp`;
+ copyFileSync(source, tmpShim);
+ renameSync(tmpShim, shimPath);
+
+ const exec = resolveLauncherExec({
+ platform: platform(),
+ execPath: process.execPath,
+ env: process.env,
+ previousExec: readRecord(dir)?.exec ?? null,
+ });
+ const launcherPath = join(dir, launcherFileName(platform()));
+ writeAtomic(launcherPath, launcherScript(platform(), { exec: exec.exec, shimPath }), 0o755);
+ writeAtomic(
+ join(dir, "launcher.json"),
+ JSON.stringify(
+ {
+ exec: exec.exec,
+ kind: exec.kind,
+ appVersion: app.getVersion(),
+ updatedAt: new Date().toISOString(),
+ } satisfies LauncherRecord,
+ null,
+ 2,
+ ),
+ 0o644,
+ );
+ if (exec.warning) log.warn(exec.warning);
+ log.info(`MCP launcher installed: ${launcherPath} → ${exec.exec} (${exec.kind})`);
+ installed = { dir, launcherPath, shimPath, exec };
+ return installed;
+ } catch (error) {
+ log.warn(`Failed to install the MCP launcher: ${String(error)}`);
+ return null;
+ }
+}
diff --git a/electron/src/main/mcp-portfile.ts b/electron/src/main/mcp-portfile.ts
index 017aa332..3974874e 100644
--- a/electron/src/main/mcp-portfile.ts
+++ b/electron/src/main/mcp-portfile.ts
@@ -1,6 +1,12 @@
import { app } from "electron";
import { randomBytes } from "node:crypto";
-import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import {
+ existsSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
import { join } from "node:path";
import { log as electronLog } from "./logger.js";
@@ -53,28 +59,35 @@ function processAlive(pid: number): boolean {
}
}
-/**
- * Remove a portfile left behind by a crashed run. Only ever deletes a file
- * whose recorded pid is gone — a live second instance is not ours to clear
- * (and `requestSingleInstanceLock` should have prevented one anyway).
- */
-export function clearStaleMcpPortfile(appDataDir: string): void {
- const filePath = portfilePath(appDataDir);
- if (!existsSync(filePath)) return;
+/** The pid recorded in an existing portfile, if a live process owns it. */
+function liveOwnerPid(filePath: string): number | null {
+ if (!existsSync(filePath)) return null;
try {
const parsed = JSON.parse(readFileSync(filePath, "utf8")) as
| Partial
| null;
const pid = parsed?.pid;
if (typeof pid === "number" && pid !== process.pid && processAlive(pid)) {
- log.warn(
- `Leaving MCP portfile owned by live pid ${pid}; not overwriting.`,
- );
- return;
+ return pid;
}
} catch {
// Unparseable file is stale by definition.
}
+ return null;
+}
+
+/**
+ * Remove a portfile left behind by a crashed run. Only ever deletes a file
+ * whose recorded pid is gone — a live second instance is not ours to clear
+ * (and `requestSingleInstanceLock` should have prevented one anyway).
+ */
+export function clearStaleMcpPortfile(appDataDir: string): void {
+ const filePath = portfilePath(appDataDir);
+ const owner = liveOwnerPid(filePath);
+ if (owner !== null) {
+ log.warn(`Leaving MCP portfile owned by live pid ${owner}; not overwriting.`);
+ return;
+ }
rmSync(filePath, { force: true });
}
@@ -92,11 +105,22 @@ export function writeMcpPortfile(input: {
appVersion: app.getVersion(),
};
const filePath = portfilePath(input.appDataDir);
+ const owner = liveOwnerPid(filePath);
+ if (owner !== null) {
+ // Another live instance serves MCP; overwriting its file would point its
+ // clients at us with a token they do not have.
+ log.warn(`MCP portfile belongs to live pid ${owner}; not writing ours.`);
+ return null;
+ }
try {
- writeFileSync(filePath, JSON.stringify(contents, null, 2), {
+ // Write-then-rename so a shim polling the file never reads it half
+ // written. The temp file is created 0600 and rename keeps the mode.
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
+ writeFileSync(tmpPath, JSON.stringify(contents, null, 2), {
encoding: "utf8",
mode: 0o600,
});
+ renameSync(tmpPath, filePath);
log.info(`MCP portfile written: ${filePath}`);
return contents;
} catch (error) {
diff --git a/electron/src/main/win-cmd.ts b/electron/src/main/win-cmd.ts
new file mode 100644
index 00000000..d5abc77c
--- /dev/null
+++ b/electron/src/main/win-cmd.ts
@@ -0,0 +1,68 @@
+/**
+ * Windows command-line helpers for running the user's `claude` CLI without
+ * trusting cmd.exe's parser more than necessary. Pure (no `electron`, no
+ * `child_process`) so Bun tests pin the behaviour down on any OS.
+ *
+ * Preferred path: an npm-installed `claude.cmd` is an npm "cmd-shim" that
+ * only runs `node \cli.js %*` (or an .exe). `cmdShimTarget` reads
+ * that target out of the shim so the CLI can be spawned directly — no cmd.exe
+ * at all, so no quoting rules to get wrong.
+ *
+ * Fallback (a .cmd we cannot parse): `cmd.exe /d /s /c ""` with
+ * `windowsVerbatimArguments`, escaped by the algorithm cross-spawn uses
+ * (https://github.com/moxystudio/node-cross-spawn, lib/util/escape.js, MIT;
+ * itself based on https://qntm.org/cmd): quote each argument for the C
+ * runtime, then caret-escape every cmd metacharacter — including `%`, `&`,
+ * `^`, `(`, `)` — and escape once more when the target re-parses `%*`
+ * (a cmd-shim does).
+ */
+
+const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
+
+/** Escape the program path for a cmd.exe command line. */
+export function escapeCmdCommand(command: string): string {
+ return command.replace(CMD_META, "^$1");
+}
+
+/** Escape one argument for `cmd.exe /d /s /c ""` (verbatim arguments). */
+export function escapeCmdArgument(argument: string, doubleEscapeMeta = false): string {
+ let arg = `${argument}`;
+ // Double the backslashes that precede a quote, and escape the quote.
+ arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
+ // Double trailing backslashes (they will precede the closing quote).
+ arg = arg.replace(/(?=(\\+?)?)\1$/, "$1$1");
+ arg = `"${arg}"`;
+ arg = arg.replace(CMD_META, "^$1");
+ if (doubleEscapeMeta) arg = arg.replace(CMD_META, "^$1");
+ return arg;
+}
+
+/** The full `/c` payload for running `command args…` through cmd.exe. */
+export function cmdCommandLine(command: string, args: readonly string[], doubleEscapeMeta: boolean): string {
+ return `"${[escapeCmdCommand(command), ...args.map((a) => escapeCmdArgument(a, doubleEscapeMeta))].join(" ")}"`;
+}
+
+/**
+ * The script or binary an npm cmd-shim runs, relative to the shim's folder
+ * (`node_modules\@anthropic-ai\claude-code\cli.js`), or null when the file is
+ * not a recognisable shim. Handles both shim generations (`%dp0%\` and
+ * `%~dp0\`).
+ */
+export function cmdShimTarget(content: string): { path: string; kind: "script" | "exe" } | null {
+ // The target is on the line that forwards the arguments (`%*`) — not the
+ // `IF EXIST "%dp0%\node.exe"` probe for a bundled node.
+ const line = content.split(/\r?\n/).find((l) => l.includes("%*"));
+ if (!line) return null;
+ const pattern = /"%(?:~dp0|dp0%)\\?([^"%]+?\.(c?js|mjs|exe))"/gi;
+ let target: { path: string; kind: "script" | "exe" } | null = null;
+ for (const match of line.matchAll(pattern)) {
+ if (/(^|\\)node\.exe$/i.test(match[1]!)) continue;
+ target = { path: match[1]!, kind: match[2]!.toLowerCase() === "exe" ? "exe" : "script" };
+ }
+ return target;
+}
+
+/** A cmd-shim forwards its arguments with `%*`, which cmd.exe parses again. */
+export function reparsesArguments(content: string | null): boolean {
+ return content === null || content.includes("%*");
+}
diff --git a/electron/src/mcp-shim/bridge.ts b/electron/src/mcp-shim/bridge.ts
new file mode 100644
index 00000000..9378f7fe
--- /dev/null
+++ b/electron/src/mcp-shim/bridge.ts
@@ -0,0 +1,373 @@
+/**
+ * The stdio half of the bridge: the MCP server Claude Code (or any stdio
+ * client) actually talks to.
+ *
+ * It is a 2025-era server toward the client, and deliberately more than a
+ * pipe, because the old pipe failed in every way that matters on a desktop:
+ *
+ * - **It works while OpenPCB is closed.** `initialize`, `ping` and the list
+ * methods are answered here, so the client registers the server instead of
+ * marking it failed. Tool calls then return a readable "start OpenPCB"
+ * result instead of hanging.
+ * - **It survives app restarts.** Every forwarded request is a stateless POST;
+ * on a connection failure or a 401 the portfile is re-read and the request
+ * retried once against the new port and token (`upstream.ts`).
+ * - **It keeps the client's tool list current.** The backend cannot push to a
+ * stateless 2025-era client, so the bridge polls the backend's state probe
+ * and emits `notifications/*\/list_changed` when OpenPCB starts, stops, or
+ * the user toggles MCP / writes.
+ * - **Cancellation works.** `notifications/cancelled` aborts the in-flight
+ * HTTP request, which aborts the tool's signal on the server.
+ */
+
+import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { cacheDir, type DiscoveryEnv } from "./portfile";
+import { Upstream, UpstreamError, type JsonRpcMessage, type McpState } from "./upstream";
+
+export const BRIDGE_VERSION = "2";
+
+/** 2025-era protocol revisions this bridge will negotiate, newest first. */
+export const LEGACY_PROTOCOL_VERSIONS = [
+ "2025-11-25",
+ "2025-06-18",
+ "2025-03-26",
+ "2024-11-05",
+];
+
+const OFFLINE_INSTRUCTIONS =
+ "OpenPCB is not running right now, so its design tools are unavailable. Ask the user to start the OpenPCB desktop app (and enable Settings → Assistant → MCP); the tool list refreshes automatically once it is up.";
+
+const TOOLS_CACHE_FILE = "mcp-tools-cache.json";
+
+export interface BridgeOptions {
+ discovery: DiscoveryEnv;
+ /** Sends a message to the client (stdout). */
+ send: (message: JsonRpcMessage) => void;
+ /** Stable per-process id; keys the design pin on the server. */
+ instanceId: string;
+ /** Overrides the client key derived from `clientInfo.name`. */
+ clientKeyOverride?: string;
+ pollMs?: number;
+ fetchImpl?: typeof fetch;
+ log?: (message: string) => void;
+}
+
+function slug(name: string): string {
+ return (
+ name
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "mcp-client"
+ );
+}
+
+function errorResponse(
+ id: JsonRpcMessage["id"],
+ code: number,
+ message: string,
+): JsonRpcMessage {
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
+}
+
+export class McpBridge {
+ readonly upstream: Upstream;
+ private clientName = "MCP client";
+ private clientKey: string;
+ private protocolVersion: string | null = null;
+ private initialized = false;
+ private readonly inFlight = new Map();
+ private pollTimer: ReturnType | null = null;
+ private lastSeen: { up: boolean; state: string | null; epoch: number } = {
+ up: false,
+ state: null,
+ epoch: 0,
+ };
+ private toolsCache: unknown | null = null;
+ private closed = false;
+
+ constructor(private readonly options: BridgeOptions) {
+ this.clientKey = options.clientKeyOverride ?? "mcp-client";
+ this.upstream = new Upstream({
+ discovery: options.discovery,
+ fetchImpl: options.fetchImpl,
+ log: options.log,
+ headers: () => ({
+ "x-openpcb-mcp-client": this.clientKey,
+ "x-openpcb-mcp-client-name": this.clientName,
+ "x-openpcb-mcp-instance": this.options.instanceId,
+ ...(this.protocolVersion
+ ? { "mcp-protocol-version": this.protocolVersion }
+ : {}),
+ }),
+ });
+ this.toolsCache = this.readToolsCache();
+ }
+
+ // ─── lifecycle ──────────────────────────────────────────────────────
+
+ start(): void {
+ if (this.pollTimer) return;
+ this.pollTimer = setInterval(() => {
+ void this.poll();
+ }, this.options.pollMs ?? 3_000);
+ // Never keep the process alive just for polling; stdin does that.
+ (this.pollTimer as { unref?: () => void }).unref?.();
+ }
+
+ close(): void {
+ this.closed = true;
+ if (this.pollTimer) clearInterval(this.pollTimer);
+ this.pollTimer = null;
+ for (const controller of this.inFlight.values()) controller.abort();
+ this.inFlight.clear();
+ }
+
+ /**
+ * One polling tick: tell the client to re-list when OpenPCB came up or went
+ * down, when the endpoint changed (a restart: new port, token or pid —
+ * even one the poll did not see happen), or when the backend's state
+ * fingerprint changed: enabled / writes, the hash of the full tool
+ * contracts, the app version, or the boot generation. A restart into an
+ * update that keeps every tool name but changes a schema is caught by all
+ * three of the last.
+ */
+ async poll(): Promise {
+ if (this.closed) return;
+ this.upstream.refresh();
+ const up = this.upstream.portfile !== null;
+ const epoch = this.upstream.epoch;
+ let state: McpState | null = null;
+ if (up) state = await this.upstream.state();
+ const fingerprint = state
+ ? [state.enabled, state.allowWrites, state.toolset, state.appVersion, state.generation ?? ""].join(":")
+ : null;
+ const changed =
+ up !== this.lastSeen.up ||
+ epoch !== this.lastSeen.epoch ||
+ (fingerprint !== null && fingerprint !== this.lastSeen.state);
+ this.lastSeen = { up, epoch, state: fingerprint ?? (up ? this.lastSeen.state : null) };
+ if (changed && this.initialized) this.notifyListsChanged();
+ }
+
+ private notifyListsChanged(): void {
+ for (const method of [
+ "notifications/tools/list_changed",
+ "notifications/prompts/list_changed",
+ "notifications/resources/list_changed",
+ ]) {
+ this.options.send({ jsonrpc: "2.0", method });
+ }
+ }
+
+ // ─── client → bridge ────────────────────────────────────────────────
+
+ async handleClientMessage(message: JsonRpcMessage): Promise {
+ if (message.method === undefined) return; // a response: we never ask the client anything
+ if (message.id === undefined || message.id === null) {
+ this.handleNotification(message);
+ return;
+ }
+ const response = await this.handleRequest(message);
+ if (response) this.options.send(response);
+ }
+
+ private handleNotification(message: JsonRpcMessage): void {
+ switch (message.method) {
+ case "notifications/initialized":
+ this.initialized = true;
+ return;
+ case "notifications/cancelled": {
+ const requestId = message.params?.requestId;
+ if (requestId !== undefined) {
+ this.inFlight.get(String(requestId))?.abort();
+ }
+ return;
+ }
+ default:
+ // roots/list_changed and friends: nothing on a stateless upstream
+ // could act on them.
+ return;
+ }
+ }
+
+ private async handleRequest(message: JsonRpcMessage): Promise {
+ switch (message.method) {
+ case "initialize":
+ return this.initialize(message);
+ case "ping":
+ return { jsonrpc: "2.0", id: message.id, result: {} };
+ case "server/discover":
+ // We are a 2025-era server: "method not found" is the definitive
+ // signal that makes a 2026-era client fall back to `initialize`.
+ return errorResponse(message.id, -32601, "Method not found: server/discover");
+ default:
+ return this.forward(message);
+ }
+ }
+
+ private async initialize(message: JsonRpcMessage): Promise {
+ const params = (message.params ?? {}) as {
+ protocolVersion?: string;
+ clientInfo?: { name?: string };
+ };
+ if (params.clientInfo?.name) {
+ this.clientName = params.clientInfo.name;
+ if (!this.options.clientKeyOverride) this.clientKey = slug(params.clientInfo.name);
+ }
+ const requested = params.protocolVersion ?? "";
+ this.protocolVersion = LEGACY_PROTOCOL_VERSIONS.includes(requested)
+ ? requested
+ : LEGACY_PROTOCOL_VERSIONS[0]!;
+
+ // Prefer the app's own answer (instructions, server version); fall back
+ // to a local one so the client registers the server either way.
+ try {
+ const upstream = await this.upstream.post(message);
+ const result = upstream?.result as
+ | { protocolVersion?: string; capabilities?: Record }
+ | undefined;
+ if (result) {
+ if (result.protocolVersion) this.protocolVersion = result.protocolVersion;
+ this.lastSeen = { ...this.lastSeen, up: true, epoch: this.upstream.epoch };
+ return {
+ jsonrpc: "2.0",
+ id: message.id,
+ result: {
+ ...result,
+ capabilities: {
+ ...(result.capabilities ?? {}),
+ tools: { listChanged: true },
+ prompts: { listChanged: true },
+ resources: { listChanged: true },
+ },
+ },
+ };
+ }
+ } catch (error) {
+ this.options.log?.(
+ `initialize answered locally: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ this.lastSeen = { up: false, state: null, epoch: this.upstream.epoch };
+ return {
+ jsonrpc: "2.0",
+ id: message.id,
+ result: {
+ protocolVersion: this.protocolVersion,
+ capabilities: {
+ tools: { listChanged: true },
+ prompts: { listChanged: true },
+ resources: { listChanged: true },
+ },
+ serverInfo: { name: "openpcb", version: `bridge-${BRIDGE_VERSION}` },
+ instructions: OFFLINE_INSTRUCTIONS,
+ },
+ };
+ }
+
+ private async forward(message: JsonRpcMessage): Promise {
+ const key = String(message.id);
+ const controller = new AbortController();
+ this.inFlight.set(key, controller);
+ try {
+ const response = await this.upstream.post(message, {
+ signal: controller.signal,
+ onNotification: (notification) => this.options.send(notification),
+ });
+ if (!response) {
+ return errorResponse(message.id, -32603, "OpenPCB returned no response.");
+ }
+ if (message.method === "tools/list" && response.result) {
+ this.writeToolsCache(response.result);
+ }
+ return { ...response, id: message.id };
+ } catch (error) {
+ return this.fallback(message, error);
+ } finally {
+ this.inFlight.delete(key);
+ }
+ }
+
+ /** What to answer when the app cannot. */
+ private fallback(message: JsonRpcMessage, error: unknown): JsonRpcMessage {
+ const text =
+ error instanceof UpstreamError
+ ? error.message
+ : `OpenPCB bridge error: ${error instanceof Error ? error.message : String(error)}`;
+ if (error instanceof UpstreamError && error.kind === "cancelled") {
+ return errorResponse(message.id, -32800, "Request cancelled.");
+ }
+ switch (message.method) {
+ case "tools/call":
+ // A tool result, not a protocol error: the model reads it and can
+ // tell the user what to do.
+ return {
+ jsonrpc: "2.0",
+ id: message.id,
+ result: {
+ content: [{ type: "text", text }],
+ structuredContent: {
+ ok: false,
+ status: "error",
+ summary: text,
+ warnings: [],
+ error: { message: text },
+ truncated: false,
+ data: null,
+ },
+ isError: true,
+ },
+ };
+ case "tools/list":
+ // Keep the last known tools visible so the model knows what OpenPCB
+ // offers; calling them explains that the app must be started.
+ return {
+ jsonrpc: "2.0",
+ id: message.id,
+ result: this.toolsCache ?? { tools: [] },
+ };
+ case "prompts/list":
+ return { jsonrpc: "2.0", id: message.id, result: { prompts: [] } };
+ case "resources/list":
+ return { jsonrpc: "2.0", id: message.id, result: { resources: [] } };
+ case "resources/templates/list":
+ return { jsonrpc: "2.0", id: message.id, result: { resourceTemplates: [] } };
+ default:
+ return errorResponse(message.id, -32000, text);
+ }
+ }
+
+ // ─── tools cache ────────────────────────────────────────────────────
+
+ private cachePath(): string | null {
+ const dir = cacheDir(this.options.discovery, this.upstream.portfilePath);
+ return dir ? join(dir, TOOLS_CACHE_FILE) : null;
+ }
+
+ private readToolsCache(): unknown | null {
+ const path = this.cachePath();
+ if (!path || !existsSync(path)) return null;
+ try {
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { tools?: unknown };
+ return Array.isArray(parsed.tools) ? parsed : null;
+ } catch {
+ return null;
+ }
+ }
+
+ private writeToolsCache(result: unknown): void {
+ this.toolsCache = result;
+ const path = this.cachePath();
+ if (!path) return;
+ try {
+ mkdirSync(join(path, ".."), { recursive: true });
+ const tmp = `${path}.${process.pid}.tmp`;
+ writeFileSync(tmp, JSON.stringify(result), { encoding: "utf8", mode: 0o600 });
+ renameSync(tmp, path);
+ } catch (error) {
+ this.options.log?.(`could not write tools cache: ${String(error)}`);
+ }
+ }
+}
diff --git a/electron/src/mcp-shim/index.ts b/electron/src/mcp-shim/index.ts
index 4bae22ca..6f8ec296 100644
--- a/electron/src/mcp-shim/index.ts
+++ b/electron/src/mcp-shim/index.ts
@@ -1,167 +1,72 @@
/**
- * `openpcb-mcp` — stdio ⇄ Streamable HTTP bridge.
+ * `openpcb-mcp` — stdio MCP bridge into the running OpenPCB app.
*
- * Claude Desktop (and any client that only speaks stdio) cannot reach the
- * Streamable HTTP endpoint the backend serves, so this process sits between
- * them. It is a dumb pipe at the transport level: whatever arrives on stdin is
- * forwarded to the HTTP transport and vice versa. Session semantics
- * (`Mcp-Session-Id`, resumption) are entirely the HTTP transport's business,
- * and stdio has none, so there is nothing to translate.
+ * Claude Code, Claude Desktop and other stdio clients spawn this process; it
+ * serves MCP on stdin/stdout and forwards to the Streamable HTTP endpoint the
+ * OpenPCB backend serves on an ephemeral loopback port (discovered through the
+ * 0600 portfile Electron main writes). The logic lives in `bridge.ts` /
+ * `upstream.ts` / `portfile.ts`, which have no Electron dependency; this file
+ * only wires them to the process.
*
- * The app must already be running — see `discoverPortfile`. There is no
- * headless fallback on purpose: two writers on one SQLite file is a worse
- * failure mode than a clear "start OpenPCB" message.
+ * Runs on the app's own Electron binary with ELECTRON_RUN_AS_NODE (see the
+ * launcher Electron main writes into the user-data dir), so no system Node is
+ * needed. There is no headless fallback on purpose: two writers on one SQLite
+ * file is a worse failure mode than "start OpenPCB".
*/
-import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
-import { existsSync, readFileSync } from "node:fs";
-import { homedir, platform } from "node:os";
-import { join } from "node:path";
+import { McpBridge } from "./bridge";
+import { resolveInstanceId } from "./instance";
+import { defaultDiscoveryEnv } from "./portfile";
+import type { JsonRpcMessage } from "./upstream";
-interface Portfile {
- version: number;
- url: string;
- port: number;
- token: string;
- pid: number;
- appVersion: string;
-}
-
-const SUPPORTED_PORTFILE_VERSION = 1;
-const PORTFILE_NAME = "mcp.json";
-const CLIENT_NAME = "openpcb-mcp-shim";
-
-/** Electron's `app.getPath("userData")` locations, reproduced without Electron. */
-function userDataDirs(): string[] {
- const home = homedir();
- const names = ["OpenPCB", "openpcb-electron"];
- switch (platform()) {
- case "darwin":
- return names.map((n) => join(home, "Library", "Application Support", n));
- case "win32": {
- const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
- return names.map((n) => join(appData, n));
- }
- default: {
- const config = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
- return names.map((n) => join(config, n));
- }
- }
-}
-
-/**
- * Candidate portfile paths, most likely first. The `dev` subdirectory is where
- * an unpackaged run puts its data (`backend-server.ts:getAppDataDir`), so a
- * developer running `npm run dev:electron` is found too.
- */
-function candidatePaths(): string[] {
- const override = process.env.OPENPCB_MCP_PORTFILE;
- if (override) return [override];
- return userDataDirs().flatMap((dir) => [
- join(dir, PORTFILE_NAME),
- join(dir, "dev", PORTFILE_NAME),
- ]);
-}
-
-function processAlive(pid: number): boolean {
- try {
- process.kill(pid, 0);
- return true;
- } catch (error) {
- // EPERM means the process exists but belongs to another user — alive.
- // Only ESRCH ("no such process") proves it is gone.
- return (error as NodeJS.ErrnoException).code === "EPERM";
- }
-}
-
-function fail(message: string): never {
+function log(message: string): void {
// stdout is the JSON-RPC channel — diagnostics must go to stderr or they
// corrupt the protocol stream.
process.stderr.write(`openpcb-mcp: ${message}\n`);
- process.exit(1);
-}
-
-function discoverPortfile(): Portfile {
- const checked: string[] = [];
- for (const path of candidatePaths()) {
- checked.push(path);
- if (!existsSync(path)) continue;
- let parsed: Portfile;
- try {
- parsed = JSON.parse(readFileSync(path, "utf8")) as Portfile;
- } catch {
- continue;
- }
- if (parsed.version !== SUPPORTED_PORTFILE_VERSION) {
- fail(
- `portfile at ${path} is version ${parsed.version}, this shim understands ${SUPPORTED_PORTFILE_VERSION}. Update OpenPCB or your MCP client config.`,
- );
- }
- // A file left by a crashed run points at a dead port; treating it as live
- // produces a confusing connection error instead of a useful one.
- if (!processAlive(parsed.pid)) continue;
- return parsed;
- }
- fail(
- `OpenPCB is not running (no live portfile found). Start OpenPCB, then retry.\nLooked in:\n ${checked.join("\n ")}`,
- );
}
async function main(): Promise {
- const portfile = discoverPortfile();
-
- const http = new StreamableHTTPClientTransport(new URL(portfile.url), {
- requestInit: {
- headers: {
- authorization: `Bearer ${portfile.token}`,
- // Identifies this client to the server, which uses it to pick the
- // backing assistant chat. Must be stable across requests.
- "x-openpcb-mcp-client": process.env.OPENPCB_MCP_CLIENT ?? CLIENT_NAME,
- },
+ const stdio = new StdioServerTransport();
+ const bridge = new McpBridge({
+ discovery: defaultDiscoveryEnv(),
+ instanceId: resolveInstanceId(process.env),
+ clientKeyOverride: process.env.OPENPCB_MCP_CLIENT?.trim() || undefined,
+ pollMs: Number(process.env.OPENPCB_MCP_POLL_MS) || undefined,
+ log,
+ send: (message) => {
+ void stdio.send(message as never).catch((error: unknown) => {
+ log(`stdout write failed: ${String(error)}`);
+ shutdown(1);
+ });
},
});
- const stdio = new StdioServerTransport();
let closing = false;
const shutdown = (code: number) => {
if (closing) return;
closing = true;
- void Promise.allSettled([http.close(), stdio.close()]).then(() => {
- process.exit(code);
- });
+ bridge.close();
+ void stdio.close().finally(() => process.exit(code));
};
- http.onmessage = (message) => {
- void stdio.send(message).catch((error: unknown) => {
- process.stderr.write(`openpcb-mcp: stdout write failed: ${String(error)}\n`);
- shutdown(1);
- });
- };
stdio.onmessage = (message) => {
- void http.send(message).catch((error: unknown) => {
- process.stderr.write(`openpcb-mcp: forward failed: ${String(error)}\n`);
- });
+ void bridge
+ .handleClientMessage(message as unknown as JsonRpcMessage)
+ .catch((error: unknown) => log(`message handling failed: ${String(error)}`));
};
-
- // A closed app (or a client that went away) ends the bridge; neither half is
- // useful alone.
- http.onclose = () => shutdown(0);
stdio.onclose = () => shutdown(0);
- http.onerror = (error) => {
- process.stderr.write(`openpcb-mcp: transport error: ${error.message}\n`);
- };
- stdio.onerror = (error) => {
- process.stderr.write(`openpcb-mcp: stdio error: ${error.message}\n`);
- };
+ stdio.onerror = (error) => log(`stdio error: ${error.message}`);
- await http.start();
await stdio.start();
-
+ // The client closing our stdin is how a stdio server is told to exit.
+ process.stdin.on("end", () => shutdown(0));
+ bridge.start();
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
}
main().catch((error: unknown) => {
- fail(error instanceof Error ? error.message : String(error));
+ log(error instanceof Error ? error.message : String(error));
+ process.exit(1);
});
diff --git a/electron/src/mcp-shim/instance.ts b/electron/src/mcp-shim/instance.ts
new file mode 100644
index 00000000..7524129d
--- /dev/null
+++ b/electron/src/mcp-shim/instance.ts
@@ -0,0 +1,27 @@
+import { createHash, randomUUID } from "node:crypto";
+
+/**
+ * The bridge's instance id — the "session" half of the MCP actor
+ * (`X-OpenPCB-MCP-Instance`). OpenPCB keys chats, design pins, proposal
+ * ownership, undo rights and idempotency on it, so it should name ONE agent
+ * session and stay the same for that session's lifetime.
+ *
+ * - `OPENPCB_MCP_INSTANCE`: explicit override (tests, custom clients).
+ * - `CLAUDE_CODE_SESSION_ID`: Claude Code sets it on every stdio MCP server it
+ * spawns. Keying on it keeps a session's ownership across `/mcp` reconnects
+ * and bridge restarts. It is hashed: OpenPCB only needs a stable opaque key,
+ * and the raw id has no business in its database.
+ * - Otherwise a random id per process (Claude Desktop, other clients).
+ */
+export function resolveInstanceId(
+ env: Record,
+ random: () => string = randomUUID,
+): string {
+ const explicit = env.OPENPCB_MCP_INSTANCE?.trim();
+ if (explicit && /^[A-Za-z0-9._:-]{1,128}$/.test(explicit)) return explicit;
+ const session = env.CLAUDE_CODE_SESSION_ID?.trim();
+ if (session) {
+ return `cc-${createHash("sha256").update(session).digest("hex").slice(0, 32)}`;
+ }
+ return random();
+}
diff --git a/electron/src/mcp-shim/portfile.ts b/electron/src/mcp-shim/portfile.ts
new file mode 100644
index 00000000..59caf20a
--- /dev/null
+++ b/electron/src/mcp-shim/portfile.ts
@@ -0,0 +1,140 @@
+/**
+ * Discovery of the running OpenPCB instance through its MCP portfile.
+ *
+ * Pure (no `electron` import) so the shim runs as plain Node and Bun tests can
+ * drive it. The portfile is written by Electron main after the backend is
+ * listening (`electron/src/main/mcp-portfile.ts`) and removed on quit.
+ */
+
+import { existsSync, readFileSync } from "node:fs";
+import { homedir, platform } from "node:os";
+import { dirname, join } from "node:path";
+
+export interface Portfile {
+ version: number;
+ url: string;
+ port: number;
+ token: string;
+ pid: number;
+ appVersion: string;
+}
+
+export const SUPPORTED_PORTFILE_VERSION = 1;
+export const PORTFILE_NAME = "mcp.json";
+
+export interface DiscoveryEnv {
+ env: Record;
+ platform: NodeJS.Platform;
+ homedir: string;
+ /** Whether a pid is a live process. Injected for tests. */
+ processAlive(pid: number): boolean;
+ readFile(path: string): string | null;
+}
+
+export function defaultDiscoveryEnv(): DiscoveryEnv {
+ return {
+ env: process.env,
+ platform: platform(),
+ homedir: homedir(),
+ processAlive,
+ readFile: (path) => (existsSync(path) ? readFileSync(path, "utf8") : null),
+ };
+}
+
+export function processAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ // EPERM means the process exists but belongs to another user — alive.
+ // Only ESRCH ("no such process") proves it is gone.
+ return (error as NodeJS.ErrnoException).code === "EPERM";
+ }
+}
+
+/** Electron's `app.getPath("userData")` locations, reproduced without Electron. */
+export function userDataDirs(env: DiscoveryEnv): string[] {
+ // "OpenPCB" is the packaged productName; "openpcb-electron" is the package
+ // name an unpackaged `npm run dev:electron` falls back to.
+ const names = ["OpenPCB", "openpcb-electron"];
+ switch (env.platform) {
+ case "darwin":
+ return names.map((n) =>
+ join(env.homedir, "Library", "Application Support", n),
+ );
+ case "win32": {
+ const appData = env.env.APPDATA ?? join(env.homedir, "AppData", "Roaming");
+ return names.map((n) => join(appData, n));
+ }
+ default: {
+ const config = env.env.XDG_CONFIG_HOME ?? join(env.homedir, ".config");
+ return names.map((n) => join(config, n));
+ }
+ }
+}
+
+/**
+ * Candidate portfile paths, most likely first. The `dev` subdirectory is where
+ * an unpackaged run puts its data (`backend-server.ts:getAppDataDir`).
+ */
+export function candidatePaths(env: DiscoveryEnv): string[] {
+ const override = env.env.OPENPCB_MCP_PORTFILE;
+ if (override) return [override];
+ return userDataDirs(env).flatMap((dir) => [
+ join(dir, PORTFILE_NAME),
+ join(dir, "dev", PORTFILE_NAME),
+ ]);
+}
+
+export type DiscoveryResult =
+ | { ok: true; portfile: Portfile; path: string }
+ | { ok: false; reason: "not-running" | "incompatible"; message: string; checked: string[] };
+
+/** Find the portfile of a live OpenPCB instance. Never throws. */
+export function discoverPortfile(env: DiscoveryEnv): DiscoveryResult {
+ const checked: string[] = [];
+ let incompatible: string | null = null;
+ for (const path of candidatePaths(env)) {
+ checked.push(path);
+ const raw = env.readFile(path);
+ if (raw === null) continue;
+ let parsed: Partial;
+ try {
+ parsed = JSON.parse(raw) as Partial;
+ } catch {
+ continue;
+ }
+ if (parsed.version !== SUPPORTED_PORTFILE_VERSION) {
+ incompatible = `The OpenPCB portfile at ${path} is version ${String(parsed.version)}; this bridge understands ${SUPPORTED_PORTFILE_VERSION}. Update OpenPCB so the bridge and the app match.`;
+ continue;
+ }
+ if (
+ typeof parsed.url !== "string" ||
+ typeof parsed.token !== "string" ||
+ typeof parsed.pid !== "number"
+ ) {
+ continue;
+ }
+ // A file left by a crashed run points at a dead port; treating it as live
+ // produces a confusing connection error instead of a useful one.
+ if (!env.processAlive(parsed.pid)) continue;
+ return { ok: true, portfile: parsed as Portfile, path };
+ }
+ if (incompatible) {
+ return { ok: false, reason: "incompatible", message: incompatible, checked };
+ }
+ return {
+ ok: false,
+ reason: "not-running",
+ message:
+ "OpenPCB is not running. Ask the user to start the OpenPCB app (and enable Settings → Assistant → MCP), then retry.",
+ checked,
+ };
+}
+
+/** Directory the bridge may keep its cache in (next to the portfile). */
+export function cacheDir(env: DiscoveryEnv, found?: string | null): string | null {
+ if (found) return dirname(found);
+ const first = candidatePaths(env)[0];
+ return first ? dirname(first) : null;
+}
diff --git a/electron/src/mcp-shim/upstream.ts b/electron/src/mcp-shim/upstream.ts
new file mode 100644
index 00000000..02cf945e
--- /dev/null
+++ b/electron/src/mcp-shim/upstream.ts
@@ -0,0 +1,333 @@
+/**
+ * The HTTP half of the bridge: forwards one JSON-RPC message to the OpenPCB
+ * backend's Streamable HTTP endpoint and turns every failure into a typed
+ * error with a message the model can act on.
+ *
+ * The backend serves 2025-era clients statelessly (a fresh server per POST),
+ * so there is no session to open or resume — each forwarded message is one
+ * self-contained POST. That is what makes an app restart survivable: the next
+ * POST just goes to the new port with the new token.
+ */
+
+import { createParser, type EventSourceMessage } from "eventsource-parser";
+import {
+ discoverPortfile,
+ type DiscoveryEnv,
+ type DiscoveryResult,
+ type Portfile,
+} from "./portfile";
+
+export type JsonRpcMessage = {
+ jsonrpc: "2.0";
+ id?: string | number | null;
+ method?: string;
+ params?: Record;
+ result?: unknown;
+ error?: { code: number; message: string; data?: unknown };
+};
+
+export type UpstreamErrorKind =
+ | "not-running"
+ | "incompatible"
+ | "disabled"
+ | "unauthorized"
+ | "not-available"
+ | "cancelled"
+ | "protocol";
+
+export class UpstreamError extends Error {
+ constructor(
+ readonly kind: UpstreamErrorKind,
+ message: string,
+ ) {
+ super(message);
+ this.name = "UpstreamError";
+ }
+}
+
+export interface McpState {
+ enabled: boolean;
+ allowWrites: boolean;
+ /** Hash of the full tool contracts (names, schemas, descriptions, annotations). */
+ toolset: string;
+ appVersion: string;
+ /** New on every backend boot; absent from older apps. */
+ generation?: string;
+}
+
+export interface UpstreamOptions {
+ discovery: DiscoveryEnv;
+ /** Identity + protocol headers for every POST. */
+ headers: () => Record;
+ fetchImpl?: typeof fetch;
+ log?: (message: string) => void;
+}
+
+function stateUrl(mcpUrl: string): string {
+ return mcpUrl.replace(/\/mcp\/?$/, "/mcp-state");
+}
+
+async function errorMessageOf(response: Response): Promise {
+ try {
+ const text = await response.text();
+ try {
+ const parsed = JSON.parse(text) as {
+ error?: { message?: string } | string;
+ };
+ if (typeof parsed.error === "string") return parsed.error;
+ if (parsed.error?.message) return parsed.error.message;
+ } catch {
+ // not JSON
+ }
+ return text.trim().slice(0, 300) || null;
+ } catch {
+ return null;
+ }
+}
+
+function sameEndpoint(a: Portfile | null, b: Portfile | null): boolean {
+ return Boolean(a && b && a.url === b.url && a.token === b.token && a.pid === b.pid);
+}
+
+export class Upstream {
+ private current: Portfile | null = null;
+ private currentPath: string | null = null;
+ private lastDiscovery: DiscoveryResult | null = null;
+ private readonly fetchImpl: typeof fetch;
+ private endpointEpoch = 0;
+
+ constructor(private readonly options: UpstreamOptions) {
+ this.fetchImpl = options.fetchImpl ?? fetch;
+ }
+
+ get portfile(): Portfile | null {
+ return this.current;
+ }
+
+ get portfilePath(): string | null {
+ return this.currentPath;
+ }
+
+ /**
+ * Bumped whenever a refresh finds a different endpoint (url, token or pid),
+ * wherever the refresh happened — a POST retry included — so the bridge
+ * notices a restart it did not poll through.
+ */
+ get epoch(): number {
+ return this.endpointEpoch;
+ }
+
+ /** Re-read the portfile. Returns whether the endpoint changed. */
+ refresh(): boolean {
+ const result = discoverPortfile(this.options.discovery);
+ this.lastDiscovery = result;
+ const next = result.ok ? result.portfile : null;
+ const changed = !sameEndpoint(this.current, next) && !(this.current === null && next === null);
+ if (changed) this.endpointEpoch += 1;
+ this.current = next;
+ this.currentPath = result.ok ? result.path : null;
+ return changed;
+ }
+
+ private unavailable(): UpstreamError {
+ const discovery = this.lastDiscovery;
+ if (discovery && !discovery.ok) {
+ return new UpstreamError(discovery.reason, discovery.message);
+ }
+ return new UpstreamError(
+ "not-running",
+ "OpenPCB is not running. Ask the user to start the OpenPCB app, then retry.",
+ );
+ }
+
+ private headersFor(portfile: Portfile): Record {
+ return {
+ "content-type": "application/json",
+ accept: "application/json, text/event-stream",
+ ...this.options.headers(),
+ authorization: `Bearer ${portfile.token}`,
+ };
+ }
+
+ /**
+ * POST one message. Resolves with the JSON-RPC response for a request, or
+ * null for a notification (202). Notifications the server streams before
+ * the response (progress) are handed to `onNotification` as they arrive.
+ */
+ async post(
+ message: JsonRpcMessage,
+ options: {
+ signal?: AbortSignal;
+ onNotification?: (notification: JsonRpcMessage) => void;
+ } = {},
+ ): Promise {
+ if (!this.current) this.refresh();
+ if (!this.current) throw this.unavailable();
+
+ let retried = false;
+ for (;;) {
+ const portfile: Portfile = this.current;
+ let response: Response;
+ try {
+ response = await this.fetchImpl(portfile.url, {
+ method: "POST",
+ headers: this.headersFor(portfile),
+ body: JSON.stringify(message),
+ signal: options.signal,
+ });
+ } catch (error) {
+ if (options.signal?.aborted) {
+ throw new UpstreamError("cancelled", "Request cancelled.");
+ }
+ // The app quit or restarted on a new port: look again, retry once.
+ const changed = this.refresh();
+ if (!retried && changed && this.current) {
+ retried = true;
+ continue;
+ }
+ this.options.log?.(`upstream unreachable: ${String(error)}`);
+ throw this.unavailable();
+ }
+
+ if (response.status === 401) {
+ // A new launch rotates the token; the portfile has the new one.
+ const changed = this.refresh();
+ if (!retried && changed && this.current) {
+ retried = true;
+ continue;
+ }
+ throw new UpstreamError(
+ "unauthorized",
+ (await errorMessageOf(response)) ??
+ "OpenPCB rejected the MCP token. Restart the bridge (/mcp → reconnect).",
+ );
+ }
+ if (response.status === 503) {
+ throw new UpstreamError(
+ "disabled",
+ (await errorMessageOf(response)) ??
+ "OpenPCB's MCP server is disabled. Ask the user to enable it in OpenPCB Settings → Assistant → MCP.",
+ );
+ }
+ if (response.status === 404) {
+ throw new UpstreamError(
+ "not-available",
+ "This OpenPCB build does not serve MCP. Ask the user to update OpenPCB.",
+ );
+ }
+ if (response.status === 202) return null;
+ if (!response.ok) {
+ throw new UpstreamError(
+ "protocol",
+ `OpenPCB answered HTTP ${response.status}: ${(await errorMessageOf(response)) ?? "no body"}`,
+ );
+ }
+
+ const contentType = response.headers.get("content-type") ?? "";
+ if (contentType.includes("text/event-stream")) {
+ return this.readSse(response, message.id ?? null, options.onNotification);
+ }
+ const text = await response.text();
+ if (!text.trim()) return null;
+ try {
+ return JSON.parse(text) as JsonRpcMessage;
+ } catch {
+ throw new UpstreamError(
+ "protocol",
+ `OpenPCB sent a response that is not JSON: ${text.trim().slice(0, 120)}`,
+ );
+ }
+ }
+ }
+
+ private async readSse(
+ response: Response,
+ requestId: JsonRpcMessage["id"],
+ onNotification?: (notification: JsonRpcMessage) => void,
+ ): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) return null;
+ return readJsonRpcSse(
+ {
+ read: () => reader.read(),
+ cancel: () => void reader.cancel().catch(() => undefined),
+ },
+ requestId,
+ onNotification,
+ (message) => this.options.log?.(message),
+ );
+ }
+
+ /**
+ * Poll the backend's state probe. Returns null when the app is unreachable
+ * or predates the probe (404) — callers then fall back to up/down tracking.
+ */
+ async state(): Promise {
+ if (!this.current) this.refresh();
+ const portfile = this.current;
+ if (!portfile) return null;
+ try {
+ const response = await this.fetchImpl(stateUrl(portfile.url), {
+ headers: { authorization: `Bearer ${portfile.token}` },
+ });
+ if (!response.ok) return null;
+ return (await response.json()) as McpState;
+ } catch {
+ return null;
+ }
+ }
+}
+
+/**
+ * Read a Streamable HTTP SSE response until the JSON-RPC response for
+ * `requestId` arrives, handing notifications (progress) to `onNotification`
+ * on the way. Framing is `eventsource-parser`'s (the SSE spec: CR, LF and CRLF
+ * line ends — also when split across chunks — multi-line `data:`, comments),
+ * not a hand-rolled splitter; the decoder is flushed at the end so a UTF-8
+ * character split across the last chunk survives, and a final event without
+ * its blank line is still dispatched. A malformed event is logged and
+ * skipped; the stream goes on. Exported for fuzz tests.
+ */
+export async function readJsonRpcSse(
+ source: {
+ read: () => Promise<{ value?: Uint8Array; done: boolean }>;
+ cancel: () => void;
+ },
+ requestId: JsonRpcMessage["id"],
+ onNotification?: (notification: JsonRpcMessage) => void,
+ log?: (message: string) => void,
+): Promise {
+ const decoder = new TextDecoder();
+ let final: JsonRpcMessage | null = null;
+ const parser = createParser({
+ onEvent(event: EventSourceMessage) {
+ if (final || !event.data) return;
+ let parsed: JsonRpcMessage;
+ try {
+ parsed = JSON.parse(event.data) as JsonRpcMessage;
+ } catch {
+ log?.(`skipped a malformed SSE event (${event.data.length} chars)`);
+ return;
+ }
+ if ("result" in parsed || "error" in parsed) {
+ if (requestId === null || parsed.id === requestId) final = parsed;
+ return;
+ }
+ if (parsed.method && parsed.id === undefined) onNotification?.(parsed);
+ // Server→client requests (sampling, elicitation) cannot be answered on
+ // a stateless endpoint; OpenPCB never sends them.
+ },
+ });
+ for (;;) {
+ const { value, done } = await source.read();
+ if (value) parser.feed(decoder.decode(value, { stream: true }));
+ if (final) {
+ source.cancel();
+ return final;
+ }
+ if (done) break;
+ }
+ // Flush a trailing partial character and a last event missing its blank line.
+ parser.feed(`${decoder.decode()}\n\n`);
+ return final;
+}
diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts
index 7268869d..ecc1660e 100644
--- a/electron/src/preload/index.ts
+++ b/electron/src/preload/index.ts
@@ -31,9 +31,13 @@ interface AppVersions {
}
interface McpConfig {
- /** Absolute path to the bundled stdio launcher; null when unpackaged. */
- shimPath: string | null;
- shimAvailable: boolean;
+ /** Stable launcher in the user-data dir; null if it could not be installed. */
+ launcherPath: string | null;
+ /** Set when the app runs from a temporary location (macOS translocation). */
+ launcherWarning: string | null;
+ /** Local Claude Code plugin marketplace; null if not written. */
+ marketplaceDir: string | null;
+ snippets: Array<{ id: string; label: string; hint: string; value: string }>;
portfilePath: string;
/** Streamable HTTP endpoint; null until the backend is listening. */
url: string | null;
@@ -67,6 +71,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
getMcpConfig: (): Promise => {
return ipcRenderer.invoke("mcp:config");
},
+ claudeCode: {
+ status: (): Promise => ipcRenderer.invoke("mcp:claude-code:status"),
+ connect: (mode: "plugin" | "server"): Promise =>
+ ipcRenderer.invoke("mcp:claude-code:connect", mode),
+ disconnect: (): Promise =>
+ ipcRenderer.invoke("mcp:claude-code:disconnect"),
+ },
openUserDataFolder: (): Promise<{ dir: string; error: string | null }> => {
return ipcRenderer.invoke("diagnostics:open-user-data");
},
diff --git a/package-lock.json b/package-lock.json
index ff0ab503..7297ac21 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"@openpcb/step-to-glb": "github:OpenPCB-app/shared#step-to-glb-v0.1.3",
"drizzle-orm": "^0.45.1",
"dxf-parser": "^1.1.2",
+ "eventsource-parser": "^3.1.1",
"zod": "^4.1.13"
},
"devDependencies": {
@@ -9895,9 +9896,9 @@
}
},
"node_modules/eventsource-parser": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
- "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
+ "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
diff --git a/package.json b/package.json
index 6da99fd5..2665d6d6 100644
--- a/package.json
+++ b/package.json
@@ -86,6 +86,7 @@
"@openpcb/step-to-glb": "github:OpenPCB-app/shared#step-to-glb-v0.1.3",
"drizzle-orm": "^0.45.1",
"dxf-parser": "^1.1.2",
+ "eventsource-parser": "^3.1.1",
"zod": "^4.1.13"
},
"overrides": {
diff --git a/src/core/backend/tests/assistant-action-id-dedup.test.ts b/src/core/backend/tests/assistant-action-id-dedup.test.ts
index efd92598..bf7b51aa 100644
--- a/src/core/backend/tests/assistant-action-id-dedup.test.ts
+++ b/src/core/backend/tests/assistant-action-id-dedup.test.ts
@@ -1,37 +1,27 @@
import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
import { ConversationStore } from "../../../modules/assistant/backend/conversation-store";
-// F6: the UNIQUE(design_id, action_id) index + createWriteProposal's
-// catch→return-existing makes a duplicate (designId, action_id) a no-op even
-// under a concurrent submit, so a duplicate write can't create a second
-// proposal. This exercises the real ConversationStore over an in-memory SQLite
-// with just the assistant_write_proposal table (cols from 0003+0007+0010).
+// The idempotency index (design_id, idempotency_scope, action_id) +
+// createOrGetWriteProposal's catch→return-existing makes a duplicate action a
+// no-op even under a concurrent submit. Exercises the real ConversationStore
+// over an in-memory SQLite built from the REAL assistant migrations, so the
+// schema here can never drift from production.
+const MIGRATIONS_DIR = path.resolve(
+ import.meta.dir,
+ "../../../modules/assistant/backend/migrations",
+);
+
function makeStore(): { store: ConversationStore; db: Database } {
const db = new Database(":memory:");
- db.run(`CREATE TABLE assistant_write_proposal (
- id TEXT PRIMARY KEY,
- chat_id TEXT NOT NULL,
- tool_event_id TEXT,
- kind TEXT NOT NULL DEFAULT 'generic',
- status TEXT NOT NULL DEFAULT 'pending',
- design_id TEXT NOT NULL,
- base_revision INTEGER,
- proposal_json TEXT,
- apply_result_json TEXT,
- tool_name TEXT, title TEXT, summary TEXT, risk_level TEXT,
- operations_json TEXT, sources_json TEXT, warnings_json TEXT, envelope_json TEXT,
- action_id TEXT,
- origin TEXT NOT NULL DEFAULT 'local',
- cloud_run_id TEXT, cloud_proposal_id TEXT,
- created_at TEXT NOT NULL, updated_at TEXT NOT NULL
- )`);
- db.run(
- `CREATE UNIQUE INDEX idx_action ON assistant_write_proposal(design_id, action_id) WHERE action_id IS NOT NULL`,
- );
- db.run(
- `CREATE UNIQUE INDEX idx_cloud ON assistant_write_proposal(design_id, cloud_proposal_id) WHERE cloud_proposal_id IS NOT NULL`,
- );
+ for (const file of readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql")).sort()) {
+ const sql = readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
+ for (const statement of sql.split("--> statement-breakpoint")) {
+ if (statement.trim()) db.run(statement);
+ }
+ }
const ctx = {
db: {
rawSql: (q: string, p: unknown[] = []) =>
@@ -41,26 +31,35 @@ function makeStore(): { store: ConversationStore; db: Database } {
return { store: new ConversationStore(ctx as never), db };
}
-describe("F6 action_id dedup (DB backstop)", () => {
- test("a second proposal with the same (designId, action_id) returns the first — no duplicate row", () => {
- const { store, db } = makeStore();
- const input = {
- chatId: "c1",
- designId: "d1",
- baseRevision: 0,
- kind: "designer_schematic_wires",
- proposal: {},
- envelope: { actionId: "wire_U1.OUT__R1.1_d1" },
- };
+function count(db: Database): number {
+ const rows = db
+ .query("SELECT COUNT(*) AS n FROM assistant_write_proposal")
+ .all() as Array<{ n: number }>;
+ return rows[0]!.n;
+}
- const first = store.createWriteProposal(input as never);
- const second = store.createWriteProposal(input as never);
+const ACTOR_A = { type: "mcp" as const, clientKey: "claude-code", instanceId: "a" };
+const ACTOR_B = { type: "mcp" as const, clientKey: "claude-code", instanceId: "b" };
- expect(second.id).toBe(first.id); // deduped to the existing proposal
- const rows = db
- .query("SELECT COUNT(*) AS n FROM assistant_write_proposal")
- .all() as Array<{ n: number }>;
- expect(rows[0]!.n).toBe(1); // the duplicate insert was rejected
+describe("action_id idempotency (DB backstop)", () => {
+ const wire = (chatId: string, actor: typeof ACTOR_A | null = null) => ({
+ chatId,
+ designId: "d1",
+ baseRevision: 0,
+ kind: "designer_schematic_wires",
+ proposal: {},
+ envelope: { actionId: "wire_U1.OUT__R1.1_d1" },
+ actor,
+ });
+
+ test("the same action in the same chat returns the first proposal — no duplicate row", () => {
+ const { store, db } = makeStore();
+ const first = store.createOrGetWriteProposal(wire("c1") as never);
+ const second = store.createOrGetWriteProposal(wire("c1") as never);
+ expect(first.created).toBe(true);
+ expect(second.created).toBe(false);
+ expect(second.record.id).toBe(first.record.id);
+ expect(count(db)).toBe(1);
});
test("proposals without an action_id are never deduped", () => {
@@ -76,9 +75,24 @@ describe("F6 action_id dedup (DB backstop)", () => {
const a = store.createWriteProposal(base as never);
const b = store.createWriteProposal(base as never);
expect(b.id).not.toBe(a.id);
- const rows = db
- .query("SELECT COUNT(*) AS n FROM assistant_write_proposal")
- .all() as Array<{ n: number }>;
- expect(rows[0]!.n).toBe(2);
+ expect(count(db)).toBe(2);
+ });
+
+ test("in-app, the same deterministic id in another chat is a different action", () => {
+ const { store, db } = makeStore();
+ expect(store.createOrGetWriteProposal(wire("c1") as never).created).toBe(true);
+ expect(store.createOrGetWriteProposal(wire("c2") as never).created).toBe(true);
+ expect(count(db)).toBe(2);
+ });
+
+ test("an MCP session dedupes across its chats; another session does not collide", () => {
+ const { store, db } = makeStore();
+ const first = store.createOrGetWriteProposal(wire("home-a", ACTOR_A) as never);
+ const retry = store.createOrGetWriteProposal(wire("design-a", ACTOR_A) as never);
+ expect(retry.created).toBe(false);
+ expect(retry.record.id).toBe(first.record.id);
+ expect(store.createOrGetWriteProposal(wire("design-b", ACTOR_B) as never).created).toBe(true);
+ expect(count(db)).toBe(2);
+ expect(first.record.actor).toEqual(ACTOR_A);
});
});
diff --git a/src/core/backend/tests/assistant-mcp-endpoint.test.ts b/src/core/backend/tests/assistant-mcp-endpoint.test.ts
index dd6aed1c..513d0519 100644
--- a/src/core/backend/tests/assistant-mcp-endpoint.test.ts
+++ b/src/core/backend/tests/assistant-mcp-endpoint.test.ts
@@ -170,8 +170,8 @@ async function readRpc(response: Response): Promise> {
beforeEach(() => {
process.env.OPENPCB_MCP_TOKEN = TOKEN;
- // The route is gated on the mcp.server dev flag; NODE_ENV is not
- // "production" under bun test, so it is on. Assert rather than assume.
+ // The route is gated on the mcp.server flag (availability "all" since the
+ // Claude Code hardening); make sure no stray override turns it off here.
delete process.env.OPENPCB_FEATURE_MCP_SERVER;
});
@@ -369,7 +369,8 @@ describe("assistant MCP endpoint", () => {
const prompts = (
promptsBody.result as { prompts: Array<{ name: string }> }
).prompts.map((p) => p.name);
- expect(prompts).toContain("openpcb-build-circuit");
+ // The build workflow needs write tools, so it is only offered with writes on.
+ expect(prompts).not.toContain("openpcb-build-circuit");
expect(prompts).toContain("openpcb-review-schematic");
expect(prompts).toContain("openpcb-drc-triage");
expect(prompts).toContain("openpcb-bom-check");
@@ -386,7 +387,26 @@ describe("assistant MCP endpoint", () => {
const mcpChats = service.conversation
.listChats()
.filter((chat) => Boolean((chat.metadata as { mcp?: unknown })?.mcp));
+ // No instance header: the client key doubles as the session, so a
+ // reconnect lands in the same home chat.
expect(mcpChats).toHaveLength(1);
- expect(mcpChats[0]?.title).toBe("MCP · Test Client");
+ expect(mcpChats[0]?.title).toMatch(/^MCP · Test Client · \d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
+ });
+});
+
+describe("release availability", () => {
+ test("the MCP route ships in production builds", async () => {
+ const { FEATURE_FLAGS } = await import("../../contracts/feature-flags/registry");
+ // Graduated so installed apps can use Claude Code; the user settings
+ // (both default off) stay the real gate.
+ expect(FEATURE_FLAGS["mcp.server"].availability).toBe("all");
+ });
+
+ test("a fresh install has MCP off and writes off", async () => {
+ const { bootMcpHarness } = await import("./helpers/mcp-harness");
+ await bootMcpHarness("assistant-mcp-fresh-install");
+ const settings = getAssistantService().getSettings();
+ expect(settings.mcpEnabled).toBe(false);
+ expect(settings.mcpAllowWrites).toBe(false);
});
});
diff --git a/src/core/backend/tests/assistant-mcp-parity.test.ts b/src/core/backend/tests/assistant-mcp-parity.test.ts
new file mode 100644
index 00000000..d05d06e8
--- /dev/null
+++ b/src/core/backend/tests/assistant-mcp-parity.test.ts
@@ -0,0 +1,102 @@
+/**
+ * Parity with the in-app assistant for MCP clients: Definition-of-Done build
+ * verification and knowledge (Docs) pages.
+ */
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import { bootMcpHarness, type McpHarness } from "./helpers/mcp-harness";
+
+let h: McpHarness;
+
+beforeAll(async () => {
+ h = await bootMcpHarness("assistant-mcp-parity");
+});
+
+interface DodData {
+ status: string;
+ failing: string[];
+ checks: Array<{ id: string; passed: boolean; message: string }>;
+}
+
+describe("designer_verify_build", () => {
+ test("checks the build against the BOM this session resolved", async () => {
+ h.enable({ writes: true });
+ const bom = await h.callTool("library_resolve_bom", {
+ goal: "One pull-up resistor",
+ items: [{ role: "pull-up resistor", query: "resistor", quantity: 1 }],
+ });
+ expect(bom.structuredContent.ok).toBe(true);
+
+ const created = await h.callTool("designer_create_design", { name: "Verify me" });
+ const designId = (created.structuredContent.data as { design: { id: string } }).design.id;
+
+ const before = await h.callTool("designer_verify_build", { designId });
+ const beforeData = before.structuredContent.data as DodData;
+ expect(beforeData.failing).toContain("bom_placed");
+ expect(before.structuredContent.summary).toContain("bom_placed");
+
+ const componentId = (
+ bom.structuredContent.data as {
+ items: Array<{ selected?: { componentId: string } }>;
+ }
+ ).items[0]?.selected?.componentId;
+ expect(componentId).toBeTruthy();
+ await h.callTool("designer_place_components", {
+ designId,
+ components: [{ componentId, quantity: 1 }],
+ });
+
+ const after = await h.callTool("designer_verify_build", { designId });
+ const afterData = after.structuredContent.data as DodData;
+ expect(afterData.failing).not.toContain("bom_placed");
+ });
+
+ test("without a resolved BOM it still runs ERC", async () => {
+ h.enable({ writes: true });
+ const created = await h.callTool("designer_create_design", { name: "No intent" });
+ const designId = (created.structuredContent.data as { design: { id: string } }).design.id;
+ const result = await h.callTool("designer_verify_build", { designId }, {
+ "x-openpcb-mcp-instance": "fresh-instance",
+ });
+ expect(result.structuredContent.ok).toBe(true);
+ expect((result.structuredContent.data as DodData).checks.length).toBe(4);
+ });
+});
+
+describe("knowledge pages", () => {
+ test("search and read a Docs page as markdown", async () => {
+ h.enable();
+ const response = await h.fetch("/api/modules/knowledge/pages", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ title: "Power budget notes",
+ content: {
+ engine: "tiptap",
+ version: 1,
+ data: {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "The 3V3 rail must stay under 500 mA." }],
+ },
+ ],
+ },
+ },
+ }),
+ });
+ expect(response.status).toBe(201);
+
+ const search = await h.callTool("knowledge_search_pages", { query: "Power budget" });
+ const pages = (search.structuredContent.data as { pages: Array<{ id: string; title: string }> }).pages;
+ expect(pages.map((p) => p.title)).toContain("Power budget notes");
+
+ const page = await h.callTool("knowledge_get_page", { pageId: pages[0]!.id });
+ expect(page.structuredContent.ok).toBe(true);
+ expect((page.structuredContent.data as { markdown: string }).markdown).toContain("500 mA");
+
+ const missing = await h.callTool("knowledge_get_page", { pageId: "nope" });
+ expect(missing.structuredContent.ok).toBe(false);
+ });
+});
diff --git a/src/core/backend/tests/assistant-mcp-pcb-tools.test.ts b/src/core/backend/tests/assistant-mcp-pcb-tools.test.ts
new file mode 100644
index 00000000..01163290
--- /dev/null
+++ b/src/core/backend/tests/assistant-mcp-pcb-tools.test.ts
@@ -0,0 +1,606 @@
+/**
+ * MCP-only PCB and design tools against the real runtime: placement, routing
+ * by net name and pad address, approval-gated deletions and rule changes, the
+ * undo ownership guard, zones/keepouts, design management.
+ */
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import { getAssistantService } from "../../../modules/assistant/backend/assistant-service";
+import type { DesignerCommandEnvelope } from "../../../sdks";
+import { bootMcpHarness, type McpHarness, type McpToolCallResult } from "./helpers/mcp-harness";
+
+let h: McpHarness;
+
+beforeAll(async () => {
+ h = await bootMcpHarness("assistant-mcp-pcb-tools");
+});
+
+interface Layout {
+ revision: number;
+ placements: Array<{
+ ref: string;
+ positionMm: { x: number; y: number };
+ rotationDeg: number;
+ side: string;
+ pads?: Array<{ pad: string; net: string | null; xMm: number; yMm: number }>;
+ }>;
+ nets: Array<{ name: string; pads: string[]; traces: number; unrouted: number }>;
+ unrouted: Array<{ net: string; from: string; to: string }>;
+ traces?: Array<{ id: string; net: string; layer: string }>;
+ zones: Array<{ id: string; net: string | null; layer: string }>;
+ keepouts: Array<{ id: string }>;
+}
+
+/** Two resistors with R1.2 wired to R2.1 → one unrouted connection on the board. */
+async function twoResistorDesign(name: string): Promise {
+ const created = await h.callTool("designer_create_design", { name });
+ const designId = (created.structuredContent.data as { design: { id: string } }).design.id;
+ await h.callTool("designer_place_components", {
+ designId,
+ components: [{ componentId: "openpcb.core.passive.resistor", quantity: 2 }],
+ });
+ const wired = await h.callTool("designer_propose_schematic_wires", {
+ designId,
+ title: "Join",
+ summary: "R1.2 to R2.1",
+ wires: [{ source: "R1.2", target: "R2.1" }],
+ });
+ expect(wired.structuredContent.ok).toBe(true);
+ // Auto-sync drops footprints wherever there is room; pin them on a line so
+ // the R1.2 → R2.1 route is a clear straight run in every test.
+ const placed = await h.callTool("pcb_place_footprints", {
+ designId,
+ placements: [
+ { ref: "R1", xMm: -6, yMm: 0, rotationDeg: 0 },
+ { ref: "R2", xMm: 6, yMm: 0, rotationDeg: 0 },
+ ],
+ });
+ expect(placed.structuredContent.ok).toBe(true);
+ return designId;
+}
+
+async function layout(designId: string): Promise {
+ const result = await h.callTool("designer_get_pcb_layout", { designId });
+ expect(result.structuredContent.ok).toBe(true);
+ return result.structuredContent.data as Layout;
+}
+
+function chatOf(designId: string) {
+ return getAssistantService()
+ .conversation.listChats()
+ .find(
+ (c) =>
+ (c.metadata as { designId?: string; mcp?: unknown } | null)?.designId === designId &&
+ Boolean((c.metadata as { mcp?: unknown }).mcp),
+ )!;
+}
+
+async function approve(designId: string, result: McpToolCallResult): Promise {
+ const proposalId = result.structuredContent.proposal!.id;
+ const response = await h.fetch(
+ `/api/modules/assistant/chats/${chatOf(designId).id}/write-proposals/${proposalId}/apply`,
+ { method: "POST", headers: { "content-type": "application/json" }, body: "{}" },
+ );
+ expect(response.ok).toBe(true);
+}
+
+async function routeFirstUnrouted(designId: string): Promise {
+ const before = await layout(designId);
+ const connection = before.unrouted[0]!;
+ return h.callTool("pcb_route", {
+ designId,
+ action_id: `route_${connection.net}_${designId}`,
+ traces: [{ net: connection.net, layer: "F.Cu", from: connection.from, to: connection.to }],
+ });
+}
+
+describe("placement and routing", () => {
+ test("footprints move by reference designator", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Place me");
+ const result = await h.callTool("pcb_place_footprints", {
+ designId,
+ placements: [
+ { ref: "R1", xMm: 5, yMm: 5, rotationDeg: 90 },
+ { ref: "R2", xMm: -5, yMm: 5 },
+ ],
+ });
+ expect(result.structuredContent.proposal?.status).toBe("applied");
+ expect(result.structuredContent.ok).toBe(true);
+ const after = await layout(designId);
+ const r1 = after.placements.find((p) => p.ref === "R1")!;
+ expect(r1.positionMm).toEqual({ x: 5, y: 5 });
+ expect(r1.rotationDeg).toBe(90);
+ expect(after.placements.find((p) => p.ref === "R2")!.positionMm).toEqual({ x: -5, y: 5 });
+ });
+
+ test("a net routes pad to pad by name and the connection is no longer unrouted", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Route me");
+ const before = await layout(designId);
+ expect(before.unrouted.length).toBe(1);
+ const result = await routeFirstUnrouted(designId);
+ expect(result.structuredContent.ok).toBe(true);
+ expect(result.structuredContent.summary).toContain("Now: DRC:");
+ expect(result.structuredContent.proposal?.status).toBe("applied");
+ const after = await layout(designId);
+ expect(after.unrouted.length).toBe(0);
+ expect(after.traces!.length).toBeGreaterThan(0);
+ // Re-sending the same action_id does not duplicate copper.
+ const again = await h.callTool("pcb_route", {
+ designId,
+ action_id: `route_${before.unrouted[0]!.net}_${designId}`,
+ traces: [
+ {
+ net: before.unrouted[0]!.net,
+ layer: "F.Cu",
+ from: before.unrouted[0]!.from,
+ to: before.unrouted[0]!.to,
+ },
+ ],
+ });
+ expect(again.structuredContent.ok).toBe(true);
+ expect((await layout(designId)).traces!.length).toBe(after.traces!.length);
+ });
+
+ test("routing refuses a pad that is not on the named net", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Wrong pad");
+ const before = await layout(designId);
+ const net = before.unrouted[0]!.net;
+ const foreign = before.placements
+ .flatMap((p) => (p.pads ?? []).map((pad) => ({ address: `${p.ref}.${pad.pad}`, net: pad.net })))
+ .find((pad) => pad.net !== net)!;
+ const result = await h.callTool("pcb_route", {
+ designId,
+ traces: [{ net, layer: "F.Cu", from: before.unrouted[0]!.from, to: foreign.address }],
+ });
+ expect(result.structuredContent.ok).toBe(false);
+ expect(result.structuredContent.error?.message).toContain("is on net");
+ });
+
+ test("deleting routing waits for approval, then removes the copper", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Delete routing");
+ await routeFirstUnrouted(designId);
+ const routed = await layout(designId);
+ const net = routed.traces![0]!.net;
+ const result = await h.callTool("pcb_delete_routing", { designId, nets: [net] });
+ expect(result.structuredContent.proposal?.status).toBe("pending");
+ expect((await layout(designId)).traces!.length).toBe(routed.traces!.length);
+ await approve(designId, result);
+ expect((await layout(designId)).traces!.length).toBe(0);
+ });
+});
+
+describe("board and rules", () => {
+ test("the board outline changes", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Outline");
+ const result = await h.callTool("pcb_set_board_outline", {
+ designId,
+ shape: "rect",
+ widthMm: 40,
+ heightMm: 25,
+ centerMm: { x: 0, y: 0 },
+ });
+ expect(result.structuredContent.ok).toBe(true);
+ const state = await h.callTool("designer_get_pcb_state", { designId });
+ const board = (state.structuredContent.data as { board: { widthMm: number; heightMm: number } }).board;
+ expect([board.widthMm, board.heightMm]).toEqual([40, 25]);
+ });
+
+ test("rule changes are never auto-applied", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Rules");
+ const result = await h.callTool("pcb_set_design_rules", {
+ designId,
+ netClasses: [{ name: "Default", traceWidthMm: 0.3 }],
+ });
+ expect(result.structuredContent.proposal?.status).toBe("pending");
+ await approve(designId, result);
+ const pcb = await h.designer.getPcbProjection(designId);
+ expect(pcb!.board.netClasses.find((c) => c.id === "default")!.traceWidthMm).toBe(0.3);
+ });
+
+ test("a new net class without explicit values is refused rather than guessed", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("No guessing");
+ const result = await h.callTool("pcb_set_design_rules", {
+ designId,
+ netClasses: [{ name: "HighCurrent", traceWidthMm: 1 }],
+ });
+ expect(result.structuredContent.ok).toBe(false);
+ expect(result.structuredContent.error?.message).toContain("clearanceMm");
+ });
+
+ test("a board ground pour and a keepout can be added", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Pours");
+ const net = (await layout(designId)).nets[0]!.name;
+ const zone = await h.callTool("pcb_add_zone", {
+ designId,
+ layer: "B.Cu",
+ net,
+ region: "board",
+ });
+ expect(zone.structuredContent.ok).toBe(true);
+ const keepout = await h.callTool("pcb_add_keepout", {
+ designId,
+ layers: ["F.Cu"],
+ pointsMm: [
+ { x: 10, y: 5 },
+ { x: 14, y: 5 },
+ { x: 14, y: 9 },
+ { x: 10, y: 9 },
+ ],
+ forbid: ["tracks", "vias"],
+ });
+ expect(keepout.structuredContent.ok).toBe(true);
+ const after = await layout(designId);
+ expect(after.zones.some((z) => z.layer === "B.Cu")).toBe(true);
+ expect(after.keepouts.length).toBe(1);
+
+ // Updating applies; deleting is its own, destructive tool and waits.
+ const zoneId = after.zones.find((z) => z.layer === "B.Cu")!.id;
+ const renamed = await h.callTool("pcb_update_zone", { designId, zoneId, name: "Ground pour" });
+ expect(renamed.structuredContent.proposal?.status).toBe("applied");
+ const removal = await h.callTool("pcb_delete_zone", { designId, zoneId });
+ expect(removal.structuredContent.proposal?.status).toBe("pending");
+ const tools = await h.listTools();
+ const del = tools.find((t) => t.name === "pcb_delete_zone")!;
+ const add = tools.find((t) => t.name === "pcb_add_zone")!;
+ expect((del.annotations as { destructiveHint?: boolean }).destructiveHint).toBe(true);
+ expect((add.annotations as { destructiveHint?: boolean }).destructiveHint).toBe(false);
+ });
+});
+
+describe("validation refuses what would be physically wrong", () => {
+ test("layers must exist on this board's stack", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Two layers");
+ const net = (await layout(designId)).nets[0]!.name;
+ const zone = await h.callTool("pcb_add_zone", { designId, layer: "In1.Cu", net, region: "board" });
+ expect(zone.structuredContent.ok).toBe(false);
+ expect(zone.structuredContent.summary).toContain("not on this 2-layer board");
+ const keepout = await h.callTool("pcb_add_keepout", {
+ designId,
+ layers: ["In2.Cu"],
+ pointsMm: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }],
+ forbid: ["tracks"],
+ });
+ expect(keepout.structuredContent.ok).toBe(false);
+ const before = await layout(designId);
+ const connection = before.unrouted[0]!;
+ const route = await h.callTool("pcb_route", {
+ designId,
+ traces: [{ net: connection.net, layer: "In1.Cu", from: connection.from, to: connection.to }],
+ });
+ expect(route.structuredContent.ok).toBe(false);
+ expect(route.structuredContent.summary).toContain("F.Cu, B.Cu");
+ const zeroWidth = await h.callTool("pcb_route", {
+ designId,
+ traces: [{ net: connection.net, layer: "F.Cu", from: connection.from, to: connection.to, widthMm: 0 }],
+ });
+ expect(zeroWidth.structuredContent.ok).toBe(false);
+ });
+
+ test("outlines: no invented radius, circles are round, polygons are simple", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Outlines");
+ const noRadius = await h.callTool("pcb_set_board_outline", {
+ designId,
+ shape: "roundrect",
+ widthMm: 40,
+ heightMm: 30,
+ });
+ expect(noRadius.structuredContent.ok).toBe(false);
+ expect(noRadius.structuredContent.summary).toContain("cornerRadiusMm");
+ const tooRound = await h.callTool("pcb_set_board_outline", {
+ designId,
+ shape: "roundrect",
+ widthMm: 40,
+ heightMm: 30,
+ cornerRadiusMm: 16,
+ });
+ expect(tooRound.structuredContent.ok).toBe(false);
+ const circleWithBox = await h.callTool("pcb_set_board_outline", {
+ designId,
+ shape: "circle",
+ widthMm: 40,
+ heightMm: 30,
+ });
+ expect(circleWithBox.structuredContent.ok).toBe(false);
+ const bowtie = await h.callTool("pcb_set_board_outline", {
+ designId,
+ shape: "polygon",
+ pointsMm: [
+ { x: 0, y: 0 },
+ { x: 20, y: 20 },
+ { x: 20, y: 0 },
+ { x: 0, y: 20 },
+ ],
+ });
+ expect(bowtie.structuredContent.ok).toBe(false);
+ expect(bowtie.structuredContent.summary).toContain("crosses");
+
+ const circle = await h.callTool("pcb_set_board_outline", { designId, shape: "circle", diameterMm: 30 });
+ expect(circle.structuredContent.ok).toBe(true);
+ let board = (await h.designer.getPcbProjection(designId))!.board.outline;
+ expect([board.kind, board.widthMm, board.heightMm]).toEqual(["circle", 30, 30]);
+ const oval = await h.callTool("pcb_set_board_outline", { designId, shape: "oval", widthMm: 40, heightMm: 20 });
+ expect(oval.structuredContent.ok).toBe(true);
+ board = (await h.designer.getPcbProjection(designId))!.board.outline;
+ expect([board.widthMm, board.heightMm]).toEqual([40, 20]);
+ });
+
+ test("design rules: positive sizes, drill smaller than pad, unique ids and names", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Rule sanity");
+ const zero = await h.callTool("pcb_set_design_rules", {
+ designId,
+ netClasses: [{ name: "Default", traceWidthMm: 0 }],
+ });
+ expect(zero.structuredContent.ok).toBe(false);
+ expect(zero.structuredContent.summary).toContain("traceWidthMm must be a number > 0");
+ const drill = await h.callTool("pcb_set_design_rules", {
+ designId,
+ netClasses: [{ name: "Fat drill", traceWidthMm: 0.3, clearanceMm: 0.2, viaDiameterMm: 0.6, viaDrillMm: 0.6 }],
+ });
+ expect(drill.structuredContent.ok).toBe(false);
+ expect(drill.structuredContent.summary).toContain("smaller than viaDiameterMm");
+ const clearance = await h.callTool("pcb_set_design_rules", {
+ designId,
+ clearanceMm: { traceToTraceMm: 0 },
+ });
+ expect(clearance.structuredContent.ok).toBe(false);
+ const dupName = await h.callTool("pcb_set_design_rules", {
+ designId,
+ netClasses: [{ id: "power2", name: "Default", traceWidthMm: 0.3, clearanceMm: 0.2, viaDiameterMm: 0.6, viaDrillMm: 0.3 }],
+ });
+ expect(dupName.structuredContent.ok).toBe(false);
+ expect(dupName.structuredContent.summary).toContain("used twice");
+ });
+});
+
+describe("history", () => {
+ test("undo reverts this session's own change but refuses the user's", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Undo guard");
+ await routeFirstUnrouted(designId);
+ expect((await layout(designId)).traces!.length).toBeGreaterThan(0);
+
+ const undo = await h.callTool("designer_undo", { designId });
+ expect(undo.structuredContent.ok).toBe(true);
+ expect((await layout(designId)).traces!.length).toBe(0);
+
+ // The user now edits in the UI; Claude must not undo that.
+ const head = await h.designer.getDesign(designId);
+ const pcb = await h.designer.getPcbProjection(designId);
+ const envelope: DesignerCommandEnvelope = {
+ commandId: crypto.randomUUID(),
+ sessionId: "designer-ui-session",
+ aggregateId: designId,
+ baseRevision: head!.head.revision,
+ issuedAt: Date.now(),
+ command: {
+ type: "pcb_move_placement",
+ placementId: pcb!.placements[0]!.id,
+ positionMm: { x: 3, y: 3 },
+ },
+ };
+ const user = await h.fetch(`/api/modules/designer/designs/${designId}/commands`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(envelope),
+ });
+ expect(user.ok).toBe(true);
+ const refused = await h.callTool("designer_undo", { designId });
+ expect(refused.structuredContent.ok).toBe(false);
+ expect(refused.structuredContent.error?.message).toContain("not made by this session");
+ expect((await h.designer.getPcbProjection(designId))!.placements[0]!.positionMm).toEqual({ x: 3, y: 3 });
+
+ const history = await h.callTool("designer_get_history", { designId });
+ expect((history.structuredContent.data as { nextUndo?: { commandType: string } }).nextUndo?.commandType).toBe(
+ "pcb_move_placement",
+ );
+ });
+
+ test("a stale expectedRevision is refused", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Stale undo");
+ await routeFirstUnrouted(designId);
+ const result = await h.callTool("designer_undo", { designId, expectedRevision: 0 });
+ expect(result.structuredContent.ok).toBe(false);
+ expect(result.structuredContent.error?.message).toContain("moved to revision");
+ });
+});
+
+describe("design management", () => {
+ test("rename, focus, and delete-with-approval", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Before rename");
+ const renamed = await h.callTool("designer_rename_design", { designId, name: "After rename" });
+ expect(renamed.structuredContent.ok).toBe(true);
+ expect((await h.designer.getDesign(designId))!.head.name).toBe("After rename");
+
+ const focus = await h.callTool("designer_focus_design", { designId });
+ expect(focus.structuredContent.ok).toBe(true);
+
+ const deletion = await h.callTool("designer_delete_design", { designId });
+ expect(deletion.structuredContent.proposal?.status).toBe("pending");
+ expect(await h.designer.getDesign(designId)).not.toBeNull();
+ await approve(designId, deletion);
+ expect(await h.designer.getDesign(designId)).toBeNull();
+ });
+
+ test("with writes off only the read tools are listed", async () => {
+ h.enable({ writes: false });
+ const names = (await h.listTools()).map((t) => t.name as string);
+ expect(names).toContain("designer_get_pcb_layout");
+ expect(names).toContain("designer_get_history");
+ expect(names).not.toContain("pcb_route");
+ expect(names).not.toContain("designer_delete_design");
+ });
+});
+
+describe("stale approvals never apply", () => {
+ async function approveExpectingRefusal(designId: string, result: McpToolCallResult) {
+ const proposalId = result.structuredContent.proposal!.id;
+ const response = await h.fetch(
+ `/api/modules/assistant/chats/${chatOf(designId).id}/write-proposals/${proposalId}/apply`,
+ { method: "POST", headers: { "content-type": "application/json" }, body: "{}" },
+ );
+ expect(response.status).toBe(400);
+ return proposalId;
+ }
+
+ test("an old design-delete proposal cannot delete newer work", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Keep newer work");
+ const deletion = await h.callTool("designer_delete_design", { designId });
+ expect(deletion.structuredContent.proposal?.status).toBe("pending");
+
+ // The user keeps working after the agent proposed the deletion.
+ const moved = await h.callTool("pcb_place_footprints", {
+ designId,
+ placements: [{ ref: "R1", xMm: -8, yMm: 0 }],
+ });
+ expect(moved.structuredContent.ok).toBe(true);
+
+ const proposalId = await approveExpectingRefusal(designId, deletion);
+ expect(await h.designer.getDesign(designId)).not.toBeNull();
+
+ const record = getAssistantService().conversation.getWriteProposalById(proposalId)!;
+ expect(record.status).toBe("failed");
+ expect((record.applyResult as { code?: string }).code).toBe("STALE_PROPOSAL");
+
+ const got = await h.callTool("assistant_get_proposal", { proposalId });
+ expect(got.structuredContent.summary).toContain("NOT applied");
+ expect(got.structuredContent.summary).toContain("design changed");
+ });
+
+ test("an old rules proposal is refused the same way", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Rules then edit");
+ const rules = await h.callTool("pcb_set_design_rules", {
+ designId,
+ netClasses: [{ name: "Default", traceWidthMm: 0.35 }],
+ });
+ expect(rules.structuredContent.proposal?.status).toBe("pending");
+ await h.callTool("pcb_place_footprints", {
+ designId,
+ placements: [{ ref: "R2", xMm: 8, yMm: 0 }],
+ });
+ await approveExpectingRefusal(designId, rules);
+ const pcb = await h.designer.getPcbProjection(designId);
+ expect(pcb!.board.netClasses.find((c) => c.id === "default")!.traceWidthMm).not.toBe(0.35);
+ });
+});
+
+describe("DRC suppression needs the user and is always reported", () => {
+ interface DrcData {
+ violations: Array<{ id: string; code: string; ruleClass: string; waived?: boolean }>;
+ counts: {
+ active: number;
+ waived: number;
+ ignoredByRuleClass: number;
+ raw: number;
+ };
+ }
+
+ async function drc(designId: string): Promise<{ data: DrcData; summary: string }> {
+ const result = await h.callTool("designer_run_drc", { designId });
+ expect(result.structuredContent.ok).toBe(true);
+ return { data: result.structuredContent.data as DrcData, summary: result.structuredContent.summary };
+ }
+
+ test("waiving waits for approval, needs a reason and a real id, and stays visible in counts", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Waive me");
+ const before = await drc(designId);
+ const target = before.data.violations.find((v) => !v.waived)!;
+ expect(target).toBeTruthy();
+ expect(before.data.counts.raw).toBe(before.data.counts.active);
+
+ const noReason = await h.callTool("pcb_waive_drc_violations", { designId, waive: [target.id] });
+ expect(noReason.structuredContent.ok).toBe(false);
+ const unknown = await h.callTool("pcb_waive_drc_violations", {
+ designId,
+ waive: ["TRACE_WIDTH_MIN-v2-0000000000000000"],
+ reason: "user accepted",
+ });
+ expect(unknown.structuredContent.ok).toBe(false);
+ expect(unknown.structuredContent.summary).toContain("Not in the current DRC report");
+
+ const waive = await h.callTool("pcb_waive_drc_violations", {
+ designId,
+ waive: [target.id],
+ reason: "User accepts this for the prototype",
+ });
+ expect(waive.structuredContent.proposal?.status).toBe("pending");
+ expect(waive.structuredContent.proposal?.kind).toBe("designer_pcb_drc_waivers");
+ expect((await drc(designId)).data.counts.waived).toBe(0);
+
+ await approve(designId, waive);
+ const after = await drc(designId);
+ expect(after.data.counts.waived).toBe(1);
+ expect(after.data.counts.raw).toBe(before.data.counts.raw);
+ expect(after.summary).toContain("1 waived");
+ expect(after.summary).toContain("not describe the board as clean");
+
+ const unwaive = await h.callTool("pcb_waive_drc_violations", { designId, unwaive: [target.id] });
+ expect(unwaive.structuredContent.proposal?.status).toBe("applied");
+ expect((await drc(designId)).data.counts.waived).toBe(0);
+ });
+
+ test("ignoring a rule class is never covered by a session allowance; hidden violations are counted", async () => {
+ h.enable({ writes: true });
+ const designId = await twoResistorDesign("Ignore class");
+ const before = await drc(designId);
+ const ruleClass = before.data.violations[0]!.ruleClass;
+
+ const allow = await h.fetch(
+ `/api/modules/assistant/chats/${chatOf(designId).id}/write-policy/session-allow`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ toolName: "pcb_set_drc_rule_class_ignores",
+ proposalKind: "designer_pcb_drc_rule_ignores",
+ riskLevel: "high",
+ }),
+ },
+ );
+ expect(allow.status).toBe(201);
+
+ const ignore = await h.callTool("pcb_set_drc_rule_class_ignores", {
+ designId,
+ ignore: [ruleClass],
+ reason: "User reviews these by hand",
+ });
+ expect(ignore.structuredContent.proposal?.status).toBe("pending");
+ // The approval card says what the user is agreeing to.
+ const card = getAssistantService().conversation.getWriteProposalById(
+ ignore.structuredContent.proposal!.id,
+ )!;
+ expect(card.summary).toContain("would hide");
+ expect(card.summary).toContain("User reviews these by hand");
+
+ await approve(designId, ignore);
+ const after = await drc(designId);
+ expect(after.data.counts.ignoredByRuleClass).toBeGreaterThan(0);
+ expect(after.data.counts.raw).toBe(before.data.counts.raw);
+ expect(after.summary).toContain("hidden by ignored rule classes");
+
+ const state = await h.callTool("designer_get_pcb_state", { designId });
+ expect(
+ (state.structuredContent.data as { drcSuppression: { ignoredRuleClasses: string[] } })
+ .drcSuppression.ignoredRuleClasses,
+ ).toContain(ruleClass);
+
+ const restore = await h.callTool("pcb_set_drc_rule_class_ignores", { designId, unignore: [ruleClass] });
+ expect(restore.structuredContent.proposal?.status).toBe("applied");
+ expect((await drc(designId)).data.counts.ignoredByRuleClass).toBe(0);
+ });
+});
diff --git a/src/core/backend/tests/assistant-mcp-result-size.test.ts b/src/core/backend/tests/assistant-mcp-result-size.test.ts
new file mode 100644
index 00000000..f4102d07
--- /dev/null
+++ b/src/core/backend/tests/assistant-mcp-result-size.test.ts
@@ -0,0 +1,124 @@
+/**
+ * Result and audit size: the text half of a result is bounded (the complete
+ * data stays in structuredContent), big reads page, and the chat database
+ * stores full results only where the panel renders them.
+ */
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import { getAssistantService } from "../../../modules/assistant/backend/assistant-service";
+import {
+ MAX_STORED_RESULT_CHARS,
+ storedArgumentsJson,
+ storedResultJson,
+} from "../../../modules/assistant/backend/mcp/call-recorder";
+import {
+ MAX_TEXT_CHARS,
+ sliceCodePoints,
+ toCallToolResult,
+} from "../../../modules/assistant/backend/mcp/result-envelope";
+import { bootMcpHarness, type McpHarness } from "./helpers/mcp-harness";
+
+let h: McpHarness;
+
+beforeAll(async () => {
+ h = await bootMcpHarness("assistant-mcp-result-size");
+});
+
+describe("what the chat database keeps", () => {
+ const big = JSON.stringify({ rows: Array.from({ length: 2_000 }, (_, i) => ({ i, name: `row ${i}` })) });
+
+ test("large reads become a digest; small ones and panel-rendered ones stay whole", () => {
+ const stored = JSON.parse(storedResultJson("designer_get_pcb_layout", big)!) as {
+ digest: boolean;
+ bytes: number;
+ sha256: string;
+ preview: string;
+ };
+ expect(stored.digest).toBe(true);
+ expect(stored.bytes).toBe(big.length);
+ expect(stored.sha256).toMatch(/^[0-9a-f]{64}$/);
+ expect(stored.preview.length).toBeLessThanOrEqual(2_000);
+ expect(storedResultJson("designer_run_erc", "{\"ok\":1}")).toBe("{\"ok\":1}");
+ expect(storedResultJson("library_search_components", big)).toBe(big);
+ });
+
+ test("proposal results keep only what the card joins on", () => {
+ const envelope = JSON.stringify({
+ id: "p1",
+ kind: "designer_pcb_route_batch",
+ designId: "d1",
+ baseRevision: 4,
+ operations: Array.from({ length: 500 }, (_, i) => ({ id: `op${i}`, payload: { big: "x".repeat(50) } })),
+ });
+ expect(JSON.parse(storedResultJson("pcb_route", envelope)!)).toEqual({
+ id: "p1",
+ kind: "designer_pcb_route_batch",
+ designId: "d1",
+ baseRevision: 4,
+ });
+ });
+
+ test("arguments are bounded too", () => {
+ expect(storedArgumentsJson("{}")).toBe("{}");
+ expect(JSON.parse(storedArgumentsJson(big)).digest).toBe(true);
+ });
+
+ test("over MCP: a layout read is stored as a digest, a library search stays renderable", async () => {
+ h.enable({ writes: true });
+ const created = await h.callTool("designer_create_design", { name: "Audit size" });
+ const designId = (created.structuredContent.data as { design: { id: string } }).design.id;
+ await h.callTool("designer_place_components", {
+ designId,
+ components: [{ componentId: "openpcb.core.passive.resistor", quantity: 30 }],
+ });
+ await h.callTool("designer_get_pcb_layout", { designId });
+ await h.callTool("library_search_components", { query: "resistor" });
+ const conversation = getAssistantService().conversation;
+ const events = conversation
+ .listChats()
+ .filter((c) => Boolean((c.metadata as { mcp?: unknown } | null)?.mcp))
+ .flatMap((c) => conversation.listToolEvents(c.id));
+ const layout = events.find((e) => e.toolName === "designer_get_pcb_layout")!;
+ expect(layout.resultJson!.length).toBeLessThan(MAX_STORED_RESULT_CHARS);
+ const search = events.find((e) => e.toolName === "library_search_components")!;
+ expect(Array.isArray((JSON.parse(search.resultJson!) as { results?: unknown[] }).results)).toBe(true);
+ });
+});
+
+describe("what the model is sent", () => {
+ test("the text half is bounded and never splits a character; structuredContent is complete", () => {
+ const data = { text: "😀".repeat(MAX_TEXT_CHARS) };
+ const result = toCallToolResult({
+ ok: true,
+ status: "ok",
+ summary: "Big.",
+ warnings: [],
+ truncated: false,
+ data,
+ });
+ const text = result.content[0]!.text;
+ expect(text.length).toBeLessThan(MAX_TEXT_CHARS + 300);
+ expect(text).toContain("complete result is in structuredContent");
+ expect(text).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
+ expect(result.structuredContent.data).toEqual(data);
+ expect(sliceCodePoints("a😀", 2)).toBe("a");
+ });
+
+ test("the PCB layout pages its footprints", async () => {
+ h.enable({ writes: true });
+ const created = await h.callTool("designer_create_design", { name: "Paged layout" });
+ const designId = (created.structuredContent.data as { design: { id: string } }).design.id;
+ await h.callTool("designer_place_components", {
+ designId,
+ components: [{ componentId: "openpcb.core.passive.resistor", quantity: 3 }],
+ });
+ const first = await h.callTool("designer_get_pcb_layout", { designId, limit: 2 });
+ const page = (first.structuredContent.data as { page: { total: number; nextOffset: number | null } }).page;
+ expect(page).toEqual({ offset: 0, limit: 2, total: 3, nextOffset: 2 } as never);
+ expect(first.structuredContent.summary).toContain("offset 2");
+ const second = await h.callTool("designer_get_pcb_layout", { designId, limit: 2, offset: 2 });
+ const rest = second.structuredContent.data as { placements: unknown[]; page: { nextOffset: number | null } };
+ expect(rest.placements).toHaveLength(1);
+ expect(rest.page.nextOffset).toBeNull();
+ });
+});
diff --git a/src/core/backend/tests/assistant-mcp-sessions.test.ts b/src/core/backend/tests/assistant-mcp-sessions.test.ts
new file mode 100644
index 00000000..89b937cc
--- /dev/null
+++ b/src/core/backend/tests/assistant-mcp-sessions.test.ts
@@ -0,0 +1,274 @@
+/**
+ * Isolation between concurrent MCP sessions of the SAME client (same client
+ * key, different instance id — two Claude Code sessions on one machine).
+ * Ownership is the proposing session, never the chat: undo, proposal
+ * visibility, session allowances and chat binding must not leak across.
+ */
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import { getAssistantService } from "../../../modules/assistant/backend/assistant-service";
+import { bootMcpHarness, type McpHarness } from "./helpers/mcp-harness";
+
+let h: McpHarness;
+
+const A = { "x-openpcb-mcp-instance": "session-a" };
+const B = { "x-openpcb-mcp-instance": "session-b" };
+const RESISTOR = "openpcb.core.passive.resistor";
+
+beforeAll(async () => {
+ h = await bootMcpHarness("assistant-mcp-sessions");
+});
+
+async function createDesign(name: string, headers = A): Promise {
+ const result = await h.callTool("designer_create_design", { name }, headers);
+ expect(result.structuredContent.ok).toBe(true);
+ return (result.structuredContent.data as { design: { id: string } }).design.id;
+}
+
+async function placeResistor(designId: string, headers: Record): Promise {
+ const result = await h.callTool(
+ "designer_place_components",
+ { designId, components: [{ componentId: RESISTOR, quantity: 1 }] },
+ headers,
+ );
+ expect(result.structuredContent.ok).toBe(true);
+}
+
+function sessionChat(designId: string, instanceId: string) {
+ return getAssistantService()
+ .conversation.listChats()
+ .find((chat) => {
+ const meta = chat.metadata as { designId?: string; mcp?: { instanceId?: string } } | null;
+ return meta?.designId === designId && meta.mcp?.instanceId === instanceId;
+ });
+}
+
+async function proposeDeletion(designId: string, headers: Record) {
+ const projection = await h.designer.getSchematicProjection(designId);
+ const partId = projection!.parts[projection!.parts.length - 1]!.id;
+ return h.callTool(
+ "designer_propose_schematic_deletions",
+ {
+ designId,
+ title: "Remove a resistor",
+ summary: "Delete the last placed part.",
+ entities: [{ entityId: partId, entityKind: "part" }],
+ },
+ headers,
+ );
+}
+
+describe("undo ownership is per session", () => {
+ test("A cannot undo B's newer change; B can; then A can undo its own", async () => {
+ h.enable({ writes: true });
+ const designId = await createDesign("Undo isolation");
+ await placeResistor(designId, A);
+ await placeResistor(designId, B);
+
+ const refused = await h.callTool("designer_undo", { designId }, A);
+ expect(refused.structuredContent.ok).toBe(false);
+ expect(refused.structuredContent.summary).toContain("not made by this session");
+
+ const own = await h.callTool("designer_undo", { designId }, B);
+ expect(own.structuredContent.ok).toBe(true);
+
+ // B's placement is undone; the top of the stack is A's placement now.
+ const later = await h.callTool("designer_undo", { designId }, A);
+ expect(later.structuredContent.ok).toBe(true);
+ });
+});
+
+describe("proposals are visible only to the session that made them", () => {
+ test("B cannot get, list or await A's pending proposal", async () => {
+ h.enable({ writes: true });
+ const designId = await createDesign("Proposal isolation");
+ await placeResistor(designId, A);
+ const pending = await proposeDeletion(designId, A);
+ const proposalId = pending.structuredContent.proposal!.id;
+ expect(pending.structuredContent.proposal!.status).toBe("pending");
+
+ const got = await h.callTool("assistant_get_proposal", { proposalId }, B);
+ expect(got.isError).toBe(true);
+ const awaited = await h.callTool(
+ "assistant_await_proposal",
+ { proposalId, timeoutSeconds: 1 },
+ B,
+ );
+ expect(awaited.isError).toBe(true);
+ const listedB = await h.callTool("assistant_list_pending_proposals", { designId }, B);
+ expect((listedB.structuredContent.data as { proposals: unknown[] }).proposals).toHaveLength(0);
+
+ const listedA = await h.callTool("assistant_list_pending_proposals", { designId }, A);
+ const mine = (listedA.structuredContent.data as { proposals: Array<{ id: string }> }).proposals;
+ expect(mine.map((p) => p.id)).toContain(proposalId);
+ const ownGet = await h.callTool("assistant_get_proposal", { proposalId }, A);
+ expect(ownGet.structuredContent.ok).toBe(true);
+ });
+});
+
+describe("session allowances do not leak", () => {
+ test("A's 'allow this tool this session' does not auto-apply B's deletion", async () => {
+ h.enable({ writes: true });
+ const designId = await createDesign("Allowance isolation");
+ await placeResistor(designId, A);
+ await placeResistor(designId, A);
+ await h.callTool("designer_get_design_summary", { designId }, B);
+
+ const chatA = sessionChat(designId, "session-a")!;
+ const allow = await h.fetch(
+ `/api/modules/assistant/chats/${chatA.id}/write-policy/session-allow`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ toolName: "designer_propose_schematic_deletions",
+ proposalKind: "designer_schematic_deletions",
+ riskLevel: "destructive",
+ }),
+ },
+ );
+ expect(allow.status).toBe(201);
+
+ const fromB = await proposeDeletion(designId, B);
+ expect(fromB.structuredContent.proposal!.status).toBe("pending");
+
+ const fromA = await proposeDeletion(designId, A);
+ expect(fromA.structuredContent.proposal!.status).toBe("applied");
+ });
+});
+
+describe("chat binding under concurrency", () => {
+ function primaryCounts(): number[] {
+ const service = getAssistantService();
+ return service.conversation
+ .listChats()
+ .filter((chat) => Boolean((chat.metadata as { mcp?: unknown } | null)?.mcp))
+ .map(
+ (chat) =>
+ service.conversation
+ .listBindings(chat.id)
+ .filter((b) => b.role === "primary" && b.status === "active" && b.kind === "design")
+ .length,
+ );
+ }
+
+ test("two sessions creating designs at once each get their own chat and pin", async () => {
+ h.enable({ writes: true });
+ const [a, b] = await Promise.all([
+ createDesign("Concurrent A", A),
+ createDesign("Concurrent B", B),
+ ]);
+ expect(a).not.toBe(b);
+ for (const count of primaryCounts()) expect(count).toBeLessThanOrEqual(1);
+ expect(sessionChat(a, "session-a")).toBeTruthy();
+ expect(sessionChat(b, "session-b")).toBeTruthy();
+ expect(sessionChat(a, "session-b")).toBeUndefined();
+
+ // Each session's pin follows its own create.
+ const summaryA = await h.callTool("designer_get_design_summary", {}, A);
+ const summaryB = await h.callTool("designer_get_design_summary", {}, B);
+ expect(JSON.stringify(summaryA.structuredContent.data)).toContain(a);
+ expect(JSON.stringify(summaryB.structuredContent.data)).toContain(b);
+ });
+
+ test("one session resolving two designs in parallel never double-binds a chat", async () => {
+ h.enable({ writes: true });
+ await createDesign("Parallel Resolve One", B);
+ await createDesign("Parallel Resolve Two", B);
+ const C = { "x-openpcb-mcp-instance": "session-c" };
+ await Promise.all([
+ h.callTool("designer_resolve_design", { query: "Parallel Resolve One" }, C),
+ h.callTool("designer_resolve_design", { query: "Parallel Resolve Two" }, C),
+ ]);
+ for (const count of primaryCounts()) expect(count).toBeLessThanOrEqual(1);
+ });
+});
+
+describe("idempotency is per session and never double-applies", () => {
+ async function partCount(designId: string): Promise {
+ return (await h.designer.getSchematicProjection(designId))!.parts.length;
+ }
+
+ test("a retried action_id replays the first result and places nothing twice", async () => {
+ h.enable({ writes: true });
+ const designId = await createDesign("Retry once");
+ const args = {
+ designId,
+ action_id: `place_r1_${designId}`,
+ components: [{ componentId: RESISTOR, quantity: 1 }],
+ };
+ const first = await h.callTool("designer_place_components", args, A);
+ expect(first.structuredContent.ok).toBe(true);
+ const retry = await h.callTool("designer_place_components", args, A);
+ expect(retry.structuredContent.summary).toContain("already_applied");
+ expect(await partCount(designId)).toBe(1);
+ });
+
+ test("two parallel identical calls land exactly once", async () => {
+ h.enable({ writes: true });
+ const designId = await createDesign("Parallel duplicate");
+ const args = {
+ designId,
+ action_id: `place_twin_${designId}`,
+ components: [{ componentId: RESISTOR, quantity: 1 }],
+ };
+ const results = await Promise.all([
+ h.callTool("designer_place_components", args, A),
+ h.callTool("designer_place_components", args, A),
+ ]);
+ const summaries = results.map((r) => r.structuredContent.summary);
+ expect(summaries.filter((s) => s.includes("already_applied"))).toHaveLength(1);
+ expect(await partCount(designId)).toBe(1);
+ });
+
+ test("the same deterministic action_id from another session is its own action (no crash, no cross-talk)", async () => {
+ h.enable({ writes: true });
+ const designId = await createDesign("Shared action id");
+ const args = {
+ designId,
+ action_id: `place_shared_${designId}`,
+ components: [{ componentId: RESISTOR, quantity: 1 }],
+ };
+ const fromA = await h.callTool("designer_place_components", args, A);
+ const fromB = await h.callTool("designer_place_components", args, B);
+ expect(fromA.structuredContent.ok).toBe(true);
+ expect(fromB.structuredContent.ok).toBe(true);
+ expect(fromB.structuredContent.summary).not.toContain("already_applied");
+ expect(await partCount(designId)).toBe(2);
+ });
+
+ test("re-sending a rejected action_id is blocked; a new id is a new proposal", async () => {
+ h.enable({ writes: true });
+ const designId = await createDesign("Rejected once");
+ await placeResistor(designId, A);
+ const partId = (await h.designer.getSchematicProjection(designId))!.parts[0]!.id;
+ const args = {
+ designId,
+ action_id: `delete_r1_${designId}`,
+ title: "Remove R1",
+ summary: "Delete R1.",
+ entities: [{ entityId: partId, entityKind: "part" }],
+ };
+ const pending = await h.callTool("designer_propose_schematic_deletions", args, A);
+ const proposalId = pending.structuredContent.proposal!.id;
+ const chat = sessionChat(designId, "session-a")!;
+ const reject = await h.fetch(
+ `/api/modules/assistant/chats/${chat.id}/write-proposals/${proposalId}/reject`,
+ { method: "POST", headers: { "content-type": "application/json" }, body: "{}" },
+ );
+ expect(reject.ok).toBe(true);
+
+ const resent = await h.callTool("designer_propose_schematic_deletions", args, A);
+ expect(resent.structuredContent.ok).toBe(false);
+ expect(resent.structuredContent.summary).toContain("duplicate_blocked");
+ expect(resent.structuredContent.warnings.join(" ")).toContain("rejected");
+
+ const fresh = await h.callTool(
+ "designer_propose_schematic_deletions",
+ { ...args, action_id: `delete_r1again_${designId}` },
+ A,
+ );
+ expect(fresh.structuredContent.proposal!.status).toBe("pending");
+ expect(fresh.structuredContent.proposal!.id).not.toBe(proposalId);
+ });
+});
diff --git a/src/core/backend/tests/assistant-mcp-tools.test.ts b/src/core/backend/tests/assistant-mcp-tools.test.ts
new file mode 100644
index 00000000..9b0ba192
--- /dev/null
+++ b/src/core/backend/tests/assistant-mcp-tools.test.ts
@@ -0,0 +1,451 @@
+/**
+ * MCP server behaviour against the real runtime (designer + library +
+ * assistant): result envelope, per-design chats, call recording, targeting,
+ * proposals. See docs/assistant/mcp-claude-code.md §1 for the findings these
+ * lock down.
+ */
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import { getAssistantService } from "../../../modules/assistant/backend/assistant-service";
+import { MCP_SERVER_INSTRUCTIONS } from "../../../modules/assistant/backend/mcp/instructions";
+import {
+ MAX_DESCRIPTION_CHARS,
+ MCP_TOOL_POLICIES,
+} from "../../../modules/assistant/backend/mcp/tool-policy";
+import {
+ bootMcpHarness,
+ MCP_TOKEN,
+ type McpHarness,
+} from "./helpers/mcp-harness";
+
+let h: McpHarness;
+
+beforeAll(async () => {
+ h = await bootMcpHarness("assistant-mcp-tools");
+});
+
+const INSTANCE_B = { "x-openpcb-mcp-instance": "instance-b" };
+
+function mcpChats() {
+ return getAssistantService()
+ .conversation.listChats()
+ .filter((chat) => Boolean((chat.metadata as { mcp?: unknown } | null)?.mcp));
+}
+
+async function createDesign(name: string): Promise {
+ const result = await h.callTool("designer_create_design", { name });
+ expect(result.structuredContent.ok).toBe(true);
+ const data = result.structuredContent.data as { design?: { id?: string } };
+ const id = data.design?.id;
+ expect(typeof id).toBe("string");
+ return id!;
+}
+
+async function summaryDesignId(headers?: Record): Promise {
+ const result = await h.callTool("designer_get_design_summary", {}, headers);
+ expect(result.structuredContent.ok).toBe(true);
+ const data = result.structuredContent.data as {
+ design?: { id?: string };
+ designId?: string;
+ };
+ return (data.design?.id ?? data.designId)!;
+}
+
+describe("MCP tool surface", () => {
+ test("every write tool declares an MCP policy entry", async () => {
+ h.enable({ writes: true });
+ const tools = await h.listTools();
+ const writes = tools.filter(
+ (t) => (t.annotations as { readOnlyHint?: boolean }).readOnlyHint === false,
+ );
+ expect(writes.length).toBeGreaterThan(0);
+ for (const tool of writes) {
+ if (tool.name === "designer_use_design") continue;
+ expect(Object.keys(MCP_TOOL_POLICIES)).toContain(tool.name as string);
+ }
+ const deletions = tools.find(
+ (t) => t.name === "designer_propose_schematic_deletions",
+ );
+ expect(
+ (deletions?.annotations as { destructiveHint?: boolean }).destructiveHint,
+ ).toBe(true);
+ });
+
+ test("descriptions and instructions fit Claude Code's 2 KB cap", async () => {
+ h.enable({ writes: true });
+ const tools = await h.listTools();
+ for (const tool of tools) {
+ expect((tool.description as string).length).toBeLessThanOrEqual(
+ MAX_DESCRIPTION_CHARS,
+ );
+ }
+ expect(MCP_SERVER_INSTRUCTIONS.length).toBeLessThanOrEqual(2_000);
+ const init = await h.rpc({
+ id: 1,
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "Claude Code", version: "2.1.0" },
+ },
+ });
+ const result = init.result as {
+ instructions?: string;
+ capabilities: { tools?: { listChanged?: boolean } };
+ };
+ expect(result.instructions).toBe(MCP_SERVER_INSTRUCTIONS);
+ expect(result.capabilities.tools?.listChanged).toBe(true);
+ });
+
+ test("instructions only name tools the server actually lists", async () => {
+ h.enable({ writes: true });
+ const names = new Set((await h.listTools()).map((t) => t.name as string));
+ const mentioned = MCP_SERVER_INSTRUCTIONS.match(
+ /\b(?:designer|library|pcb|assistant|knowledge)_[a-z_]+|\bcompile_circuit\b/g,
+ );
+ for (const name of new Set(mentioned ?? [])) {
+ expect(names.has(name)).toBe(true);
+ }
+ });
+});
+
+describe("result envelope", () => {
+ test("a failing read carries a readable error in both result halves", async () => {
+ h.enable();
+ const result = await h.callTool("designer_get_pcb_state", {
+ designId: "no-such-design",
+ });
+ expect(result.isError).toBe(true);
+ expect(result.structuredContent.ok).toBe(false);
+ expect(result.structuredContent.error?.message).toContain("not found");
+ expect(result.structuredContent.summary).toContain("not found");
+ expect(result.content[0]?.text).toContain("not found");
+ });
+
+ test("a successful read carries summary and data together", async () => {
+ h.enable();
+ const result = await h.callTool("designer_list_designs");
+ expect(result.structuredContent.ok).toBe(true);
+ expect(result.structuredContent.summary.length).toBeGreaterThan(0);
+ expect(result.structuredContent.data).toBeTruthy();
+ expect(result.content[0]?.text).toContain(result.structuredContent.summary);
+ });
+});
+
+describe("design targeting and chats", () => {
+ test("create_design works repeatedly and pins each new design", async () => {
+ h.enable({ writes: true });
+ const first = await createDesign("MCP first");
+ expect(await summaryDesignId()).toBe(first);
+ const second = await createDesign("MCP second");
+ expect(second).not.toBe(first);
+ expect(await summaryDesignId()).toBe(second);
+ });
+
+ test("two instances keep separate pins", async () => {
+ h.enable({ writes: true });
+ const a = await createDesign("Pin A");
+ const b = await createDesign("Pin B");
+ await h.callTool("designer_use_design", { designId: a });
+ await h.callTool("designer_use_design", { designId: b }, INSTANCE_B);
+ expect(await summaryDesignId()).toBe(a);
+ expect(await summaryDesignId(INSTANCE_B)).toBe(b);
+ });
+
+ test("each session gets one bound chat per design that the design dock lists", async () => {
+ h.enable({ writes: true });
+ const id = await createDesign("Dock listed");
+ await h.callTool("designer_get_design_summary", { designId: id });
+ await h.callTool("designer_get_design_summary", { designId: id });
+ await h.callTool("designer_get_design_summary", { designId: id }, INSTANCE_B);
+
+ const chats = mcpChats().filter(
+ (chat) => (chat.metadata as { designId?: string }).designId === id,
+ );
+ // One per session (instance-a created it via createDesign, instance-b on
+ // its first call) — never shared, never duplicated within a session.
+ expect(chats).toHaveLength(2);
+ const instances = chats.map(
+ (chat) => (chat.metadata as { mcp?: { instanceId?: string } }).mcp?.instanceId,
+ );
+ expect(new Set(instances).size).toBe(2);
+ for (const chat of chats) {
+ expect(getAssistantService().contextResolver.getPrimaryDesign(chat.id)?.refId).toBe(id);
+ }
+
+ const response = await h.fetch(
+ `/api/modules/assistant/design-chats?designId=${encodeURIComponent(id)}`,
+ );
+ const body = (await response.json()) as
+ | Array<{ id: string }>
+ | { chats?: Array<{ id: string }> };
+ const listed = Array.isArray(body) ? body : (body.chats ?? []);
+ expect(listed.map((c) => c.id)).toContain(chats[0]!.id);
+ });
+
+ test("the home chat stays unbound", async () => {
+ h.enable({ writes: true });
+ await createDesign("Home stays clean");
+ await h.callTool("library_search_components", { query: "resistor" });
+ const home = mcpChats().filter(
+ (chat) =>
+ (chat.metadata as { mcp?: { role?: string } }).mcp?.role === "home",
+ );
+ expect(home.length).toBeGreaterThan(0);
+ for (const chat of home) {
+ expect(
+ getAssistantService().contextResolver.getPrimaryDesign(chat.id),
+ ).toBeUndefined();
+ }
+ });
+});
+
+describe("call recording", () => {
+ test("each call becomes a tool event on a visible activity message", async () => {
+ h.enable({ writes: true });
+ const id = await createDesign("Recorded");
+ await h.callTool("designer_get_design_summary", { designId: id });
+ const chat = mcpChats().find(
+ (c) => (c.metadata as { designId?: string }).designId === id,
+ )!;
+ const conversation = getAssistantService().conversation;
+ const messages = conversation.listMessages(chat.id, { limit: 50 }).items;
+ const activity = messages.filter(
+ (m) => (m.metadata as { mcp?: { activity?: boolean } } | null)?.mcp?.activity,
+ );
+ expect(activity.length).toBeGreaterThan(0);
+ expect(activity.at(-1)!.content).toContain("designer_get_design_summary");
+ const events = conversation.listToolEvents(chat.id, {
+ messageIds: activity.map((m) => m.id),
+ });
+ expect(events.some((e) => e.toolName === "designer_get_design_summary")).toBe(
+ true,
+ );
+ expect(events.every((e) => e.status === "succeeded")).toBe(true);
+ });
+});
+
+describe("proposals", () => {
+ test("a deletion stays pending, returns its id, and renders in the design chat", async () => {
+ h.enable({ writes: true });
+ const designId = await createDesign("Deletion target");
+
+ const search = await h.callTool("library_search_components", {
+ query: "resistor",
+ limit: 5,
+ });
+ const hits = JSON.stringify(search.structuredContent.data);
+ const componentId = /"componentId":"([^"]+)"/.exec(hits)?.[1] ??
+ /"id":"([^"]+)"/.exec(hits)?.[1];
+ expect(componentId).toBeTruthy();
+
+ const placed = await h.callTool("designer_place_components", {
+ designId,
+ components: [{ componentId, quantity: 1 }],
+ });
+ expect(placed.structuredContent.ok).toBe(true);
+
+ const projection = await h.designer.getSchematicProjection(designId);
+ const partId = projection?.parts[0]?.id;
+ expect(partId).toBeTruthy();
+
+ const result = await h.callTool("designer_propose_schematic_deletions", {
+ designId,
+ title: "Remove the resistor",
+ summary: "Delete the only placed part.",
+ entities: [{ entityId: partId, entityKind: "part" }],
+ });
+ const proposal = result.structuredContent.proposal;
+ expect(proposal?.status).toBe("pending");
+ expect(proposal?.riskLevel).toBe("destructive");
+ expect(proposal?.approvalHint).toContain("approve");
+
+ const chat = mcpChats().find(
+ (c) => (c.metadata as { designId?: string }).designId === designId,
+ )!;
+ const conversation = getAssistantService().conversation;
+ expect(conversation.getWriteProposal(chat.id, proposal!.id)?.status).toBe(
+ "pending",
+ );
+ // The card renders from a succeeded tool event whose result is {id, kind}.
+ const events = conversation.listToolEvents(chat.id);
+ const card = events.find(
+ (e) => e.toolName === "designer_propose_schematic_deletions",
+ );
+ expect(card?.status).toBe("succeeded");
+ const parsed = JSON.parse(card!.resultJson!) as { id: string; kind: string };
+ expect(parsed.id).toBe(proposal!.id);
+ // Nothing was deleted.
+ expect((await h.designer.getSchematicProjection(designId))?.parts.length).toBe(1);
+ });
+});
+
+describe("shim support routes", () => {
+ test("mcp-state is bearer-gated and fingerprints the tool set", async () => {
+ const unauthorized = await h.fetch("/api/modules/assistant/mcp-state");
+ expect(unauthorized.status).toBe(401);
+
+ h.enable({ writes: false });
+ const read = (await (
+ await h.fetch("/api/modules/assistant/mcp-state", {
+ headers: { authorization: `Bearer ${MCP_TOKEN}` },
+ })
+ ).json()) as { enabled: boolean; allowWrites: boolean; toolset: string };
+ expect(read.enabled).toBe(true);
+ expect(read.allowWrites).toBe(false);
+
+ h.enable({ writes: true });
+ const write = (await (
+ await h.fetch("/api/modules/assistant/mcp-state", {
+ headers: { authorization: `Bearer ${MCP_TOKEN}` },
+ })
+ ).json()) as { toolset: string };
+ expect(write.toolset).not.toBe(read.toolset);
+ });
+
+ test("connected clients are listed for the Settings panel", async () => {
+ h.enable();
+ await h.callTool("designer_list_designs", {}, INSTANCE_B);
+ const body = (await (
+ await h.fetch("/api/modules/assistant/mcp/clients")
+ ).json()) as { clients: Array<{ instanceId: string; clientName: string }> };
+ const b = body.clients.find((c) => c.instanceId === "instance-b");
+ expect(b?.clientName).toBe("Claude Code");
+ });
+});
+
+describe("resolve_design", () => {
+ test("resolving a design that already has a chat keeps one chat per design", async () => {
+ h.enable({ writes: true });
+ const id = await createDesign("Resolve me uniquely");
+ await h.callTool("designer_get_design_summary", { designId: id });
+ const resolved = await h.callTool("designer_resolve_design", {
+ query: "Resolve me uniquely",
+ });
+ expect(resolved.structuredContent.ok).toBe(true);
+ const chats = mcpChats().filter(
+ (chat) => (chat.metadata as { designId?: string }).designId === id,
+ );
+ expect(chats).toHaveLength(1);
+ // resolve pins the design for this session.
+ expect(await summaryDesignId()).toBe(id);
+ });
+});
+
+async function pendingDeletion(name: string): Promise<{
+ designId: string;
+ proposalId: string;
+ chatId: string;
+}> {
+ const designId = await createDesign(name);
+ await h.callTool("designer_place_components", {
+ designId,
+ components: [{ componentId: "openpcb.core.passive.resistor", quantity: 1 }],
+ });
+ const partId = (await h.designer.getSchematicProjection(designId))?.parts[0]?.id;
+ const result = await h.callTool("designer_propose_schematic_deletions", {
+ designId,
+ title: "Remove",
+ summary: "Remove the resistor.",
+ entities: [{ entityId: partId, entityKind: "part" }],
+ });
+ const proposalId = result.structuredContent.proposal!.id;
+ const chatId = mcpChats().find(
+ (c) => (c.metadata as { designId?: string }).designId === designId,
+ )!.id;
+ return { designId, proposalId, chatId };
+}
+
+describe("approval round-trip", () => {
+ test("pending proposals are listed and looked up; hint names the await tool", async () => {
+ h.enable({ writes: true });
+ const { designId, proposalId } = await pendingDeletion("Approve list");
+ const listed = await h.callTool("assistant_list_pending_proposals", { designId });
+ const proposals = (listed.structuredContent.data as {
+ proposals: Array<{ id: string }>;
+ }).proposals;
+ expect(proposals.map((p) => p.id)).toContain(proposalId);
+ const got = await h.callTool("assistant_get_proposal", { proposalId });
+ expect((got.structuredContent.data as { status: string }).status).toBe("pending");
+ // Another client cannot read it.
+ const foreign = await h.callTool(
+ "assistant_get_proposal",
+ { proposalId },
+ { "x-openpcb-mcp-client": "someone-else" },
+ );
+ expect(foreign.isError).toBe(true);
+ });
+
+ test("await resolves when the user approves in the panel", async () => {
+ h.enable({ writes: true });
+ const { designId, proposalId, chatId } = await pendingDeletion("Approve me");
+ const waiting = h.callTool("assistant_await_proposal", {
+ proposalId,
+ timeoutSeconds: 30,
+ });
+ await Bun.sleep(50);
+ const applied = await h.fetch(
+ `/api/modules/assistant/chats/${chatId}/write-proposals/${proposalId}/apply`,
+ { method: "POST", headers: { "content-type": "application/json" }, body: "{}" },
+ );
+ expect(applied.ok).toBe(true);
+ const result = await waiting;
+ expect((result.structuredContent.data as { status: string }).status).toBe("applied");
+ expect(result.structuredContent.summary).toContain("approved");
+ expect((await h.designer.getSchematicProjection(designId))?.parts.length).toBe(0);
+ });
+
+ test("await reports a rejection", async () => {
+ h.enable({ writes: true });
+ const { proposalId, chatId } = await pendingDeletion("Reject me");
+ const waiting = h.callTool("assistant_await_proposal", {
+ proposalId,
+ timeoutSeconds: 30,
+ });
+ await Bun.sleep(50);
+ await h.fetch(
+ `/api/modules/assistant/chats/${chatId}/write-proposals/${proposalId}/reject`,
+ { method: "POST", headers: { "content-type": "application/json" }, body: "{}" },
+ );
+ const result = await waiting;
+ expect((result.structuredContent.data as { status: string }).status).toBe("rejected");
+ expect(result.structuredContent.summary).toContain("Do not re-send");
+ });
+
+ test("await times out as pending", async () => {
+ h.enable({ writes: true });
+ const { proposalId } = await pendingDeletion("Nobody answers");
+ const result = await h.callTool("assistant_await_proposal", {
+ proposalId,
+ timeoutSeconds: 1,
+ });
+ expect((result.structuredContent.data as { status: string }).status).toBe("pending");
+ });
+});
+
+describe("assistant event stream", () => {
+ test("MCP activity and proposal changes are published", async () => {
+ h.enable({ writes: true });
+ const controller = new AbortController();
+ const response = await h.fetch("/api/modules/assistant/events", {
+ signal: controller.signal,
+ });
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
+ const reader = response.body!.getReader();
+ const decoder = new TextDecoder();
+ let text = "";
+ const collect = (async () => {
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ text += decoder.decode(value);
+ if (text.includes("proposal.updated") && text.includes("chat.activity")) break;
+ }
+ })();
+ await pendingDeletion("Streamed");
+ await Promise.race([collect, Bun.sleep(3_000)]);
+ controller.abort();
+ expect(text).toContain("event: chat.activity");
+ expect(text).toContain("event: proposal.updated");
+ });
+});
diff --git a/src/core/backend/tests/assistant-placement-proposal.test.ts b/src/core/backend/tests/assistant-placement-proposal.test.ts
index f4f2e68f..2f35e8a1 100644
--- a/src/core/backend/tests/assistant-placement-proposal.test.ts
+++ b/src/core/backend/tests/assistant-placement-proposal.test.ts
@@ -140,6 +140,22 @@ function mockContextResolver(bound = false): ContextResolver {
updatedAt: "updated",
};
},
+ bindDesignIfUnbound(_chatId: string, design: { id: string; name: string }) {
+ return {
+ created: true,
+ binding: {
+ id: "binding-2",
+ chatId: "chat-1",
+ kind: "design",
+ refId: design.id,
+ label: design.name,
+ role: "primary",
+ status: "active",
+ createdAt: "created",
+ updatedAt: "updated",
+ },
+ };
+ },
} as unknown as ContextResolver;
}
diff --git a/src/core/backend/tests/assistant-rules-validation.test.ts b/src/core/backend/tests/assistant-rules-validation.test.ts
new file mode 100644
index 00000000..296ce232
--- /dev/null
+++ b/src/core/backend/tests/assistant-rules-validation.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, test } from "bun:test";
+import {
+ applyNetClassPatches,
+ clearanceProblems,
+ uniqueId,
+} from "../../../modules/assistant/backend/tools/rules-validation";
+import type { PcbNetClass } from "../../../sdks";
+
+const base = (over: Partial): PcbNetClass =>
+ ({
+ id: "default",
+ name: "Default",
+ traceWidthMm: 0.25,
+ clearanceMm: 0.2,
+ viaDiameterMm: 0.8,
+ viaDrillMm: 0.4,
+ color: "#888",
+ defaultViaProtection: "none",
+ ...over,
+ }) as PcbNetClass;
+
+describe("net class patches", () => {
+ test("a new class never takes an id another class already has", () => {
+ const existing = [base({}), base({ id: "high-speed", name: "HS" })];
+ const out = applyNetClassPatches(existing, [
+ { name: "High Speed", traceWidthMm: 0.2, clearanceMm: 0.2, viaDiameterMm: 0.6, viaDrillMm: 0.3 },
+ ]);
+ expect(out.problems).toEqual([]);
+ expect(out.classes.map((c) => c.id)).toEqual(["default", "high-speed", "high-speed-2"]);
+ });
+
+ test("new classes copy presentation, not another class's electrical metadata", () => {
+ const existing = [base({ voltageV: 48, currentA: 3, diffPairGapMm: 0.1 })];
+ const out = applyNetClassPatches(existing, [
+ { name: "Signal", traceWidthMm: 0.2, clearanceMm: 0.2, viaDiameterMm: 0.6, viaDrillMm: 0.3 },
+ ]);
+ const created = out.classes[1]!;
+ expect(created.voltageV).toBeUndefined();
+ expect(created.currentA).toBeUndefined();
+ expect(created.diffPairGapMm).toBeUndefined();
+ expect(created.color).toBe("#888");
+ });
+
+ test("two patches on one class, an explicit colliding id, and bad geometry are all reported", () => {
+ const existing = [base({})];
+ const twice = applyNetClassPatches(existing, [
+ { name: "Default", traceWidthMm: 0.3 },
+ { id: "default", name: "Default", clearanceMm: 0.3 },
+ ]);
+ expect(twice.problems.join(" ")).toContain("changed twice");
+ const bad = applyNetClassPatches(existing, [
+ { name: "Bad", traceWidthMm: -1, clearanceMm: 0.2, viaDiameterMm: 0.3, viaDrillMm: 0.5 },
+ ]);
+ expect(bad.problems.join(" ")).toContain("traceWidthMm must be a number > 0");
+ expect(bad.problems.join(" ")).toContain("smaller than viaDiameterMm");
+ });
+
+ test("an existing class that is already odd does not block unrelated edits", () => {
+ const existing = [base({}), base({ id: "legacy", name: "Legacy", clearanceMm: 0 })];
+ const out = applyNetClassPatches(existing, [{ name: "Default", traceWidthMm: 0.3 }]);
+ expect(out.problems).toEqual([]);
+ });
+
+ test("helpers", () => {
+ expect(uniqueId("gnd", new Set(["gnd", "gnd-2"]))).toBe("gnd-3");
+ expect(clearanceProblems({ traceToTraceMm: 0, traceToPadMm: 0.2 })).toEqual([
+ "clearance traceToTraceMm must be a number > 0",
+ ]);
+ });
+});
diff --git a/src/core/backend/tests/assistant-write-idempotency.test.ts b/src/core/backend/tests/assistant-write-idempotency.test.ts
index 1a34a4f3..1cc0a273 100644
--- a/src/core/backend/tests/assistant-write-idempotency.test.ts
+++ b/src/core/backend/tests/assistant-write-idempotency.test.ts
@@ -24,7 +24,7 @@ interface DispatchRecord {
/**
* Minimal in-memory ConversationStore: the write tools only touch
- * listWriteProposals / createWriteProposal / updateWriteProposalStatus.
+ * getWriteProposalByActionKey / createWriteProposal / updateWriteProposalStatus.
*/
function makeConversation(): ConversationStore {
const rows = new Map();
@@ -59,6 +59,16 @@ function makeConversation(): ConversationStore {
listWriteProposals(chatId: string) {
return [...rows.values()].filter((r) => r.chatId === chatId);
},
+ // In-app scope is the chat: "chat:" (writeProposalIdempotencyScope).
+ getWriteProposalByActionKey(designId: string, scope: string, actionId: string) {
+ const matches = [...rows.values()].filter(
+ (r) =>
+ r.designId === designId &&
+ `chat:${r.chatId}` === scope &&
+ (r.envelope as { actionId?: string } | null)?.actionId === actionId,
+ );
+ return matches[matches.length - 1] ?? null;
+ },
updateWriteProposalStatus(
_chatId: string,
id: string,
diff --git a/src/core/backend/tests/designer-live-events.test.ts b/src/core/backend/tests/designer-live-events.test.ts
new file mode 100644
index 00000000..1abd45d5
--- /dev/null
+++ b/src/core/backend/tests/designer-live-events.test.ts
@@ -0,0 +1,164 @@
+/**
+ * Design change stream + shared undo (docs/assistant/mcp-claude-code.md §1,
+ * B4): edits from outside the designer UI — the in-app assistant, MCP clients —
+ * must (a) reach the UI's undo stack and (b) be announced on
+ * `GET /api/modules/designer/events` so the canvas refreshes.
+ */
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import type { DesignerCommandEnvelope } from "../../../sdks";
+import { bootMcpHarness, type McpHarness } from "./helpers/mcp-harness";
+
+const UI_SESSION = "designer-ui-session";
+let h: McpHarness;
+
+beforeAll(async () => {
+ h = await bootMcpHarness("designer-live-events");
+});
+
+function envelope(
+ designId: string,
+ baseRevision: number,
+ command: DesignerCommandEnvelope["command"],
+): DesignerCommandEnvelope {
+ return {
+ commandId: crypto.randomUUID(),
+ sessionId: UI_SESSION,
+ aggregateId: designId,
+ baseRevision,
+ issuedAt: Date.now(),
+ command,
+ };
+}
+
+async function uiCommand(designId: string, baseRevision: number, command: DesignerCommandEnvelope["command"]) {
+ const response = await h.fetch(`/api/modules/designer/designs/${designId}/commands`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(envelope(designId, baseRevision, command)),
+ });
+ expect(response.ok).toBe(true);
+}
+
+/** Collect SSE frames from the designer event stream until `until` matches. */
+async function collectEvents(
+ designId: string | null,
+ action: () => Promise,
+ until: (events: Array>) => boolean,
+): Promise>> {
+ const controller = new AbortController();
+ const query = designId ? `?designId=${encodeURIComponent(designId)}` : "";
+ const response = await h.fetch(`/api/modules/designer/events${query}`, {
+ signal: controller.signal,
+ });
+ const reader = response.body!.getReader();
+ const decoder = new TextDecoder();
+ const events: Array> = [];
+ let buffer = "";
+ const pump = (async () => {
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) return;
+ buffer += decoder.decode(value);
+ let split = buffer.indexOf("\n\n");
+ while (split !== -1) {
+ const data = buffer
+ .slice(0, split)
+ .split("\n")
+ .find((line) => line.startsWith("data:"));
+ if (data) events.push(JSON.parse(data.slice(5)) as Record);
+ buffer = buffer.slice(split + 2);
+ split = buffer.indexOf("\n\n");
+ }
+ if (until(events)) return;
+ }
+ })();
+ await action();
+ await Promise.race([pump, Bun.sleep(3_000)]);
+ controller.abort();
+ return events;
+}
+
+describe("shared undo stack", () => {
+ test("the UI's undo reverts the assistant's newer edit, not the user's older one", async () => {
+ const design = await h.designer.createDesign({ name: "Shared undo" });
+ await uiCommand(design.id, 0, {
+ type: "place_part",
+ componentId: "openpcb.core.passive.resistor",
+ positionNm: { x: 0, y: 0 },
+ });
+ // The UI reads its history (caching the session) before the agent edits.
+ await h.fetch(`/api/modules/designer/designs/${design.id}/history?sessionId=${UI_SESSION}`);
+ const head = await h.designer.getDesign(design.id);
+ const agent = await h.designer.dispatchCommand(
+ design.id,
+ envelope(design.id, head!.head.revision, {
+ type: "place_part",
+ componentId: "openpcb.core.passive.capacitor",
+ positionNm: { x: 10_000_000, y: 0 },
+ }),
+ { actor: "assistant" },
+ );
+ expect(agent.ok).toBe(true);
+
+ const history = (await (
+ await h.fetch(`/api/modules/designer/designs/${design.id}/history?sessionId=${UI_SESSION}`)
+ ).json()) as { data: { history: { undoDepth: number } } };
+ expect(history.data.history.undoDepth).toBe(2);
+
+ await h.fetch(`/api/modules/designer/designs/${design.id}/history/undo`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ sessionId: UI_SESSION }),
+ });
+ const parts = (await h.designer.getSchematicProjection(design.id))?.parts ?? [];
+ expect(parts.map((p) => p.componentId)).toEqual(["openpcb.core.passive.resistor"]);
+ });
+});
+
+describe("design event stream", () => {
+ test("an MCP write is announced with the assistant as actor", async () => {
+ h.enable({ writes: true });
+ const created = await h.callTool("designer_create_design", { name: "Streamed design" });
+ const designId = (created.structuredContent.data as { design: { id: string } }).design.id;
+ const events = await collectEvents(
+ designId,
+ async () => {
+ const placed = await h.callTool("designer_place_components", {
+ designId,
+ components: [{ componentId: "openpcb.core.passive.resistor", quantity: 1 }],
+ });
+ expect(placed.structuredContent.ok).toBe(true);
+ },
+ (evts) => evts.some((e) => e.type === "design.changed"),
+ );
+ const change = events.find((e) => e.type === "design.changed");
+ expect(change?.designId).toBe(designId);
+ expect(change?.actor).toBe("assistant");
+ expect(typeof change?.revision).toBe("number");
+ });
+
+ test("undo, focus requests and deletes are announced", async () => {
+ const design = await h.designer.createDesign({ name: "Lifecycle" });
+ await uiCommand(design.id, 0, {
+ type: "place_part",
+ componentId: "openpcb.core.passive.resistor",
+ positionNm: { x: 0, y: 0 },
+ });
+ const events = await collectEvents(
+ null,
+ async () => {
+ await h.designer.undo(design.id, UI_SESSION);
+ expect(h.designer.requestFocus(design.id).delivered).toBe(true);
+ expect(await h.designer.deleteDesign(design.id)).toBe(true);
+ },
+ (evts) => evts.some((e) => e.type === "design.deleted"),
+ );
+ const types = events.filter((e) => e.designId === design.id).map((e) => e.type);
+ expect(types).toContain("design.changed");
+ expect(types).toContain("design.focus");
+ expect(types).toContain("design.deleted");
+ expect(events.find((e) => e.type === "design.changed" && e.designId === design.id)?.source).toBe("undo");
+ expect(await h.designer.deleteDesign(design.id)).toBe(false);
+ });
+});
diff --git a/src/core/backend/tests/drc-engine.test.ts b/src/core/backend/tests/drc-engine.test.ts
index c56ac67b..f5ba478a 100644
--- a/src/core/backend/tests/drc-engine.test.ts
+++ b/src/core/backend/tests/drc-engine.test.ts
@@ -933,6 +933,40 @@ describe("S6 §8 — options default from the projection", () => {
),
).toContain("TRACE_TO_TRACE_CLEARANCE");
});
+
+ test("hidden violations are counted, never silently dropped", () => {
+ const base = runDrc(clearancePair());
+ // Nothing hidden: the report keeps its exact pre-existing shape.
+ expect(base.suppressed).toBeUndefined();
+ const clearanceIds = new Set(
+ base.violations.filter((v) => v.ruleClass === "clearance").map((v) => v.id),
+ );
+ expect(clearanceIds.size).toBeGreaterThan(0);
+
+ const ignored = runDrc(
+ clearancePair({
+ viewState: { ...board().viewState!, drcIgnoredRuleClasses: ["clearance"] },
+ }),
+ );
+ expect(ignored.suppressed).toEqual({
+ byRuleClass: clearanceIds.size,
+ bySeverityOverride: 0,
+ });
+
+ const overridden = runDrc(
+ clearancePair({ drcSeverityOverrides: { TRACE_TO_TRACE_CLEARANCE: "ignore" } }),
+ );
+ expect(overridden.suppressed?.bySeverityOverride).toBeGreaterThan(0);
+
+ // A waiver keeps the violation listed (waived: true) — not "suppressed".
+ const id = [...clearanceIds][0]!;
+ const waived = runDrc(
+ clearancePair({
+ viewState: { ...board().viewState!, drcWaivedViolationIds: [id] },
+ }),
+ );
+ expect(waived.suppressed).toBeUndefined();
+ });
});
/** S6 §2.1 / §10 — every reason the resolver can refuse a rule for. */
diff --git a/src/core/backend/tests/helpers/mcp-harness.ts b/src/core/backend/tests/helpers/mcp-harness.ts
new file mode 100644
index 00000000..07efe102
--- /dev/null
+++ b/src/core/backend/tests/helpers/mcp-harness.ts
@@ -0,0 +1,192 @@
+/**
+ * Boots the real module runtime (designer + library + assistant + tasks +
+ * knowledge, from `src/modules`) behind the real HTTP server, and speaks MCP
+ * JSON-RPC to `/api/modules/assistant/mcp` the way the bundled shim does.
+ *
+ * Use it for MCP tests that need real designs: designer tools, proposals,
+ * chat recording. Pure endpoint tests (auth, gating) can keep the lighter
+ * two-module harness in `assistant-mcp-endpoint.test.ts`.
+ */
+
+import { existsSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ getAssistantService,
+ resetAssistantServiceForTesting,
+} from "../../../../modules/assistant/backend/assistant-service";
+import { resetTaskRuntimeForTesting } from "../../../../modules/tasks/backend/runtime-singleton";
+import { MODULE_SDK_TOKENS, type DesignerSDK } from "../../../../sdks";
+import { resetSharedSqliteForTesting } from "../../db/sqlite-client";
+import { DiagnosticsStore } from "../../diagnostics/diagnostics-store";
+import { createHttpServer } from "../../http/create-http-server";
+import { MentionRegistry } from "../../mentions";
+import { ModuleRuntime } from "../../modules/module-loader";
+import { ModuleRouterRegistry } from "../../router/module-registry";
+
+export const MCP_TOKEN = "test-mcp-token";
+export const MCP_ORIGIN = "http://127.0.0.1";
+export const MCP_URL = `${MCP_ORIGIN}/api/modules/assistant/mcp`;
+
+const SRC_ROOT = path.resolve(import.meta.dir, "../../../..");
+const PACK_DIR = path.resolve(SRC_ROOT, "../resources/core-library");
+
+/**
+ * The bundled CoreLibrary pack. Pinned (unpinned, the locator prefers a
+ * sibling `../CoreLibrary/dist/*-dev.opclib`, which would make tests
+ * machine-dependent). CI fetches the newest release; a clean checkout carries
+ * an older one — take whichever is present, newest first.
+ */
+function bundledPack(): string | undefined {
+ for (const name of [
+ "openpcb-core-library-0.1.0-beta.2.opclib",
+ "openpcb-core-library-0.1.0-beta.1.opclib",
+ ]) {
+ const candidate = path.join(PACK_DIR, name);
+ if (existsSync(candidate)) return candidate;
+ }
+ return undefined;
+}
+
+export interface McpHarness {
+ server: ReturnType;
+ runtime: ModuleRuntime;
+ designer: DesignerSDK;
+ /** Turn the MCP server on, optionally with writes. */
+ enable(options?: { writes?: boolean }): void;
+ /** Raw JSON-RPC POST; returns the parsed JSON-RPC response body. */
+ rpc(
+ body: Record,
+ headers?: Record,
+ ): Promise>;
+ listTools(headers?: Record): Promise>>;
+ /** `tools/call`; returns the MCP `CallToolResult`. */
+ callTool(
+ name: string,
+ args?: Record,
+ headers?: Record,
+ ): Promise;
+ fetch(pathname: string, init?: RequestInit): Promise;
+}
+
+export interface McpToolCallResult {
+ content: Array<{ type: string; text: string }>;
+ structuredContent: {
+ ok: boolean;
+ status: string;
+ summary: string;
+ warnings: string[];
+ error?: { message: string };
+ proposal?: {
+ id: string;
+ kind: string;
+ status: string;
+ riskLevel: string | null;
+ approvalHint?: string;
+ };
+ data: unknown;
+ };
+ isError?: boolean;
+}
+
+/** Streamable HTTP may answer with JSON or SSE; return the JSON-RPC response. */
+export async function readRpc(response: Response): Promise> {
+ const text = await response.text();
+ const contentType = response.headers.get("content-type") ?? "";
+ if (!contentType.includes("text/event-stream")) {
+ return JSON.parse(text) as Record;
+ }
+ const frames = text
+ .split("\n")
+ .filter((line) => line.startsWith("data:"))
+ .map((line) => JSON.parse(line.slice(5).trim()) as Record);
+ // The response is the frame carrying `result` or `error`; progress
+ // notifications may precede it.
+ const final = frames.find((f) => "result" in f || "error" in f);
+ if (!final) throw new Error(`No JSON-RPC response frame in: ${text}`);
+ return final;
+}
+
+let requestId = 100;
+
+export function defaultMcpHeaders(): Record {
+ return {
+ "content-type": "application/json",
+ accept: "application/json, text/event-stream",
+ authorization: `Bearer ${MCP_TOKEN}`,
+ "x-openpcb-mcp-client": "claude-code",
+ "x-openpcb-mcp-client-name": "Claude Code",
+ "x-openpcb-mcp-instance": "instance-a",
+ };
+}
+
+export async function bootMcpHarness(label: string): Promise {
+ process.env.OPENPCB_MCP_TOKEN = MCP_TOKEN;
+ delete process.env.OPENPCB_FEATURE_MCP_SERVER;
+ const pack = bundledPack();
+ if (pack) process.env.OPENPCB_BUNDLED_LIBRARY_PATH = pack;
+ resetSharedSqliteForTesting();
+ resetTaskRuntimeForTesting();
+ resetAssistantServiceForTesting();
+ process.env.OPENPCB_DB_PATH = path.join(
+ os.tmpdir(),
+ `${label}-${Date.now()}-${crypto.randomUUID()}.sqlite`,
+ );
+ MentionRegistry.init();
+
+ const moduleRegistry = new ModuleRouterRegistry();
+ const runtime = new ModuleRuntime({ moduleRegistry, workspaceRoot: SRC_ROOT });
+ await runtime.bootstrap();
+ const server = createHttpServer({
+ diagnosticsStore: new DiagnosticsStore(),
+ moduleRegistry,
+ moduleRuntime: runtime,
+ });
+ const designer = runtime
+ .getSdkRegistry()
+ .resolve(MODULE_SDK_TOKENS.DESIGNER);
+
+ const rpc: McpHarness["rpc"] = async (body, headers) => {
+ const response = await server.fetch(
+ new Request(MCP_URL, {
+ method: "POST",
+ headers: { ...defaultMcpHeaders(), ...(headers ?? {}) },
+ body: JSON.stringify({ jsonrpc: "2.0", ...body }),
+ }),
+ );
+ return readRpc(response);
+ };
+
+ return {
+ server,
+ runtime,
+ designer,
+ enable(options = {}) {
+ getAssistantService().updateSettings({
+ mcpEnabled: true,
+ mcpAllowWrites: options.writes === true,
+ });
+ },
+ rpc,
+ async listTools(headers) {
+ const body = await rpc({ id: ++requestId, method: "tools/list" }, headers);
+ if (body.error) throw new Error(JSON.stringify(body.error));
+ return (body.result as { tools: Array> }).tools;
+ },
+ async callTool(name, args = {}, headers) {
+ const body = await rpc(
+ {
+ id: ++requestId,
+ method: "tools/call",
+ params: { name, arguments: args },
+ },
+ headers,
+ );
+ if (body.error) throw new Error(JSON.stringify(body.error));
+ return body.result as McpToolCallResult;
+ },
+ fetch(pathname, init) {
+ return server.fetch(new Request(`${MCP_ORIGIN}${pathname}`, init));
+ },
+ };
+}
diff --git a/src/core/backend/tests/mcp-claude-code-setup.test.ts b/src/core/backend/tests/mcp-claude-code-setup.test.ts
new file mode 100644
index 00000000..4de56835
--- /dev/null
+++ b/src/core/backend/tests/mcp-claude-code-setup.test.ts
@@ -0,0 +1,547 @@
+/**
+ * Claude Code setup (electron/src/main/*): the stable launcher, the setup
+ * snippets, the generated local plugin (with a drift check against the live
+ * MCP tool list) and the one-click CLI flow. Pure modules, tested under Bun
+ * because the electron workspace has no test runner.
+ */
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import { chmodSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ mcpSnippets,
+ posixLauncher,
+ resolveLauncherExec,
+ stdioServerConfig,
+ windowsLauncher,
+} from "../../../../electron/src/main/mcp-launcher-content";
+import {
+ buildPluginMarketplace,
+ toolNamesInSkill,
+} from "../../../../electron/src/main/claude-plugin-content";
+import {
+ claudeCodeStatus,
+ claudeInvocation,
+ connectClaudeCode,
+ disconnectClaudeCode,
+ parseMcpGet,
+ type CliEnv,
+ type RunResult,
+ type StatusInput,
+} from "../../../../electron/src/main/claude-code-cli";
+import {
+ clearRegistration,
+ readRegistration,
+ writeRegistration,
+} from "../../../../electron/src/main/claude-registration";
+import {
+ cmdShimTarget,
+ escapeCmdArgument,
+ escapeCmdCommand,
+} from "../../../../electron/src/main/win-cmd";
+import type { StdioServerConfig } from "../../../../electron/src/main/mcp-launcher-content";
+import { bootMcpHarness, MCP_TOKEN, type McpHarness } from "./helpers/mcp-harness";
+
+const REPO = path.resolve(import.meta.dir, "../../../..");
+const TEMPLATE = path.join(REPO, "electron/resources/claude-plugin/openpcb");
+
+function readTree(root: string): Record {
+ const files: Record = {};
+ const walk = (dir: string) => {
+ for (const entry of readdirSync(dir)) {
+ const full = path.join(dir, entry);
+ if (statSync(full).isDirectory()) walk(full);
+ else files[path.relative(root, full).split(path.sep).join("/")] = readFileSync(full, "utf8");
+ }
+ };
+ walk(root);
+ return files;
+}
+
+describe("launcher exec resolution", () => {
+ test("AppImage and portable builds exec the file the user launched, not the temp mount", () => {
+ expect(
+ resolveLauncherExec({
+ platform: "linux",
+ execPath: "/tmp/.mount_OpenPCBx/openpcb",
+ env: { APPIMAGE: "/home/u/Apps/OpenPCB.AppImage" },
+ }).exec,
+ ).toBe("/home/u/Apps/OpenPCB.AppImage");
+ expect(
+ resolveLauncherExec({
+ platform: "win32",
+ execPath: "C:\\Users\\u\\AppData\\Local\\Temp\\x\\OpenPCB.exe",
+ env: { PORTABLE_EXECUTABLE_FILE: "D:\\Tools\\OpenPCB-Portable.exe" },
+ }).exec,
+ ).toBe("D:\\Tools\\OpenPCB-Portable.exe");
+ });
+
+ test("a translocated macOS app keeps the last good exec and warns", () => {
+ const r = resolveLauncherExec({
+ platform: "darwin",
+ execPath: "/private/var/folders/x/AppTranslocation/ABC/d/OpenPCB.app/Contents/MacOS/OpenPCB",
+ env: {},
+ previousExec: "/Applications/OpenPCB.app/Contents/MacOS/OpenPCB",
+ });
+ expect(r.kind).toBe("translocated");
+ expect(r.exec).toBe("/Applications/OpenPCB.app/Contents/MacOS/OpenPCB");
+ expect(r.warning).toContain("Applications");
+ });
+
+ test("an installed app execs itself", () => {
+ const r = resolveLauncherExec({
+ platform: "darwin",
+ execPath: "/Applications/OpenPCB.app/Contents/MacOS/OpenPCB",
+ env: {},
+ });
+ expect(r).toEqual({
+ exec: "/Applications/OpenPCB.app/Contents/MacOS/OpenPCB",
+ kind: "installed",
+ warning: null,
+ });
+ });
+});
+
+describe("setup snippets", () => {
+ const WIN_TARGET = {
+ launcherPath: "C:\\Users\\Jo & Ann\\AppData\\Roaming\\OpenPCB\\mcp\\openpcb-mcp.cmd",
+ exec: "C:\\Users\\Jo & Ann\\AppData\\Local\\Programs\\OpenPCB\\OpenPCB.exe",
+ shimPath: "C:\\Users\\Jo & Ann\\AppData\\Roaming\\OpenPCB\\mcp\\shim.js",
+ };
+
+ test("Windows registers the app binary with ELECTRON_RUN_AS_NODE — no cmd.exe in the transport", () => {
+ expect(stdioServerConfig("win32", WIN_TARGET)).toEqual({
+ type: "stdio",
+ command: WIN_TARGET.exec,
+ args: [WIN_TARGET.shimPath],
+ env: { ELECTRON_RUN_AS_NODE: "1" },
+ });
+ const snippets = mcpSnippets({ platform: "win32", target: WIN_TARGET, marketplaceDir: null });
+ const server = snippets.find((s) => s.id === "claude-code-server")!;
+ expect(server.hint).toContain("PowerShell");
+ expect(server.value).toBe(
+ `claude mcp add --scope user openpcb -e ELECTRON_RUN_AS_NODE=1 -- '${WIN_TARGET.exec}' '${WIN_TARGET.shimPath}'`,
+ );
+ const desktop = JSON.parse(snippets.find((s) => s.id === "claude-desktop")!.value);
+ expect(desktop.mcpServers.openpcb).toEqual({
+ command: WIN_TARGET.exec,
+ args: [WIN_TARGET.shimPath],
+ env: { ELECTRON_RUN_AS_NODE: "1" },
+ });
+ });
+
+ test("the fallback .cmd launcher survives %, & and non-ASCII paths", () => {
+ const launcher = windowsLauncher({ exec: "C:\\50% off\\Jo & Ann\\OpenPCB.exe", shimPath: "C:\\Zoë\\shim.js" });
+ expect(launcher).toContain('set "EXEC_PATH=C:\\50%% off\\Jo & Ann\\OpenPCB.exe"');
+ expect(launcher).toContain("chcp 65001");
+ expect(launcher).toContain('set "ELECTRON_RUN_AS_NODE=1"');
+ expect(launcher).toContain('no longer at "%EXEC_PATH%"');
+ expect(launcher).toContain("\r\n");
+ });
+
+ test("macOS / Linux register the launcher for every project and quote paths with spaces", () => {
+ const snippets = mcpSnippets({
+ platform: "darwin",
+ target: {
+ launcherPath: "/Users/u/Library/Application Support/OpenPCB/mcp/openpcb-mcp",
+ exec: "/Applications/OpenPCB.app/Contents/MacOS/OpenPCB",
+ shimPath: "/Users/u/Library/Application Support/OpenPCB/mcp/shim.js",
+ },
+ marketplaceDir: "/Users/u/Library/Application Support/OpenPCB/claude-code/marketplace",
+ });
+ const server = snippets.find((s) => s.id === "claude-code-server")!.value;
+ expect(server).toBe(
+ "claude mcp add --scope user openpcb -- '/Users/u/Library/Application Support/OpenPCB/mcp/openpcb-mcp'",
+ );
+ const plugin = snippets.find((s) => s.id === "claude-code-plugin")!.value;
+ expect(plugin).toContain("claude plugin marketplace add '/Users/u/Library/Application Support/OpenPCB/claude-code/marketplace'");
+ expect(plugin).toContain("claude plugin install openpcb@openpcb-desktop --scope user");
+ const desktop = JSON.parse(snippets.find((s) => s.id === "claude-desktop")!.value);
+ expect(desktop.mcpServers.openpcb.command).toContain("openpcb-mcp");
+ });
+});
+
+describe("Windows command lines", () => {
+ // cross-spawn's own implementation is the reference for the port.
+ const reference = require("cross-spawn/lib/util/escape") as {
+ argument(arg: string, doubleEscape?: boolean): string;
+ command(cmd: string): string;
+ };
+ const vectors = [
+ "plain",
+ "with space",
+ "C:\\Users\\Jo & Ann\\x",
+ "50% off",
+ "caret^and(paren)",
+ 'quote"inside',
+ "trailing\\",
+ 'back\\"slash-quote',
+ "Zoë 😀 <>|;,*?!`[]",
+ "{\"type\":\"stdio\",\"command\":\"C:\\\\x y\\\\a.exe\"}",
+ ];
+
+ test("argument escaping matches cross-spawn for awkward inputs", () => {
+ for (const vector of vectors) {
+ expect(escapeCmdArgument(vector)).toBe(reference.argument(vector));
+ expect(escapeCmdArgument(vector, true)).toBe(reference.argument(vector, true));
+ expect(escapeCmdCommand(vector)).toBe(reference.command(vector));
+ }
+ });
+
+ test("an npm cmd-shim is resolved to node + script, so cmd.exe is never involved", () => {
+ const shim = [
+ "@ECHO off",
+ "GOTO start",
+ ":find_dp0",
+ "SET dp0=%~dp0",
+ "EXIT /b",
+ ":start",
+ "SETLOCAL",
+ "CALL :find_dp0",
+ "",
+ 'IF EXIST "%dp0%\\node.exe" (',
+ ' SET "_prog=%dp0%\\node.exe"',
+ ") ELSE (",
+ ' SET "_prog=node"',
+ " SET PATHEXT=%PATHEXT:;.JS;=;%",
+ ")",
+ "",
+ 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*',
+ ].join("\r\n");
+ expect(cmdShimTarget(shim)).toEqual({
+ path: "node_modules\\@anthropic-ai\\claude-code\\cli.js",
+ kind: "script",
+ });
+ const cli = "C:\\Users\\Jo & Ann\\AppData\\Roaming\\npm\\claude.cmd";
+ const script = "C:\\Users\\Jo & Ann\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js";
+ const node = "C:\\Program Files\\nodejs\\node.exe";
+ const env: CliEnv = {
+ platform: "win32",
+ env: { PATH: "C:\\Program Files\\nodejs;C:\\Windows" },
+ homedir: "C:\\Users\\Jo & Ann",
+ exists: (p) => p === cli || p === script || p === node,
+ readFile: (p) => (p === cli ? shim : null),
+ run: async () => ({ code: 0, stdout: "", stderr: "" }),
+ };
+ expect(claudeInvocation(env, cli)).toEqual({ file: node, prefix: [script] });
+ // An unreadable .cmd falls back to cmd.exe with every argument escaped.
+ const fallback = claudeInvocation({ ...env, readFile: () => null }, cli);
+ expect(fallback.file).toBe("cmd.exe");
+ expect(fallback.cmdLine!(["mcp", "get", "a & b"])).toBe(
+ `"${reference.command(cli)} ${reference.argument("mcp", true)} ${reference.argument("get", true)} ${reference.argument("a & b", true)}"`,
+ );
+ });
+});
+
+describe("registration record", () => {
+ test("round-trips and keeps the installation id across reconnects", () => {
+ const dir = mkdtempSync(path.join(os.tmpdir(), "openpcb-reg-"));
+ try {
+ expect(readRegistration(dir)).toBeNull();
+ const config: StdioServerConfig = { type: "stdio", command: "/x/openpcb-mcp", args: [] };
+ const first = writeRegistration(dir, {
+ mode: "server",
+ serverName: "openpcb",
+ scope: "user",
+ config,
+ appVersion: "1.0.0",
+ });
+ const second = writeRegistration(dir, {
+ mode: "plugin",
+ serverName: "openpcb",
+ scope: "user",
+ config,
+ appVersion: "1.0.1",
+ });
+ expect(second.installationId).toBe(first.installationId);
+ expect(readRegistration(dir)?.mode).toBe("plugin");
+ clearRegistration(dir);
+ expect(readRegistration(dir)).toBeNull();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
+
+describe("generated plugin", () => {
+ let h: McpHarness;
+ beforeAll(async () => {
+ h = await bootMcpHarness("mcp-claude-code-setup");
+ });
+
+ test("marketplace, manifest and .mcp.json point at the launcher, version = app", () => {
+ const files = buildPluginMarketplace({
+ appVersion: "9.9.9",
+ templateFiles: readTree(TEMPLATE),
+ server: { command: "C:\\A\\OpenPCB.exe", args: ["C:\\B\\shim.js"], env: { ELECTRON_RUN_AS_NODE: "1" } },
+ });
+ const market = JSON.parse(files[".claude-plugin/marketplace.json"]!);
+ expect(market.name).toBe("openpcb-desktop");
+ expect(market.plugins[0]).toMatchObject({ name: "openpcb", source: "./openpcb", version: "9.9.9" });
+ expect(JSON.parse(files["openpcb/.claude-plugin/plugin.json"]!).version).toBe("9.9.9");
+ expect(JSON.parse(files["openpcb/.mcp.json"]!)).toEqual({
+ mcpServers: {
+ openpcb: { command: "C:\\A\\OpenPCB.exe", args: ["C:\\B\\shim.js"], env: { ELECTRON_RUN_AS_NODE: "1" } },
+ },
+ });
+ expect(Object.keys(files).filter((f) => f.endsWith("SKILL.md")).length).toBeGreaterThanOrEqual(6);
+ expect(files["openpcb/README.md"]).toBeUndefined();
+ });
+
+ test("every tool a skill names exists on the MCP server (writes on)", async () => {
+ h.enable({ writes: true });
+ const listed = new Set((await h.listTools()).map((t) => t.name as string));
+ const template = readTree(TEMPLATE);
+ for (const [file, contents] of Object.entries(template)) {
+ if (!file.endsWith("SKILL.md")) continue;
+ for (const name of toolNamesInSkill(contents)) {
+ if (!listed.has(name)) throw new Error(`${file} names unknown tool ${name}`);
+ }
+ }
+ });
+
+ test("the generated POSIX launcher starts the bridge and serves a real MCP client", async () => {
+ if (process.platform === "win32") return;
+ const { Client } = await import("@modelcontextprotocol/client");
+ const { StdioClientTransport } = await import("@modelcontextprotocol/client/stdio");
+ h.enable();
+ const dir = mkdtempSync(path.join(os.tmpdir(), "openpcb-launcher-"));
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => h.server.fetch(req) });
+ try {
+ const portfile = path.join(dir, "mcp.json");
+ writeFileSync(
+ portfile,
+ JSON.stringify({
+ version: 1,
+ url: `http://127.0.0.1:${server.port}/api/modules/assistant/mcp`,
+ port: server.port,
+ token: MCP_TOKEN,
+ pid: process.pid,
+ appVersion: "test",
+ }),
+ );
+ // Bun stands in for the Electron binary: it ignores ELECTRON_RUN_AS_NODE
+ // and runs the bridge source directly.
+ const launcher = path.join(dir, "openpcb-mcp");
+ writeFileSync(
+ launcher,
+ posixLauncher({
+ exec: process.execPath,
+ shimPath: path.join(REPO, "electron/src/mcp-shim/index.ts"),
+ }),
+ );
+ chmodSync(launcher, 0o755);
+ const client = new Client({ name: "launcher-e2e", version: "1.0.0" });
+ await client.connect(
+ new StdioClientTransport({
+ command: launcher,
+ args: [],
+ env: { ...(process.env as Record), OPENPCB_MCP_PORTFILE: portfile },
+ stderr: "ignore",
+ }),
+ );
+ try {
+ const tools = await client.listTools();
+ expect(tools.tools.map((t) => t.name)).toContain("designer_get_pcb_layout");
+ } finally {
+ await client.close();
+ }
+ } finally {
+ server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }, 30_000);
+});
+
+/** A scripted `claude` CLI. */
+function fakeCli(script: (args: string[]) => RunResult) {
+ const calls: string[][] = [];
+ const env: CliEnv = {
+ platform: "linux",
+ env: { PATH: "/fake/bin", SHELL: "/bin/sh" },
+ homedir: "/home/u",
+ exists: (p) => p === "/fake/bin/claude",
+ run: async (file, args) => {
+ if (file !== "/fake/bin/claude") return { code: 1, stdout: "", stderr: "" };
+ calls.push(args);
+ return script(args);
+ },
+ };
+ return { env, calls };
+}
+
+const OK = (stdout = ""): RunResult => ({ code: 0, stdout, stderr: "" });
+const MISSING: RunResult = { code: 1, stdout: "", stderr: 'No MCP server named "openpcb". Configured servers: ' };
+const LAUNCHER = "/home/u/.config/OpenPCB/mcp/openpcb-mcp";
+const SERVER: StdioServerConfig = { type: "stdio", command: LAUNCHER, args: [] };
+const MARKET = "/home/u/.config/OpenPCB/claude-code/marketplace";
+
+/** `claude mcp get` as Claude Code 2.1.282 prints it (captured from the real CLI). */
+function mcpGet(command: string, args = "", env: Record = {}, scope = "User config (available in all your projects)"): RunResult {
+ const envLines = Object.entries(env).map(([k, v]) => ` ${k}=${v}`);
+ return OK(
+ [
+ "openpcb:",
+ ` Scope: ${scope}`,
+ " Status: ✓ Connected",
+ " Type: stdio",
+ ` Command: ${command}`,
+ ` Args: ${args}`,
+ " Environment:",
+ ...envLines,
+ "",
+ "To remove this server, run: claude mcp remove openpcb -s user",
+ ].join("\n"),
+ );
+}
+
+function statusInput(over: Partial = {}): StatusInput {
+ return { appVersion: "1.0.0", expectedServer: SERVER, registration: null, marketplaceDir: MARKET, ...over };
+}
+
+describe("mcp get parsing", () => {
+ test("reads scope, command, args and environment from the real output format", () => {
+ const info = parseMcpGet(
+ mcpGet("C:\\Program Files\\OpenPCB\\OpenPCB.exe", "C:\\Users\\Jo & Ann\\shim.js", { ELECTRON_RUN_AS_NODE: "1" }).stdout,
+ );
+ expect(info).toEqual({
+ scope: "User config (available in all your projects)",
+ type: "stdio",
+ command: "C:\\Program Files\\OpenPCB\\OpenPCB.exe",
+ args: "C:\\Users\\Jo & Ann\\shim.js",
+ env: { ELECTRON_RUN_AS_NODE: "1" },
+ });
+ });
+});
+
+describe("one-click connect", () => {
+ test("plugin mode adds the marketplace, installs, and drops our duplicate server", async () => {
+ let installed = false;
+ const { env, calls } = fakeCli((args) => {
+ const cmd = args.join(" ");
+ if (cmd === "--version") return OK("2.1.282 (Claude Code)");
+ if (cmd === "plugin list --json") return OK(installed ? '[{"id":"openpcb@openpcb-desktop","version":"1.0.0"}]' : "[]");
+ if (cmd === "plugin marketplace list --json") return OK("[]");
+ if (cmd === "mcp get openpcb") return mcpGet(LAUNCHER);
+ if (cmd.startsWith("plugin install")) installed = true;
+ return OK();
+ });
+ const result = await connectClaudeCode(env, { ...statusInput(), mode: "plugin", serverConfig: SERVER });
+ expect(result.ok).toBe(true);
+ expect(result.message).toContain("/reload-plugins");
+ expect(result.registration).toMatchObject({ mode: "plugin", config: SERVER, marketplaceDir: MARKET });
+ const joined = calls.map((c) => c.join(" "));
+ expect(joined).toContain(`plugin marketplace add ${MARKET}`);
+ expect(joined).toContain("plugin install openpcb@openpcb-desktop --scope user");
+ expect(joined).toContain("mcp remove openpcb --scope user");
+ const status = await claudeCodeStatus(env, statusInput({ registration: { installationId: "i", registeredAt: "t", ...result.registration! } }));
+ expect(status.plugin.installed).toBe(true);
+ expect(status.updateAvailable).toBe(false);
+ });
+
+ test("an installed older plugin is updated in place", async () => {
+ const { env, calls } = fakeCli((args) => {
+ const cmd = args.join(" ");
+ if (cmd === "plugin list --json") return OK('[{"id":"openpcb@openpcb-desktop","version":"0.9.0"}]');
+ if (cmd === "plugin marketplace list --json") return OK(`[{"name":"openpcb-desktop","path":"${MARKET}"}]`);
+ if (cmd === "mcp get openpcb") return MISSING;
+ return OK();
+ });
+ const status = await claudeCodeStatus(env, statusInput());
+ expect(status.updateAvailable).toBe(true);
+ await connectClaudeCode(env, { ...statusInput(), mode: "plugin", serverConfig: SERVER });
+ const joined = calls.map((c) => c.join(" "));
+ expect(joined).toContain("plugin marketplace update openpcb-desktop");
+ expect(joined).toContain("plugin update openpcb@openpcb-desktop --scope user");
+ });
+
+ test("a same-named marketplace that is not ours is left alone", async () => {
+ const { env, calls } = fakeCli((args) => {
+ const cmd = args.join(" ");
+ if (cmd === "plugin list --json") return OK("[]");
+ if (cmd === "plugin marketplace list --json") return OK('[{"name":"openpcb-desktop","path":"/somewhere/else"}]');
+ if (cmd === "mcp get openpcb") return MISSING;
+ return OK();
+ });
+ const result = await connectClaudeCode(env, { ...statusInput(), mode: "plugin", serverConfig: SERVER });
+ expect(result.ok).toBe(false);
+ expect(calls.map((c) => c.join(" ")).some((c) => c.startsWith("plugin marketplace update"))).toBe(false);
+ });
+
+ test("ownership is exact: a look-alike path or a project-scope server is not ours", async () => {
+ for (const other of [
+ mcpGet("/usr/local/bin/openpcb-mcp"),
+ mcpGet(`${LAUNCHER}-fork`),
+ mcpGet(LAUNCHER, "", {}, "Project config (shared via .mcp.json)"),
+ ]) {
+ const { env, calls } = fakeCli((args) => {
+ const cmd = args.join(" ");
+ if (cmd === "plugin list --json" || cmd === "plugin marketplace list --json") return OK("[]");
+ if (cmd === "mcp get openpcb") return other;
+ return OK();
+ });
+ const result = await connectClaudeCode(env, { ...statusInput(), mode: "server", serverConfig: SERVER });
+ expect(result.ok).toBe(false);
+ expect(result.message).toContain("did not register");
+ const disconnect = await disconnectClaudeCode(env, statusInput());
+ expect(disconnect.ok).toBe(true);
+ expect(calls.map((c) => c.join(" ")).some((c) => c.startsWith("mcp remove"))).toBe(false);
+ }
+ });
+
+ test("an app that moved is detected from the record and offered as an update", async () => {
+ const oldExec = "D:\\Old\\OpenPCB.exe";
+ const shim = "C:\\Users\\u\\AppData\\Roaming\\OpenPCB\\mcp\\shim.js";
+ const recorded: StdioServerConfig = { type: "stdio", command: oldExec, args: [shim], env: { ELECTRON_RUN_AS_NODE: "1" } };
+ const expected: StdioServerConfig = { ...recorded, command: "C:\\Programs\\OpenPCB\\OpenPCB.exe" };
+ const { env } = fakeCli((args) => {
+ const cmd = args.join(" ");
+ if (cmd === "plugin list --json" || cmd === "plugin marketplace list --json") return OK("[]");
+ if (cmd === "mcp get openpcb") return mcpGet(oldExec, shim, { ELECTRON_RUN_AS_NODE: "1" });
+ return OK();
+ });
+ const status = await claudeCodeStatus(env, {
+ appVersion: "1.0.0",
+ expectedServer: expected,
+ registration: { installationId: "i", registeredAt: "t", mode: "server", serverName: "openpcb", scope: "user", config: recorded, appVersion: "0.9.0" },
+ });
+ expect(status.server.ownedByOpenPcb).toBe(true);
+ expect(status.server.outdated).toBe(true);
+ expect(status.updateAvailable).toBe(true);
+ expect(status.registeredMode).toBe("server");
+ });
+
+ test("server mode registers at user scope with mcp add; disconnect removes only ours", async () => {
+ let registered = false;
+ const { env, calls } = fakeCli((args) => {
+ const cmd = args.join(" ");
+ if (cmd === "plugin list --json" || cmd === "plugin marketplace list --json") return OK("[]");
+ if (cmd === "mcp get openpcb") return registered ? mcpGet(LAUNCHER) : MISSING;
+ if (args[0] === "mcp" && args[1] === "add") registered = true;
+ if (args[0] === "mcp" && args[1] === "remove") registered = false;
+ return OK();
+ });
+ const connect = await connectClaudeCode(env, { ...statusInput(), mode: "server", serverConfig: SERVER });
+ expect(connect.ok).toBe(true);
+ const add = calls.find((c) => c[0] === "mcp" && c[1] === "add")!;
+ expect(add).toEqual(["mcp", "add", "--scope", "user", "openpcb", "--", LAUNCHER]);
+ const disconnect = await disconnectClaudeCode(env, statusInput());
+ expect(disconnect.ok).toBe(true);
+ expect(disconnect.clearRegistration).toBe(true);
+ expect(registered).toBe(false);
+ });
+
+ test("no CLI → an actionable message, nothing run", async () => {
+ const env: CliEnv = {
+ platform: "linux",
+ env: { PATH: "/nowhere", SHELL: "/bin/sh" },
+ homedir: "/home/u",
+ exists: () => false,
+ run: async () => ({ code: 1, stdout: "", stderr: "" }),
+ };
+ const result = await connectClaudeCode(env, { ...statusInput(), mode: "plugin", serverConfig: SERVER });
+ expect(result.ok).toBe(false);
+ expect(result.message).toContain("not found");
+ });
+});
diff --git a/src/core/backend/tests/mcp-shim-bridge.test.ts b/src/core/backend/tests/mcp-shim-bridge.test.ts
new file mode 100644
index 00000000..6339ab23
--- /dev/null
+++ b/src/core/backend/tests/mcp-shim-bridge.test.ts
@@ -0,0 +1,440 @@
+/**
+ * The stdio bridge (`electron/src/mcp-shim/bridge.ts`) against a real backend
+ * on a real loopback port: the failure modes that made the old pipe unusable
+ * from Claude Code — app not running, app restarting on a new port and token,
+ * MCP switched off, writes toggled, cancelled calls.
+ *
+ * Lives here (Bun) because the electron workspace has no test runner; the
+ * bridge modules are Electron-free by design.
+ */
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { McpBridge } from "../../../../electron/src/mcp-shim/bridge";
+import { resolveInstanceId } from "../../../../electron/src/mcp-shim/instance";
+import {
+ processAlive,
+ type DiscoveryEnv,
+} from "../../../../electron/src/mcp-shim/portfile";
+import { readJsonRpcSse, type JsonRpcMessage } from "../../../../electron/src/mcp-shim/upstream";
+import { getAssistantService } from "../../../modules/assistant/backend/assistant-service";
+import { bootMcpHarness, MCP_TOKEN, type McpHarness } from "./helpers/mcp-harness";
+
+let h: McpHarness;
+let dir: string;
+let portfilePath: string;
+let servers: Array<{ stop(force?: boolean): void; port: number }> = [];
+
+function serve(): { stop(force?: boolean): void; port: number } {
+ const server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ fetch: (req) => h.server.fetch(req),
+ });
+ servers.push(server as never);
+ return server as never;
+}
+
+function writePortfile(port: number, token: string): void {
+ writeFileSync(
+ portfilePath,
+ JSON.stringify({
+ version: 1,
+ url: `http://127.0.0.1:${port}/api/modules/assistant/mcp`,
+ port,
+ token,
+ pid: process.pid,
+ appVersion: "test",
+ }),
+ );
+}
+
+function discovery(): DiscoveryEnv {
+ return {
+ env: { OPENPCB_MCP_PORTFILE: portfilePath },
+ platform: process.platform,
+ homedir: dir,
+ processAlive,
+ readFile: (p) => {
+ try {
+ return require("node:fs").readFileSync(p, "utf8") as string;
+ } catch {
+ return null;
+ }
+ },
+ };
+}
+
+function makeBridge(fetchImpl?: typeof fetch) {
+ const sent: JsonRpcMessage[] = [];
+ const bridge = new McpBridge({
+ discovery: discovery(),
+ instanceId: `bridge-${crypto.randomUUID()}`,
+ send: (m) => sent.push(m),
+ fetchImpl,
+ });
+ let nextId = 1;
+ async function request(method: string, params?: Record) {
+ const id = nextId++;
+ const before = sent.length;
+ await bridge.handleClientMessage({ jsonrpc: "2.0", id, method, params });
+ return sent.slice(before).find((m) => m.id === id)!;
+ }
+ async function init() {
+ const response = await request("initialize", {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "Claude Code", version: "2.1.282" },
+ });
+ await bridge.handleClientMessage({
+ jsonrpc: "2.0",
+ method: "notifications/initialized",
+ });
+ return response;
+ }
+ return { bridge, sent, request, init };
+}
+
+beforeAll(async () => {
+ h = await bootMcpHarness("mcp-shim-bridge");
+ dir = mkdtempSync(path.join(os.tmpdir(), "openpcb-bridge-"));
+ portfilePath = path.join(dir, "mcp.json");
+});
+
+afterAll(() => {
+ for (const server of servers) server.stop(true);
+ servers = [];
+ process.env.OPENPCB_MCP_TOKEN = MCP_TOKEN;
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("mcp bridge", () => {
+ test("answers initialize and explains itself while OpenPCB is not running", async () => {
+ rmSync(portfilePath, { force: true });
+ const { init, request } = makeBridge();
+ const init1 = await init();
+ const result = init1.result as {
+ instructions: string;
+ capabilities: { tools: { listChanged: boolean } };
+ };
+ expect(result.instructions).toContain("not running");
+ expect(result.capabilities.tools.listChanged).toBe(true);
+
+ const call = await request("tools/call", {
+ name: "designer_list_designs",
+ arguments: {},
+ });
+ const callResult = call.result as {
+ isError: boolean;
+ content: Array<{ text: string }>;
+ };
+ expect(callResult.isError).toBe(true);
+ expect(callResult.content[0]!.text).toContain("not running");
+
+ const discover = await request("server/discover");
+ expect(discover.error?.code).toBe(-32601);
+ expect((await request("ping")).result).toEqual({});
+ });
+
+ test("announces the tools once OpenPCB comes up, then forwards calls", async () => {
+ rmSync(portfilePath, { force: true });
+ h.enable();
+ const { bridge, sent, init, request } = makeBridge();
+ await init();
+ await bridge.poll();
+ const server = serve();
+ writePortfile(server.port, MCP_TOKEN);
+ const before = sent.length;
+ await bridge.poll();
+ expect(
+ sent.slice(before).some((m) => m.method === "notifications/tools/list_changed"),
+ ).toBe(true);
+
+ const tools = (await request("tools/list")).result as { tools: Array<{ name: string }> };
+ expect(tools.tools.map((t) => t.name)).toContain("designer_list_designs");
+ const call = await request("tools/call", {
+ name: "designer_list_designs",
+ arguments: {},
+ });
+ expect((call.result as { structuredContent: { ok: boolean } }).structuredContent.ok).toBe(true);
+ });
+
+ test("survives an app restart on a new port with a new token", async () => {
+ h.enable();
+ const first = serve();
+ writePortfile(first.port, MCP_TOKEN);
+ const { init, request } = makeBridge();
+ await init();
+ expect((await request("tools/list")).result).toBeTruthy();
+
+ first.stop(true);
+ const rotated = `rotated-${crypto.randomUUID()}`;
+ process.env.OPENPCB_MCP_TOKEN = rotated;
+ const second = serve();
+ writePortfile(second.port, rotated);
+
+ const call = await request("tools/call", {
+ name: "designer_list_designs",
+ arguments: {},
+ });
+ expect((call.result as { structuredContent: { ok: boolean } }).structuredContent.ok).toBe(true);
+ process.env.OPENPCB_MCP_TOKEN = MCP_TOKEN;
+ });
+
+ test("re-reads the token after a 401 on the same port", async () => {
+ h.enable();
+ const server = serve();
+ writePortfile(server.port, MCP_TOKEN);
+ const { init, request } = makeBridge();
+ await init();
+ await request("tools/list");
+ const rotated = `rotated-${crypto.randomUUID()}`;
+ process.env.OPENPCB_MCP_TOKEN = rotated;
+ writePortfile(server.port, rotated);
+ const call = await request("tools/call", {
+ name: "designer_list_designs",
+ arguments: {},
+ });
+ expect((call.result as { structuredContent: { ok: boolean } }).structuredContent.ok).toBe(true);
+ process.env.OPENPCB_MCP_TOKEN = MCP_TOKEN;
+ });
+
+ test("reports a disabled server as an actionable tool result", async () => {
+ const server = serve();
+ writePortfile(server.port, MCP_TOKEN);
+ getAssistantService().updateSettings({ mcpEnabled: false });
+ const { init, request } = makeBridge();
+ await init();
+ const call = await request("tools/call", {
+ name: "designer_list_designs",
+ arguments: {},
+ });
+ const result = call.result as { isError: boolean; content: Array<{ text: string }> };
+ expect(result.isError).toBe(true);
+ expect(result.content[0]!.text).toContain("Settings");
+ h.enable();
+ });
+
+ test("tells the client to re-list tools when writes are toggled", async () => {
+ h.enable({ writes: false });
+ const server = serve();
+ writePortfile(server.port, MCP_TOKEN);
+ const { bridge, sent, init } = makeBridge();
+ await init();
+ await bridge.poll();
+ const before = sent.length;
+ h.enable({ writes: true });
+ await bridge.poll();
+ expect(
+ sent.slice(before).some((m) => m.method === "notifications/tools/list_changed"),
+ ).toBe(true);
+ const quiet = sent.length;
+ await bridge.poll();
+ expect(sent.length).toBe(quiet);
+ });
+
+ test("re-lists after a restart even when a call's retry already followed it", async () => {
+ h.enable();
+ const first = serve();
+ writePortfile(first.port, MCP_TOKEN);
+ const { bridge, sent, init, request } = makeBridge();
+ await init();
+ await bridge.poll();
+ await bridge.poll();
+ first.stop(true);
+ const rotated = `rotated-${crypto.randomUUID()}`;
+ process.env.OPENPCB_MCP_TOKEN = rotated;
+ const second = serve();
+ writePortfile(second.port, rotated);
+ // The call's own retry discovers the new endpoint first…
+ await request("tools/call", { name: "designer_list_designs", arguments: {} });
+ const before = sent.length;
+ // …and the next poll still tells the client the server changed under it.
+ await bridge.poll();
+ expect(sent.slice(before).some((m) => m.method === "notifications/tools/list_changed")).toBe(true);
+ process.env.OPENPCB_MCP_TOKEN = MCP_TOKEN;
+ });
+
+ test("re-lists when the tool contracts change but not their number", async () => {
+ h.enable({ writes: false });
+ const server = serve();
+ writePortfile(server.port, MCP_TOKEN);
+ let tamper = false;
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const response = await fetch(input, init);
+ if (!tamper || !String(input).endsWith("/mcp-state")) return response;
+ const state = (await response.json()) as { toolset: string };
+ const [count] = state.toolset.split(":");
+ return Response.json({ ...state, toolset: `${count}:changed-schema-hash` });
+ }) as typeof fetch;
+ const { bridge, sent, init } = makeBridge(fetchImpl);
+ await init();
+ await bridge.poll();
+ await bridge.poll();
+ const before = sent.length;
+ tamper = true;
+ await bridge.poll();
+ expect(sent.slice(before).some((m) => m.method === "notifications/tools/list_changed")).toBe(true);
+ });
+
+ test("notifications/cancelled aborts the in-flight request", async () => {
+ writePortfile(1, MCP_TOKEN);
+ const hanging: typeof fetch = ((_url: unknown, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () =>
+ reject(new DOMException("aborted", "AbortError")),
+ );
+ })) as typeof fetch;
+ const { bridge, sent } = makeBridge(hanging);
+ const pending = bridge.handleClientMessage({
+ jsonrpc: "2.0",
+ id: 77,
+ method: "tools/call",
+ params: { name: "designer_run_drc", arguments: {} },
+ });
+ await Promise.resolve();
+ await bridge.handleClientMessage({
+ jsonrpc: "2.0",
+ method: "notifications/cancelled",
+ params: { requestId: 77 },
+ });
+ await pending;
+ const response = sent.find((m) => m.id === 77);
+ expect(response?.error?.code).toBe(-32800);
+ });
+});
+
+describe("bundled shim process", () => {
+ test("a real MCP client connects over stdio and calls a tool", async () => {
+ const { Client } = await import("@modelcontextprotocol/client");
+ const { StdioClientTransport } = await import("@modelcontextprotocol/client/stdio");
+ h.enable();
+ const server = serve();
+ writePortfile(server.port, MCP_TOKEN);
+
+ const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [path.resolve(import.meta.dir, "../../../../electron/src/mcp-shim/index.ts")],
+ env: { ...(process.env as Record), OPENPCB_MCP_PORTFILE: portfilePath },
+ stderr: "ignore",
+ });
+ const client = new Client({ name: "shim-e2e", version: "1.0.0" });
+ await client.connect(transport);
+ try {
+ const tools = await client.listTools();
+ expect(tools.tools.map((t) => t.name)).toContain("designer_list_designs");
+ const result = (await client.callTool({
+ name: "designer_list_designs",
+ arguments: {},
+ })) as { structuredContent?: { ok?: boolean } };
+ expect(result.structuredContent?.ok).toBe(true);
+ } finally {
+ await client.close();
+ }
+ }, 30_000);
+});
+
+describe("bridge instance id", () => {
+ test("an explicit OPENPCB_MCP_INSTANCE wins when it is a safe token", () => {
+ expect(resolveInstanceId({ OPENPCB_MCP_INSTANCE: "ci-run-7" }, () => "random")).toBe("ci-run-7");
+ expect(resolveInstanceId({ OPENPCB_MCP_INSTANCE: "bad id; rm" }, () => "random")).toBe("random");
+ });
+
+ test("Claude Code's session id gives a stable, opaque id per session", () => {
+ const one = resolveInstanceId({ CLAUDE_CODE_SESSION_ID: "4bf27f6f-aaaa" }, () => "random");
+ const again = resolveInstanceId({ CLAUDE_CODE_SESSION_ID: "4bf27f6f-aaaa" }, () => "other");
+ const other = resolveInstanceId({ CLAUDE_CODE_SESSION_ID: "5c000000-bbbb" }, () => "random");
+ expect(one).toBe(again);
+ expect(one).not.toBe(other);
+ expect(one.startsWith("cc-")).toBe(true);
+ expect(one).not.toContain("4bf27f6f");
+ });
+
+ test("without either, every process gets a fresh id", () => {
+ expect(resolveInstanceId({}, () => "fresh")).toBe("fresh");
+ });
+});
+
+describe("backend state fingerprint", () => {
+ test("hashes the tool contracts and names the boot", () => {
+ h.enable({ writes: true });
+ const state = getAssistantService().mcpState();
+ expect(state.toolset).toMatch(/^\d+:[0-9a-f]{24}$/);
+ expect(state.generation).toMatch(/^[0-9a-f-]{36}$/);
+ h.enable({ writes: false });
+ const readOnly = getAssistantService().mcpState();
+ expect(readOnly.toolset).not.toBe(state.toolset);
+ expect(readOnly.generation).toBe(state.generation);
+ });
+});
+
+describe("SSE framing (fuzzed chunk boundaries)", () => {
+ // Deterministic PRNG so a failure is reproducible.
+ function prng(seed: number) {
+ let x = seed >>> 0;
+ return () => {
+ x ^= x << 13;
+ x ^= x >>> 17;
+ x ^= x << 5;
+ return (x >>> 0) / 0x1_0000_0000;
+ };
+ }
+
+ const progress = { jsonrpc: "2.0", method: "notifications/progress", params: { progress: 1, message: "Zeichnung ✓ — ohm Ω 🔧" } };
+ const response = { jsonrpc: "2.0", id: 7, result: { content: [{ type: "text", text: "Ω µ 😀 done" }] } };
+
+ function stream(eol: string, trailingBlank: boolean): Uint8Array {
+ const pretty = JSON.stringify(response, null, 1).split("\n");
+ const events = [
+ `: keep-alive comment${eol}${eol}`,
+ `event: message${eol}data: ${JSON.stringify(progress)}${eol}${eol}`,
+ `data: {"this is": not json${eol}${eol}`,
+ // Multi-line data: the lines join with \n, which is valid inside JSON.
+ pretty.map((line) => `data: ${line}`).join(eol) + (trailingBlank ? `${eol}${eol}` : eol),
+ ];
+ return new TextEncoder().encode(events.join(""));
+ }
+
+ async function parse(bytes: Uint8Array, cuts: number[]) {
+ const chunks: Uint8Array[] = [];
+ let start = 0;
+ for (const cut of [...cuts, bytes.length]) {
+ chunks.push(bytes.slice(start, cut));
+ start = cut;
+ }
+ const notes: JsonRpcMessage[] = [];
+ let i = 0;
+ const final = await readJsonRpcSse(
+ {
+ read: async () =>
+ i < chunks.length ? { value: chunks[i++], done: false } : { value: undefined, done: true },
+ cancel: () => undefined,
+ },
+ 7,
+ (n) => notes.push(n),
+ );
+ return { final, notes };
+ }
+
+ for (const [label, eol] of [["LF", "\n"], ["CRLF", "\r\n"], ["CR", "\r"]] as const) {
+ for (const trailingBlank of [true, false]) {
+ test(`${label} line ends, ${trailingBlank ? "with" : "without"} the final blank line`, async () => {
+ const bytes = stream(eol, trailingBlank);
+ const random = prng(label.length * 1000 + (trailingBlank ? 1 : 2));
+ for (let round = 0; round < 250; round += 1) {
+ const cuts = new Set();
+ const count = 1 + Math.floor(random() * 12);
+ while (cuts.size < count) cuts.add(1 + Math.floor(random() * (bytes.length - 1)));
+ const { final, notes } = await parse(bytes, [...cuts].sort((a, b) => a - b));
+ expect(final).toEqual(response as unknown as JsonRpcMessage);
+ expect(notes).toEqual([progress as unknown as JsonRpcMessage]);
+ }
+ // Every single byte its own chunk: splits every CRLF and every UTF-8 sequence.
+ const everyByte = Array.from({ length: bytes.length - 1 }, (_, k) => k + 1);
+ expect((await parse(bytes, everyByte)).final).toEqual(response as unknown as JsonRpcMessage);
+ });
+ }
+ }
+});
diff --git a/src/core/contracts/feature-flags/registry.ts b/src/core/contracts/feature-flags/registry.ts
index deb61afb..0b18c6aa 100644
--- a/src/core/contracts/feature-flags/registry.ts
+++ b/src/core/contracts/feature-flags/registry.ts
@@ -114,9 +114,14 @@ export const FEATURE_FLAGS = {
"Bundle routing: collect same-side pads, route one centerline, commit N parallel lanes atomically (diff pairs auto-detected by _P/_N and +/- net-name suffixes).",
},
"mcp.server": {
- availability: "dev",
+ // Graduated: installed builds must serve MCP so Claude Code (the user's
+ // own subscription) can drive OpenPCB. The scripted write path stays
+ // behind two user settings, both default OFF (mcp_enabled,
+ // mcp_allow_writes), a per-launch bearer token, and in-panel approval for
+ // deletions and rule changes. Release notes: .github/release-notes/.
+ availability: "all",
description:
- "MCP server (Streamable HTTP at /api/modules/assistant/mcp + bundled stdio shim) exposing the assistant tool registry to external agents. Graduate to 'all' after a bake cycle — it opens a scripted write path into designs.",
+ "MCP server (Streamable HTTP at /api/modules/assistant/mcp + stdio bridge, launcher and Claude Code plugin) exposing OpenPCB's tools to external agents such as Claude Code.",
},
"dataset.capture": {
availability: "prod",
diff --git a/src/core/frontend/src/settings/panels/AssistantPanel.tsx b/src/core/frontend/src/settings/panels/AssistantPanel.tsx
index f3bd78e8..4ef07450 100644
--- a/src/core/frontend/src/settings/panels/AssistantPanel.tsx
+++ b/src/core/frontend/src/settings/panels/AssistantPanel.tsx
@@ -21,6 +21,7 @@ import { useAuth } from "../../cloud/AuthProvider";
import { cloudRequestHeaders } from "../../cloud/request-headers";
import { cn } from "@/lib/utils";
import { McpSection } from "./McpSection";
+import { useFeatureFlag } from "../../feature-flags";
import { Pill } from "@shared/frontend/ui/pill";
import { StackedCard } from "@shared/frontend/ui/stacked-card";
import type {
@@ -97,6 +98,9 @@ async function readJson(url: string, init?: RequestInit): Promise {
export function AssistantPanel() {
const { backendURL } = useRuntime();
+ // The MCP route only exists where the `mcp.server` flag is on; never offer
+ // controls and setup commands for an endpoint the backend does not serve.
+ const mcpServerAvailable = useFeatureFlag("mcp.server");
const { session } = useAuth();
const base = useMemo(
() => (backendURL ? `${backendURL}/api/modules/assistant` : null),
@@ -428,10 +432,13 @@ export function AssistantPanel() {
- void saveSettings(patch).catch(reportError)}
- />
+ {mcpServerAvailable ? (
+ void saveSettings(patch).catch(reportError)}
+ assistantBase={base}
+ />
+ ) : null}
{/* Providers — stacked accordion */}
diff --git a/src/core/frontend/src/settings/panels/McpSection.test.ts b/src/core/frontend/src/settings/panels/McpSection.test.ts
new file mode 100644
index 00000000..450e54da
--- /dev/null
+++ b/src/core/frontend/src/settings/panels/McpSection.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "vitest";
+import { connectLabel, describeClaudeCodeStatus } from "./McpSection";
+
+const base: ClaudeCodeStatus = {
+ cliPath: "/usr/local/bin/claude",
+ cliVersion: "2.1.282",
+ plugin: { installed: false, version: null, enabled: false },
+ marketplace: { registered: false, path: null, ours: false },
+ server: { registered: false, ownedByOpenPcb: false, outdated: false },
+ registeredMode: null,
+ updateAvailable: false,
+};
+
+describe("Claude Code status line", () => {
+ test("covers missing CLI, not connected, plugin, update, server-only", () => {
+ expect(describeClaudeCodeStatus(null)).toContain("Checking");
+ expect(describeClaudeCodeStatus({ ...base, cliPath: null })).toContain("not installed");
+ expect(describeClaudeCodeStatus(base)).toContain("not connected");
+ expect(
+ describeClaudeCodeStatus({
+ ...base,
+ plugin: { installed: true, version: "0.1.1-beta", enabled: true },
+ }),
+ ).toContain("Connected with the OpenPCB plugin 0.1.1-beta");
+ expect(
+ describeClaudeCodeStatus({
+ ...base,
+ plugin: { installed: true, version: "0.1.0", enabled: true },
+ updateAvailable: true,
+ }),
+ ).toContain("update is available");
+ expect(
+ describeClaudeCodeStatus({
+ ...base,
+ server: { registered: true, ownedByOpenPcb: true, outdated: false },
+ }),
+ ).toContain("MCP server only");
+ expect(
+ describeClaudeCodeStatus({
+ ...base,
+ server: { registered: true, ownedByOpenPcb: true, outdated: true },
+ updateAvailable: true,
+ }),
+ ).toContain("old OpenPCB location");
+ });
+
+ test("the primary button updates whatever this installation registered", () => {
+ expect(connectLabel(base)).toBe("Connect Claude Code");
+ expect(connectLabel({ ...base, updateAvailable: true, registeredMode: "plugin" })).toBe("Update plugin");
+ expect(connectLabel({ ...base, updateAvailable: true, registeredMode: "server" })).toBe("Update connection");
+ });
+});
diff --git a/src/core/frontend/src/settings/panels/McpSection.tsx b/src/core/frontend/src/settings/panels/McpSection.tsx
index 23c2e8cd..12fbd8f4 100644
--- a/src/core/frontend/src/settings/panels/McpSection.tsx
+++ b/src/core/frontend/src/settings/panels/McpSection.tsx
@@ -1,59 +1,37 @@
-import { useEffect, useState } from "react";
-import { Check, Copy, Plug } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { AlertTriangle, Check, Copy, Plug, RefreshCw } from "lucide-react";
import type { AssistantSettings } from "../../../../../sdks/assistant";
/**
- * MCP server controls.
+ * MCP server controls — how the user connects Claude Code (their own Claude
+ * subscription) to OpenPCB.
*
* Two independent switches, both default off: the server itself, and whether
- * write tools are advertised to it. They are separate because enabling the
- * server is about reachability (read your designs from Claude Code) while
- * enabling writes hands an external process a scripted path to mutate them.
+ * write tools are advertised to it. Enabling the server is about reachability
+ * (read your designs from Claude Code); enabling writes hands an external
+ * process a scripted path to change them, with deletions and rule changes
+ * still waiting for approval in the assistant panel. `settings-store.ts`
+ * forces writes off whenever the server is off.
*
- * `settings-store.ts` also forces writes off whenever the server is off, so the
- * disabled state here matches what the backend will actually persist.
+ * Connecting: the one-click button drives the user's `claude` CLI to install
+ * OpenPCB's local plugin (tools + workflow skills) or just the MCP server;
+ * the manual commands below do the same by hand. Both point at the stable
+ * launcher Electron main keeps in the user-data dir, so they survive app
+ * updates and restarts.
*/
interface Props {
settings: AssistantSettings | null;
onSave: (patch: Partial) => void;
+ /** `${backendURL}/api/modules/assistant`, for the connected-clients list. */
+ assistantBase: string | null;
}
-type Snippet = { id: string; label: string; hint: string; value: string };
-
-function buildSnippets(config: McpConfig | null): Snippet[] {
- if (!config) return [];
- const snippets: Snippet[] = [];
-
- if (config.shimAvailable && config.shimPath) {
- snippets.push({
- id: "claude-code-stdio",
- label: "Claude Code",
- hint: "Run this in your terminal.",
- value: `claude mcp add openpcb -- "${config.shimPath}"`,
- });
- snippets.push({
- id: "claude-desktop",
- label: "Claude Desktop",
- hint: "Merge into claude_desktop_config.json, then restart Claude Desktop.",
- value: JSON.stringify(
- { mcpServers: { openpcb: { command: config.shimPath } } },
- null,
- 2,
- ),
- });
- }
-
- if (config.url) {
- snippets.push({
- id: "http",
- label: "HTTP (Claude Code, Codex)",
- hint: "The port changes on every app restart — re-copy after restarting OpenPCB.",
- value: `claude mcp add --transport http openpcb ${config.url} --header "Authorization: Bearer ${config.token}"`,
- });
- }
-
- return snippets;
+interface McpClient {
+ instanceId: string;
+ clientName: string;
+ lastSeen: string;
+ callCount: number;
}
function CopyButton({ value }: { value: string }) {
@@ -84,33 +62,113 @@ function CopyButton({ value }: { value: string }) {
);
}
-export function McpSection({ settings, onSave }: Props) {
+function relative(iso: string): string {
+ const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
+ if (seconds < 60) return "just now";
+ const minutes = Math.round(seconds / 60);
+ return minutes < 60 ? `${minutes} min ago` : `${Math.round(minutes / 60)} h ago`;
+}
+
+export function describeClaudeCodeStatus(status: ClaudeCodeStatus | null): string {
+ if (!status) return "Checking Claude Code…";
+ if (!status.cliPath) return "Claude Code is not installed (the `claude` command was not found).";
+ if (status.plugin.installed) {
+ return status.updateAvailable
+ ? `Connected with the OpenPCB plugin ${status.plugin.version ?? ""} — an update is available.`
+ : `Connected with the OpenPCB plugin ${status.plugin.version ?? ""}.`;
+ }
+ if (status.server.registered) {
+ if (!status.server.ownedByOpenPcb) return "Claude Code has a different server named “openpcb”.";
+ return status.server.outdated
+ ? "Connected (MCP server only), but Claude Code still points at an old OpenPCB location — update the connection."
+ : "Connected (MCP server only, no skills).";
+ }
+ return `Claude Code ${status.cliVersion ?? ""} found — not connected yet.`;
+}
+
+/** The primary button: connect, or update whatever this installation registered. */
+export function connectLabel(status: ClaudeCodeStatus | null): string {
+ if (!status?.updateAvailable) return "Connect Claude Code";
+ return status.registeredMode === "server" ? "Update connection" : "Update plugin";
+}
+
+export function McpSection({ settings, onSave, assistantBase }: Props) {
const [config, setConfig] = useState(null);
+ const [status, setStatus] = useState(null);
+ const [busy, setBusy] = useState(null);
+ const [result, setResult] = useState(null);
+ const [clients, setClients] = useState([]);
const enabled = settings?.mcpEnabled ?? false;
const allowWrites = settings?.mcpAllowWrites ?? false;
+ const claudeCode = window.electronAPI?.claudeCode;
+
+ const refreshStatus = useCallback(async () => {
+ if (!claudeCode) return;
+ setStatus(null);
+ setStatus(await claudeCode.status().catch(() => null));
+ }, [claudeCode]);
useEffect(() => {
if (!enabled) return;
const api = window.electronAPI?.getMcpConfig;
if (!api) return;
- // Re-read whenever the server is switched on: the URL carries the backend's
- // ephemeral port, which is new on every app launch.
void api()
.then(setConfig)
.catch(() => setConfig(null));
- }, [enabled]);
+ void refreshStatus();
+ }, [enabled, refreshStatus]);
- const snippets = buildSnippets(config);
+ // Who is connected right now — the list the MCP endpoint keeps per client
+ // session (idle ones drop out after 30 minutes).
+ useEffect(() => {
+ if (!enabled || !assistantBase) return;
+ let cancelled = false;
+ const load = () =>
+ fetch(`${assistantBase}/mcp/clients`)
+ .then((r) => (r.ok ? (r.json() as Promise<{ clients: McpClient[] }>) : null))
+ .then((body) => {
+ if (!cancelled && body) setClients(body.clients);
+ })
+ .catch(() => undefined);
+ void load();
+ const timer = setInterval(load, 10_000);
+ return () => {
+ cancelled = true;
+ clearInterval(timer);
+ };
+ }, [enabled, assistantBase]);
+
+ const run = async (label: string, action: () => Promise) => {
+ setBusy(label);
+ setResult(null);
+ try {
+ setResult(await action());
+ } catch (error) {
+ setResult({
+ ok: false,
+ message: error instanceof Error ? error.message : String(error),
+ log: [],
+ });
+ } finally {
+ setBusy(null);
+ void refreshStatus();
+ }
+ };
+
+ const connected =
+ Boolean(status?.plugin.installed) ||
+ Boolean(status?.server.registered && status.server.ownedByOpenPcb);
return (
- MCP server
+ MCP server · Claude Code
- Lets Claude Code, Claude Desktop and Codex read and edit the design you
- have open. OpenPCB must be running.
+ Use Claude Code (or Claude Desktop, Codex) as the agent for OpenPCB with
+ your own Claude subscription. It reads and edits the designs in this
+ app while OpenPCB is running.
+ The port and token change every time OpenPCB starts, so a client
+ configured this way must be updated after each restart. Prefer the
+ options above.
+
- The stdio bridge ships with packaged builds only, so the Claude
- Desktop snippet is unavailable in development.
+ Connecting Claude Code is available in the OpenPCB desktop app.
)}
diff --git a/src/core/frontend/src/vite-env.d.ts b/src/core/frontend/src/vite-env.d.ts
index 534a0083..fe247f59 100644
--- a/src/core/frontend/src/vite-env.d.ts
+++ b/src/core/frontend/src/vite-env.d.ts
@@ -51,6 +51,7 @@ declare global {
}>;
getAppVersions?: () => Promise;
getMcpConfig?: () => Promise;
+ claudeCode?: ElectronClaudeCode;
openLogsFolder?: () => Promise;
openCrashDumpsFolder?: () => Promise;
openUserDataFolder?: () => Promise;
@@ -67,15 +68,43 @@ declare global {
/** Mirrors the `mcp:config` IPC payload in electron/src/main/diagnostics-ipc.ts. */
interface McpConfig {
- /** Absolute path to the bundled stdio launcher; null when unpackaged. */
- shimPath: string | null;
- shimAvailable: boolean;
+ /** Stable launcher in the user-data dir; null if it could not be installed. */
+ launcherPath: string | null;
+ /** Set when the app runs from a temporary location (macOS translocation). */
+ launcherWarning: string | null;
+ /** Local Claude Code plugin marketplace; null if not written. */
+ marketplaceDir: string | null;
+ snippets: Array<{ id: string; label: string; hint: string; value: string }>;
portfilePath: string;
/** Streamable HTTP endpoint; null until the backend is listening. */
url: string | null;
token: string;
}
+ /** Mirrors ClaudeCodeStatus in electron/src/main/claude-code-cli.ts. */
+ interface ClaudeCodeStatus {
+ cliPath: string | null;
+ cliVersion: string | null;
+ plugin: { installed: boolean; version: string | null; enabled: boolean };
+ marketplace: { registered: boolean; path: string | null; ours: boolean };
+ server: { registered: boolean; ownedByOpenPcb: boolean; outdated: boolean };
+ registeredMode: "plugin" | "server" | null;
+ updateAvailable: boolean;
+ }
+
+ /** Mirrors ActionResult in electron/src/main/claude-code-cli.ts. */
+ interface ClaudeCodeActionResult {
+ ok: boolean;
+ message: string;
+ log: Array<{ args: string[]; code: number; output: string }>;
+ }
+
+ interface ElectronClaudeCode {
+ status(): Promise;
+ connect(mode: "plugin" | "server"): Promise;
+ disconnect(): Promise;
+ }
+
// Mirrors UpdaterState in electron/src/main/updater.ts.
type UpdaterStatus =
| { state: "checking" }
diff --git a/src/modules/assistant/backend/assistant-service.ts b/src/modules/assistant/backend/assistant-service.ts
index 7eb38504..51ec9d51 100644
--- a/src/modules/assistant/backend/assistant-service.ts
+++ b/src/modules/assistant/backend/assistant-service.ts
@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto";
import { NotFoundError, ValidationError } from "../../../core/contracts/errors";
import type { CoreBackendModuleContext } from "../../../core/contracts/modules/backend-module";
import {
@@ -56,14 +57,29 @@ import { ContextResolver } from "./context-resolver";
import { RunService } from "./run-service";
import { buildOpenpcbToolRegistry } from "./tools/openpcb-tool-registry";
import { registerExtendedReadTools } from "./tools/read-tools";
+import { registerKnowledgeTools } from "./tools/knowledge-tools";
+import {
+ APPROVAL_REQUIRED_KINDS,
+ NEVER_SESSION_ALLOWED_KINDS,
+ registerMcpPcbTools,
+} from "./tools/mcp-pcb-tools";
+import { registerMcpDesignTools } from "./tools/mcp-design-tools";
+import { BuildIntentStore } from "./verification/build-intent-store";
import { McpEndpoint } from "./mcp/handler";
-import { McpSessionRegistry } from "./mcp/session";
+import {
+ McpConnectionRegistry,
+ type ChatDefaults,
+ type McpConnectionSummary,
+} from "./mcp/connections";
+import { McpCallRecorder } from "./mcp/call-recorder";
+import { annotationsFor, mcpDescription, metaFor } from "./mcp/tool-policy";
+import { AssistantEventBus } from "./events";
import type { AiToolRegistry } from "@openpcb/ai-core";
import {
applyAssistantWriteProposal,
applyFailureResult,
} from "./proposals/proposal-apply-service";
-import type { SchematicApplyResult } from "./tools/designer-tools";
+import { isProposalStaleError, type SchematicApplyResult } from "./tools/designer-tools";
import {
AssistantWriteSessionPolicy,
type AssistantSessionWriteAllowance,
@@ -75,6 +91,17 @@ import {
let service: AssistantService | null = null;
+/** FNV-1a — a stable, dependency-free fingerprint (not a security hash). */
+/** JSON with object keys sorted at every level, so equal contracts hash equal. */
+function canonicalJson(value: unknown): string {
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
+ const entries = Object.entries(value as Record)
+ .filter(([, v]) => v !== undefined)
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
+}
+
export class AssistantService {
readonly conversation: ConversationStore;
readonly providers: ProviderStore;
@@ -83,14 +110,30 @@ export class AssistantService {
readonly contextResolver: ContextResolver;
readonly runService: RunService;
readonly writeSessionPolicy = new AssistantWriteSessionPolicy();
+ /** Live change notifications for the panel (`GET /events`) and MCP awaits. */
+ readonly events = new AssistantEventBus();
private readonly tasks: TasksSDK;
private mcpEndpoint: McpEndpoint | null = null;
- private readonly mcpRegistries = new Map();
+ private mcpConnections: McpConnectionRegistry | null = null;
+ /** Keyed by `${allowWrites}:${allowRawToolData}` — both change the registry. */
+ private readonly mcpRegistries = new Map();
+ private readonly mcpToolsetFingerprints = new Map();
+ /** Identifies this backend boot to the stdio bridge (see mcpState). */
+ private readonly mcpGeneration = crypto.randomUUID();
constructor(private readonly ctx: CoreBackendModuleContext) {
this.providers = new ProviderStore(ctx);
this.providers.ensureDefaults();
this.conversation = new ConversationStore(ctx);
+ this.conversation.onWriteProposalChange((record) =>
+ this.events.publish({
+ type: "proposal.updated",
+ chatId: record.chatId,
+ proposalId: record.id,
+ status: record.status,
+ designId: record.designId ?? null,
+ }),
+ );
this.settings = new SettingsStore(ctx, this.providers);
this.settings.ensureDefaults();
this.prompts = new PromptService();
@@ -460,6 +503,18 @@ export class AssistantService {
if (message.includes("Confirm partial apply")) {
throw new ValidationError(message);
}
+ if (isProposalStaleError(err)) {
+ // The design moved on since the proposal was made: nothing applied,
+ // and the record says why (the card and assistant_await_proposal
+ // read `code`), so it is never mistaken for a transient failure.
+ this.conversation.updateWriteProposalStatus(
+ chatId,
+ proposalId,
+ "failed",
+ err.toApplyResult(proposalId, record.designId),
+ );
+ throw new ValidationError(message);
+ }
const failureResult = applyFailureResult(err);
this.conversation.updateWriteProposalStatus(
chatId,
@@ -626,13 +681,25 @@ export class AssistantService {
return this.settings.getSettings();
}
updateSettings(input: Partial): AssistantSettings {
- return this.settings.updateSettings(input);
+ const before = this.settings.getSettings();
+ const after = this.settings.updateSettings(input);
+ if (
+ before.mcpEnabled !== after.mcpEnabled ||
+ before.mcpAllowWrites !== after.mcpAllowWrites ||
+ before.allowRawToolData !== after.allowRawToolData
+ ) {
+ // The MCP tool set (or its result shaping) changed: tell connected
+ // clients to re-list tools.
+ this.mcpEndpoint?.notifyToolsChanged();
+ }
+ return after;
}
// ─── MCP ──────────────────────────────────────────────────────────────
/**
- * Streamable HTTP MCP endpoint, built on first use. Held on the service so
- * the SDK handler keeps its session state across requests.
+ * Streamable HTTP MCP endpoint, built on first use. The SDK handler itself
+ * is stateless per request; what persists across requests is held here —
+ * the connection registry (pins, backing chats) and the call recorder.
*/
get mcp(): McpEndpoint {
if (!this.mcpEndpoint) {
@@ -640,47 +707,182 @@ export class AssistantService {
ctx: this.ctx,
appVersion: this.ctx.manifest.version,
contextResolver: this.contextResolver,
- sessions: new McpSessionRegistry({
- ctx: this.ctx,
+ conversation: this.conversation,
+ connections: this.mcpConnectionRegistry(),
+ recorder: new McpCallRecorder({
conversation: this.conversation,
- contextResolver: this.contextResolver,
- createChat: (input) => this.createChat({ title: input.title }),
+ onChatActivity: (chatId) => this.publishChatActivity(chatId),
}),
+ events: this.events,
+ buildIntents: new BuildIntentStore(this.ctx),
getSettings: () => this.settings.getSettings(),
getRegistry: (allowWrites) => this.mcpRegistry(allowWrites),
+ pendingProposalHint: (chatTitle) =>
+ `Waiting for the user to approve or reject it in OpenPCB's assistant panel (chat "${chatTitle}"). Tell the user, then call assistant_await_proposal with this proposal id; do not re-send it.`,
});
}
return this.mcpEndpoint;
}
+ private publishChatActivity(chatId: string): void {
+ const metadata = this.conversation.getChat(chatId)?.metadata as
+ | { designId?: unknown }
+ | null
+ | undefined;
+ this.events.publish({
+ type: "chat.activity",
+ chatId,
+ designId: typeof metadata?.designId === "string" ? metadata.designId : null,
+ });
+ }
+
+ /** Connected MCP clients, most recent first (for the Settings panel). */
+ listMcpClients(): McpConnectionSummary[] {
+ return this.mcpConnectionRegistry().list();
+ }
+
+ private mcpConnectionRegistry(): McpConnectionRegistry {
+ this.mcpConnections ??= new McpConnectionRegistry({
+ ctx: this.ctx,
+ conversation: this.conversation,
+ contextResolver: this.contextResolver,
+ chatDefaults: () => this.mcpChatDefaults(),
+ });
+ return this.mcpConnections;
+ }
+
/**
- * MCP tool registry, cached per write-mode so Ajv does not recompile every
- * tool schema on each request. Separate from the in-app assistant registry:
- * MCP additionally gets the extended read tools, and its auto-apply policy
- * is gated on the `mcpAllowWrites` setting on top of the usual risk check.
+ * Provider stamped on chats created for MCP clients. The external agent
+ * never runs through this provider, so a missing default must not break
+ * MCP: fall back to any provider row, then to a sentinel id.
+ */
+ private mcpChatDefaults(): ChatDefaults {
+ const settings = this.settings.getSettings();
+ const provider =
+ this.providers.getProviderInternal(settings.defaultProviderId) ??
+ this.providers.listProviders()[0] ??
+ null;
+ return {
+ providerConfigId: provider?.id ?? "mcp-external",
+ model: provider?.defaultModel ?? "external",
+ promptPresetId: settings.defaultPromptPresetId,
+ };
+ }
+
+ /**
+ * MCP tool registry, cached per (write mode, raw-data setting) so Ajv does
+ * not recompile every tool schema on each request. Separate from the in-app
+ * assistant registry: MCP additionally gets the extended tools, and its
+ * auto-apply policy is gated on `mcpAllowWrites` on top of the usual risk
+ * check.
*/
private mcpRegistry(allowWrites: boolean): AiToolRegistry {
- const cached = this.mcpRegistries.get(allowWrites);
+ const allowRawToolData = this.settings.getSettings().allowRawToolData;
+ const key = `${allowWrites}:${allowRawToolData}`;
+ const cached = this.mcpRegistries.get(key);
if (cached) return cached;
+ // Undoable edits auto-apply (the user can Ctrl+Z them); destructive ones
+ // and non-undoable rule changes wait for the user unless they allowed that
+ // tool for the session in the panel.
+ const mcpDesignerOptions = {
+ isSessionAutoApplyAllowed: (input: {
+ chatId: string;
+ toolName: string;
+ proposalKind: string;
+ riskLevel?: string | null;
+ }) =>
+ allowWrites &&
+ ((input.riskLevel !== "destructive" &&
+ !APPROVAL_REQUIRED_KINDS.has(input.proposalKind)) ||
+ // Irreversible and verification-suppressing kinds: never on a
+ // session allowance, every one is a fresh decision.
+ (!NEVER_SESSION_ALLOWED_KINDS.has(input.proposalKind) &&
+ this.writeSessionPolicy.isAllowed(input))),
+ };
const registry = buildOpenpcbToolRegistry(
this.ctx,
this.contextResolver,
this.conversation,
{
- allowRawToolData: this.settings.getSettings().allowRawToolData,
- designerTools: {
- isSessionAutoApplyAllowed: (input) =>
- allowWrites &&
- (input.riskLevel !== "destructive" ||
- this.writeSessionPolicy.isAllowed(input)),
- },
+ allowRawToolData,
+ designerTools: mcpDesignerOptions,
},
);
registerExtendedReadTools(registry, this.ctx);
- this.mcpRegistries.set(allowWrites, registry);
+ registerKnowledgeTools(registry);
+ registerMcpPcbTools(
+ registry,
+ this.ctx,
+ this.contextResolver,
+ this.conversation,
+ mcpDesignerOptions,
+ );
+ registerMcpDesignTools(
+ registry,
+ this.ctx,
+ this.contextResolver,
+ this.conversation,
+ );
+ this.mcpRegistries.set(key, registry);
return registry;
}
+ /**
+ * Cheap state probe for the stdio shim: whether the server is on, whether
+ * writes are on, and a fingerprint of the advertised tool set. The shim
+ * polls it and synthesizes `notifications/tools/list_changed` for 2025-era
+ * clients, which the stateless endpoint cannot push to.
+ */
+ mcpState(): {
+ enabled: boolean;
+ allowWrites: boolean;
+ toolset: string;
+ appVersion: string;
+ generation: string;
+ } {
+ const settings = this.settings.getSettings();
+ return {
+ enabled: settings.mcpEnabled,
+ allowWrites: settings.mcpAllowWrites,
+ toolset: settings.mcpEnabled ? this.mcpToolsetFingerprint(settings.mcpAllowWrites) : "0:",
+ appVersion: this.ctx.manifest.version,
+ // New per backend boot: an app restart or update can keep every tool
+ // name and still change a schema, so the bridge re-lists on a new boot
+ // even when the hash below happens to match.
+ generation: this.mcpGeneration,
+ };
+ }
+
+ /**
+ * Hash of the exact tool contracts a client would be served — name,
+ * version, description, input schema, annotations, `_meta` — not just the
+ * names: a same-named tool whose schema changed must reach clients as
+ * `list_changed`, or they keep calling it with the old arguments.
+ * Tool definitions do not change within a boot, so it is cached per mode.
+ */
+ private mcpToolsetFingerprint(allowWrites: boolean): string {
+ const allowRawToolData = this.settings.getSettings().allowRawToolData;
+ const key = `${allowWrites}:${allowRawToolData}`;
+ const cached = this.mcpToolsetFingerprints.get(key);
+ if (cached) return cached;
+ const descriptors = this.mcpRegistry(allowWrites)
+ .list()
+ .filter((t) => allowWrites || t.definition.effect !== "write")
+ .map((t) => ({
+ name: t.definition.name,
+ version: t.definition.version,
+ description: mcpDescription(t),
+ inputSchema: t.definition.inputSchema,
+ annotations: annotationsFor(t),
+ meta: metaFor(t) ?? null,
+ }))
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
+ const digest = createHash("sha256").update(canonicalJson(descriptors)).digest("hex");
+ const fingerprint = `${descriptors.length}:${digest.slice(0, 24)}`;
+ this.mcpToolsetFingerprints.set(key, fingerprint);
+ return fingerprint;
+ }
+
// ─── providers ────────────────────────────────────────────────────────
listProviders(): AssistantProviderConfig[] {
return this.providers.listProviders();
diff --git a/src/modules/assistant/backend/context-resolver.ts b/src/modules/assistant/backend/context-resolver.ts
index 8905cbcd..ff3b11b7 100644
--- a/src/modules/assistant/backend/context-resolver.ts
+++ b/src/modules/assistant/backend/context-resolver.ts
@@ -147,12 +147,24 @@ export class ContextResolver {
};
}
- await this.bindDesign(chatId, { id: top.id, name: top.name });
+ // listDesigns() was awaited above: another call may have bound the chat
+ // since the primary check. Bind only if still unbound (atomic — see
+ // bindDesignIfUnbound) and report the design that actually won.
+ const outcome = this.bindDesignIfUnbound(chatId, { id: top.id, name: top.name });
+ if (!outcome.created && outcome.binding.refId !== top.id) {
+ return {
+ status: "already-bound-to-other-design",
+ candidates: hits.slice(0, 5),
+ message: `This chat is bound to "${outcome.binding.label}". Start a new chat to work on "${top.name}".`,
+ };
+ }
return {
status: "resolved",
resolved: top,
candidates: hits.slice(0, 5),
- message: `Bound chat to design "${top.name}".`,
+ message: outcome.created
+ ? `Bound chat to design "${top.name}".`
+ : `Already bound to ${top.name}.`,
};
}
@@ -171,6 +183,30 @@ export class ContextResolver {
return this.store.createBinding(chatId, binding);
}
+ /**
+ * Bind the chat to a design unless it already has a primary design. The
+ * check and the insert run back to back with no await in between, on the
+ * one synchronous SQLite writer, so two concurrent callers can never both
+ * see "unbound" and leave the chat with two primary designs. Returns the
+ * binding that is primary afterwards and whether this call created it.
+ */
+ bindDesignIfUnbound(
+ chatId: string,
+ design: { id: string; name: string },
+ ): { binding: AssistantContextBindingDto; created: boolean } {
+ const existing = this.getPrimaryDesign(chatId);
+ if (existing) return { binding: existing, created: false };
+ const binding: AiContextBinding = {
+ id: crypto.randomUUID(),
+ kind: "design",
+ refId: design.id,
+ label: design.name,
+ role: "primary",
+ status: "active",
+ };
+ return { binding: this.store.createBinding(chatId, binding), created: true };
+ }
+
/**
* If the chat has no primary design and this designId resolves to a real design, auto-bind it.
* Idempotent: no-op when already bound to this design. If bound to a different design, returns null.
@@ -185,10 +221,11 @@ export class ContextResolver {
if (!designer) return null;
const design = await designer.getDesign(designId);
if (!design) return null;
- return this.bindDesign(chatId, {
+ const outcome = this.bindDesignIfUnbound(chatId, {
id: design.head.id,
name: design.head.name,
});
+ return outcome.binding.refId === designId ? outcome.binding : null;
}
/**
diff --git a/src/modules/assistant/backend/conversation-store.ts b/src/modules/assistant/backend/conversation-store.ts
index 727ca185..7d2e9376 100644
--- a/src/modules/assistant/backend/conversation-store.ts
+++ b/src/modules/assistant/backend/conversation-store.ts
@@ -15,6 +15,7 @@ import type {
AiContextBindingStatus,
AiToolStatus,
AiSourceRef,
+ AssistantWriteProposalActor,
AssistantWriteProposalDto,
AssistantWriteProposalKind,
AssistantWriteProposalStatus,
@@ -191,6 +192,14 @@ function rowToWriteProposal(
origin: row.origin === "cloud" ? ("cloud" as const) : ("local" as const),
cloudRunId: row.cloud_run_id ? String(row.cloud_run_id) : null,
cloudProposalId: row.cloud_proposal_id ? String(row.cloud_proposal_id) : null,
+ actor:
+ row.actor_client_key && row.actor_instance_id
+ ? {
+ type: "mcp" as const,
+ clientKey: String(row.actor_client_key),
+ instanceId: String(row.actor_instance_id),
+ }
+ : null,
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
};
@@ -230,6 +239,18 @@ export interface UpsertToolEventInput {
sources?: AiSourceRef[];
}
+/**
+ * Who an `action_id` is unique for: the MCP session that issued the write, or
+ * (in-app) the chat. Retries dedupe within the scope; the same deterministic
+ * id from another session or chat is a different action.
+ */
+export function writeProposalIdempotencyScope(
+ chatId: string,
+ actor: AssistantWriteProposalActor | null | undefined,
+): string {
+ return actor ? `mcp:${actor.clientKey}:${actor.instanceId}` : `chat:${chatId}`;
+}
+
export interface CreateWriteProposalInput {
id?: string;
chatId: string;
@@ -250,6 +271,8 @@ export interface CreateWriteProposalInput {
origin?: "local" | "cloud";
cloudRunId?: string | null;
cloudProposalId?: string | null;
+ /** MCP client session that proposed it (ownership); null in-app. */
+ actor?: AssistantWriteProposalActor | null;
}
export interface ListMessagesOptions {
@@ -286,12 +309,34 @@ function messageCursor(row: Record): string {
export class ConversationStore {
private readonly rawSql: RawSqlFn;
readonly mentions: MentionRepository;
+ /**
+ * Called after a write proposal is created or changes status — from any
+ * path (panel apply/reject, auto-apply inside a tool, cloud mirroring). The
+ * service wires it to the assistant event bus.
+ */
+ private proposalListener: ((record: AssistantWriteProposalDto) => void) | null =
+ null;
constructor(ctx: CoreBackendModuleContext) {
this.rawSql = rawSqlFrom(ctx);
this.mentions = new MentionRepository(ctx);
}
+ onWriteProposalChange(
+ listener: ((record: AssistantWriteProposalDto) => void) | null,
+ ): void {
+ this.proposalListener = listener;
+ }
+
+ private emitProposal(record: AssistantWriteProposalDto | null): void {
+ if (!record || !this.proposalListener) return;
+ try {
+ this.proposalListener(record);
+ } catch {
+ // Notification is best-effort; the write already happened.
+ }
+ }
+
// ---------- chats ----------
createChat(input: CreateChatRecord): AssistantChat {
const timestamp = now();
@@ -615,9 +660,23 @@ export class ConversationStore {
}
// ---------- write proposals ----------
+ /** Insert a proposal; on an idempotency conflict returns the existing one. */
createWriteProposal(
input: CreateWriteProposalInput,
): AssistantWriteProposalDto {
+ return this.createOrGetWriteProposal(input).record;
+ }
+
+ /**
+ * Insert a proposal, or — when one with the same idempotency key
+ * (design, scope, action_id) or cloud id already exists — return that one
+ * with `created: false`. Callers MUST NOT apply anything when `created` is
+ * false: the returned record is the action that already happened (or is
+ * pending), not the envelope they were about to persist.
+ */
+ createOrGetWriteProposal(
+ input: CreateWriteProposalInput,
+ ): { record: AssistantWriteProposalDto; created: boolean } {
const timestamp = now();
const proposalId = input.id ?? id();
const envelope = input.envelope ?? null;
@@ -627,9 +686,10 @@ export class ConversationStore {
const actionId =
(envelope as { actionId?: string } | null)?.actionId ?? null;
const cloudProposalId = input.cloudProposalId ?? null;
+ const scope = writeProposalIdempotencyScope(input.chatId, input.actor);
try {
this.rawSql(
- "INSERT INTO assistant_write_proposal (id,chat_id,tool_event_id,kind,status,design_id,base_revision,proposal_json,apply_result_json,tool_name,title,summary,risk_level,operations_json,sources_json,warnings_json,envelope_json,action_id,origin,cloud_run_id,cloud_proposal_id,created_at,updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO assistant_write_proposal (id,chat_id,tool_event_id,kind,status,design_id,base_revision,proposal_json,apply_result_json,tool_name,title,summary,risk_level,operations_json,sources_json,warnings_json,envelope_json,action_id,origin,cloud_run_id,cloud_proposal_id,actor_client_key,actor_instance_id,idempotency_scope,created_at,updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
proposalId,
input.chatId,
@@ -652,28 +712,45 @@ export class ConversationStore {
input.origin ?? "local",
input.cloudRunId ?? null,
cloudProposalId,
+ input.actor?.clientKey ?? null,
+ input.actor?.instanceId ?? null,
+ scope,
timestamp,
timestamp,
],
);
} catch (err) {
- // F6: a concurrent submit already inserted a proposal for the same
- // (design_id, action_id) — the UNIQUE index rejects this one. Return the
- // existing proposal so the caller reuses it instead of duplicating writes.
- // Same story for a re-streamed cloud proposal (design_id, cloud_proposal_id).
+ // The idempotency index (design, scope, action_id) or the cloud index
+ // (design, cloud_proposal_id) already holds this action — a concurrent
+ // submit, a retry, or a re-streamed cloud proposal.
const isUnique = /unique|constraint/i.test(String(err));
const existing = isUnique
? (actionId
- ? this.getWriteProposalByActionId(input.designId, actionId)
+ ? this.getWriteProposalByActionKey(input.designId, scope, actionId)
: null) ??
(cloudProposalId
? this.getWriteProposalByCloudId(input.designId, cloudProposalId)
: null)
: null;
- if (existing) return existing;
+ if (existing) return { record: existing, created: false };
throw err;
}
- return this.getWriteProposal(input.chatId, proposalId)!;
+ const created = this.getWriteProposal(input.chatId, proposalId)!;
+ this.emitProposal(created);
+ return { record: created, created: true };
+ }
+
+ /**
+ * A proposal by id alone. Used by MCP clients, which learn the id from a
+ * tool result but not the chat it lives in; callers must still check the
+ * chat belongs to them.
+ */
+ getWriteProposalById(proposalId: string): AssistantWriteProposalDto | null {
+ const row = this.rawSql(
+ "SELECT * FROM assistant_write_proposal WHERE id=? LIMIT 1",
+ [proposalId],
+ )[0];
+ return row ? rowToWriteProposal(row) : null;
}
/** S6: look up a mirrored proposal by its cloud identity (dedupes SSE redelivery). */
@@ -688,18 +765,63 @@ export class ConversationStore {
return row ? rowToWriteProposal(row) : null;
}
- /** F6: look up a proposal by its idempotency key (design + action_id). */
- getWriteProposalByActionId(
+ /** The proposal holding an idempotency key (design + scope + action_id), if any. */
+ getWriteProposalByActionKey(
designId: string,
+ scope: string,
actionId: string,
): AssistantWriteProposalDto | null {
const row = this.rawSql(
- "SELECT * FROM assistant_write_proposal WHERE design_id=? AND action_id=? LIMIT 1",
- [designId, actionId],
+ "SELECT * FROM assistant_write_proposal WHERE design_id=? AND idempotency_scope=? AND action_id=? ORDER BY created_at DESC LIMIT 1",
+ [designId, scope, actionId],
)[0];
return row ? rowToWriteProposal(row) : null;
}
+ /** Proposals an MCP session made, newest first; optionally one status / design. */
+ listWriteProposalsByActor(
+ actor: { clientKey: string; instanceId: string },
+ filter: { status?: AssistantWriteProposalStatus; designId?: string } = {},
+ ): AssistantWriteProposalDto[] {
+ const clauses = ["actor_client_key=?", "actor_instance_id=?"];
+ const params: unknown[] = [actor.clientKey, actor.instanceId];
+ if (filter.status) {
+ clauses.push("status=?");
+ params.push(filter.status);
+ }
+ if (filter.designId) {
+ clauses.push("design_id=?");
+ params.push(filter.designId);
+ }
+ return this.rawSql(
+ `SELECT * FROM assistant_write_proposal WHERE ${clauses.join(" AND ")} ORDER BY created_at DESC`,
+ params,
+ ).map(rowToWriteProposal);
+ }
+
+ /**
+ * Designer command ids an MCP session landed on a design through its
+ * applied proposals — what `designer_undo` / `designer_redo` may touch.
+ */
+ commandIdsAppliedByActor(
+ actor: { clientKey: string; instanceId: string },
+ designId: string,
+ ): Set {
+ const ids = new Set();
+ const rows = this.rawSql(
+ "SELECT apply_result_json FROM assistant_write_proposal WHERE actor_client_key=? AND actor_instance_id=? AND design_id=? AND status IN ('applied','partial')",
+ [actor.clientKey, actor.instanceId, designId],
+ );
+ for (const row of rows) {
+ const applied = decodeJson<{ commandIds?: unknown } | null>(row.apply_result_json, null);
+ if (!Array.isArray(applied?.commandIds)) continue;
+ for (const commandId of applied.commandIds) {
+ if (typeof commandId === "string") ids.add(commandId);
+ }
+ }
+ return ids;
+ }
+
getWriteProposal(
chatId: string,
proposalId: string,
@@ -736,6 +858,7 @@ export class ConversationStore {
);
const next = this.getWriteProposal(chatId, proposalId);
if (!next) throw new Error(`Write proposal not found: ${proposalId}`);
+ this.emitProposal(next);
return next;
}
}
diff --git a/src/modules/assistant/backend/events.ts b/src/modules/assistant/backend/events.ts
new file mode 100644
index 00000000..6821ce0f
--- /dev/null
+++ b/src/modules/assistant/backend/events.ts
@@ -0,0 +1,147 @@
+/**
+ * In-process change bus for the assistant module.
+ *
+ * The panel otherwise only updates live during its own task runs (streamed
+ * over the tasks SSE). Anything that changes a chat from outside a run — an
+ * MCP client's tool calls, a proposal approved in another window, an
+ * auto-applied write — publishes here, and `GET /events` (SSE) relays it so
+ * the panel can refetch. It also lets `assistant_await_proposal` wake the
+ * moment the user decides instead of polling the database.
+ *
+ * Events carry ids, never content: subscribers refetch through the normal
+ * routes, so there is one read path and nothing sensitive on the stream.
+ */
+
+export type AssistantEvent =
+ | {
+ type: "chat.activity";
+ chatId: string;
+ /** The design the chat is bound to, when known — lets a design dock filter. */
+ designId: string | null;
+ }
+ | {
+ type: "proposal.updated";
+ chatId: string;
+ proposalId: string;
+ status: string;
+ designId: string | null;
+ };
+
+type Listener = (event: AssistantEvent) => void;
+
+export class AssistantEventBus {
+ private readonly listeners = new Set();
+
+ publish(event: AssistantEvent): void {
+ for (const listener of [...this.listeners]) {
+ try {
+ listener(event);
+ } catch {
+ // A broken subscriber must not stop delivery to the others.
+ }
+ }
+ }
+
+ subscribe(listener: Listener): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ get listenerCount(): number {
+ return this.listeners.size;
+ }
+
+ /**
+ * Resolve with the next `proposal.updated` for `proposalId` whose status
+ * `isDone` accepts, or null on timeout / abort.
+ */
+ waitForProposal(
+ proposalId: string,
+ isDone: (status: string) => boolean,
+ options: { timeoutMs: number; signal?: AbortSignal },
+ ): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (value: string | null) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ unsubscribe();
+ options.signal?.removeEventListener("abort", onAbort);
+ resolve(value);
+ };
+ const onAbort = () => finish(null);
+ const unsubscribe = this.subscribe((event) => {
+ if (
+ event.type === "proposal.updated" &&
+ event.proposalId === proposalId &&
+ isDone(event.status)
+ ) {
+ finish(event.status);
+ }
+ });
+ const timer = setTimeout(() => finish(null), options.timeoutMs);
+ if (options.signal?.aborted) finish(null);
+ else options.signal?.addEventListener("abort", onAbort);
+ });
+ }
+}
+
+/** Server-Sent Events framing for one event. */
+export function sseFrame(event: AssistantEvent): string {
+ return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
+}
+
+/**
+ * `GET /events` body: relays the bus as SSE, with a comment-frame keepalive so
+ * idle proxies and the browser keep the stream open.
+ */
+export function assistantEventStream(
+ bus: AssistantEventBus,
+ signal: AbortSignal,
+ keepAliveMs = 15_000,
+): Response {
+ const encoder = new TextEncoder();
+ let cleanup = () => {};
+ const stream = new ReadableStream({
+ start(controller) {
+ let closed = false;
+ const write = (text: string) => {
+ if (closed) return;
+ try {
+ controller.enqueue(encoder.encode(text));
+ } catch {
+ close();
+ }
+ };
+ const unsubscribe = bus.subscribe((event) => write(sseFrame(event)));
+ const keepAlive = setInterval(() => write(": keepalive\n\n"), keepAliveMs);
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ unsubscribe();
+ clearInterval(keepAlive);
+ try {
+ controller.close();
+ } catch {
+ // already closed
+ }
+ };
+ cleanup = close;
+ write(": connected\n\n");
+ signal.addEventListener("abort", close);
+ },
+ cancel() {
+ cleanup();
+ },
+ });
+ return new Response(stream, {
+ headers: {
+ "content-type": "text/event-stream",
+ "cache-control": "no-cache",
+ connection: "keep-alive",
+ },
+ });
+}
diff --git a/src/modules/assistant/backend/mcp/auth.ts b/src/modules/assistant/backend/mcp/auth.ts
index 52579dbf..fd7591ea 100644
--- a/src/modules/assistant/backend/mcp/auth.ts
+++ b/src/modules/assistant/backend/mcp/auth.ts
@@ -1,4 +1,4 @@
-import { timingSafeEqual } from "node:crypto";
+import { createHash, timingSafeEqual } from "node:crypto";
/**
* Bearer-token gate for the MCP endpoint.
@@ -15,20 +15,18 @@ export type McpAuthFailure =
| { code: "server_misconfigured"; message: string }
| { code: "unauthorized"; message: string };
+/**
+ * Compare SHA-256 digests rather than the raw strings: `timingSafeEqual`
+ * throws on a length mismatch, and branching on length first would leak it.
+ * Digests are always 32 bytes, so every comparison costs the same.
+ */
function constantTimeEquals(a: string, b: string): boolean {
- const left = Buffer.from(a, "utf8");
- const right = Buffer.from(b, "utf8");
- // timingSafeEqual throws on length mismatch, which would itself leak length.
- // Compare a fixed-size digest-shaped pair instead: pad to the longer length.
- if (left.length !== right.length) {
- // Still burn a comparison so the failure path costs the same either way.
- timingSafeEqual(left, left);
- return false;
- }
+ const left = createHash("sha256").update(a, "utf8").digest();
+ const right = createHash("sha256").update(b, "utf8").digest();
return timingSafeEqual(left, right);
}
-function readBearer(req: Request): string | null {
+function readBearer(req: Pick): string | null {
const header = req.headers.get("authorization");
if (!header) return null;
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
@@ -41,7 +39,7 @@ function readBearer(req: Request): string | null {
* nothing to check against, and serving unauthenticated would silently drop
* the only gate this endpoint has.
*/
-export function checkMcpAuth(req: Request): McpAuthFailure | null {
+export function checkMcpAuth(req: Pick): McpAuthFailure | null {
const expected = process.env.OPENPCB_MCP_TOKEN;
if (!expected || expected.length === 0) {
return {
diff --git a/src/modules/assistant/backend/mcp/call-recorder.ts b/src/modules/assistant/backend/mcp/call-recorder.ts
new file mode 100644
index 00000000..816d43b6
--- /dev/null
+++ b/src/modules/assistant/backend/mcp/call-recorder.ts
@@ -0,0 +1,231 @@
+import { createHash } from "node:crypto";
+import type { AiSourceRef } from "@openpcb/ai-core";
+import type { AssistantMessage } from "../../../../sdks";
+import type { ConversationStore } from "../conversation-store";
+import type { McpConnection } from "./connections";
+
+/**
+ * Persists every MCP tool call into the backing chat so the user can watch the
+ * external agent in the assistant panel — and, crucially, so write proposals
+ * get the tool event their approval card is rendered from (`MessageCard`
+ * builds proposal cards from `succeeded` tool events whose result JSON is
+ * `{id, kind}`, attached to a visible message).
+ *
+ * Deliberately NOT the in-app run-service path: that one writes `role:"tool"`
+ * replay messages tied to provider tool calls, and replaying those without the
+ * assistant `toolCalls` message that introduced them would corrupt the in-app
+ * history if the user later types into this chat. Here each burst of calls
+ * hangs off one plain, visible assistant "activity" message.
+ */
+
+/** A burst of calls from one connection shares an activity message for this long. */
+export const ACTIVITY_WINDOW_MS = 15 * 60 * 1000;
+
+/**
+ * Audit storage budget. An agent loop can call a large read (PCB layout, DRC,
+ * connectivity) dozens of times; storing each full result would grow the
+ * chat database by megabytes per session for data nobody re-reads — the
+ * model already got it, and the design is the source of truth. So results are
+ * stored whole only where the panel renders them, or when small; anything
+ * else becomes a digest (size, hash, preview).
+ */
+export const MAX_STORED_RESULT_CHARS = 8_000;
+export const MAX_STORED_ARGS_CHARS = 16_000;
+const PREVIEW_CHARS = 2_000;
+
+/** Tools whose tool-event result `MessageCard` parses into a card — kept whole. */
+const PANEL_RENDERED_TOOLS: ReadonlySet = new Set([
+ "library_search_components",
+ "library_resolve_bom",
+ "designer_place_components",
+]);
+
+function preview(json: string): string {
+ const code = json.charCodeAt(PREVIEW_CHARS - 1);
+ const end = code >= 0xd800 && code <= 0xdbff ? PREVIEW_CHARS - 1 : PREVIEW_CHARS;
+ return json.slice(0, end);
+}
+
+function digest(json: string): string {
+ return JSON.stringify({
+ digest: true,
+ bytes: Buffer.byteLength(json, "utf8"),
+ sha256: createHash("sha256").update(json).digest("hex"),
+ preview: preview(json),
+ });
+}
+
+/**
+ * What to persist as a call's result. Proposal results keep only the keys the
+ * card joins on (`{id, kind, designId, baseRevision}`) — the full envelope is
+ * already stored once, in the proposal record.
+ */
+export function storedResultJson(toolName: string, resultJson: string | null): string | null {
+ if (resultJson === null) return null;
+ if (PANEL_RENDERED_TOOLS.has(toolName)) return resultJson;
+ try {
+ const parsed = JSON.parse(resultJson) as Record | null;
+ if (parsed && typeof parsed.id === "string" && typeof parsed.kind === "string") {
+ return JSON.stringify({
+ id: parsed.id,
+ kind: parsed.kind,
+ designId: parsed.designId ?? null,
+ baseRevision: parsed.baseRevision ?? null,
+ });
+ }
+ } catch {
+ // not JSON: fall through to the size rule
+ }
+ return resultJson.length <= MAX_STORED_RESULT_CHARS ? resultJson : digest(resultJson);
+}
+
+/** Arguments are stored for the audit trail, bounded like results. */
+export function storedArgumentsJson(argumentsJson: string): string {
+ return argumentsJson.length <= MAX_STORED_ARGS_CHARS ? argumentsJson : digest(argumentsJson);
+}
+
+export interface RecordedCall {
+ chatId: string;
+ messageId: string;
+ eventId: string;
+ toolCallId: string;
+ toolName: string;
+ argumentsJson: string;
+}
+
+export interface McpCallRecorderDeps {
+ conversation: ConversationStore;
+ /** Fired after anything visible in `chatId` changed. */
+ onChatActivity?: (chatId: string) => void;
+ now?: () => number;
+}
+
+interface ActivityMetadata {
+ activity: true;
+ instanceId: string;
+ clientName: string;
+}
+
+function activityOf(message: AssistantMessage): ActivityMetadata | null {
+ const mcp = (message.metadata as { mcp?: unknown } | null)?.mcp;
+ if (typeof mcp !== "object" || mcp === null) return null;
+ return (mcp as { activity?: unknown }).activity === true
+ ? (mcp as ActivityMetadata)
+ : null;
+}
+
+function oneLine(text: string, max = 240): string {
+ const flat = text.replace(/\s+/g, " ").trim();
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
+}
+
+export class McpCallRecorder {
+ private readonly now: () => number;
+
+ constructor(private readonly deps: McpCallRecorderDeps) {
+ this.now = deps.now ?? Date.now;
+ }
+
+ private activityMessage(chatId: string, connection: McpConnection): string {
+ const latest = this.deps.conversation.listMessages(chatId, { limit: 1 })
+ .items[0];
+ if (latest && latest.role === "assistant") {
+ const activity = activityOf(latest);
+ const age = this.now() - Date.parse(latest.createdAt);
+ if (
+ activity &&
+ activity.instanceId === connection.instanceId &&
+ Number.isFinite(age) &&
+ age < ACTIVITY_WINDOW_MS
+ ) {
+ return latest.id;
+ }
+ }
+ const metadata: Record = {
+ mcp: {
+ activity: true,
+ instanceId: connection.instanceId,
+ clientName: connection.clientName,
+ } satisfies ActivityMetadata,
+ };
+ return this.deps.conversation.createMessage({
+ chatId,
+ role: "assistant",
+ content: `**${connection.clientName}** is working in OpenPCB over MCP.`,
+ metadata,
+ }).id;
+ }
+
+ begin(input: {
+ chatId: string;
+ connection: McpConnection;
+ toolName: string;
+ args: Record;
+ }): RecordedCall {
+ const messageId = this.activityMessage(input.chatId, input.connection);
+ const toolCallId = `mcp_${crypto.randomUUID()}`;
+ let argumentsJson = "{}";
+ try {
+ argumentsJson = storedArgumentsJson(JSON.stringify(input.args ?? {}));
+ } catch {
+ // keep "{}" — arguments are informational here
+ }
+ const event = this.deps.conversation.upsertToolEvent({
+ chatId: input.chatId,
+ taskId: null,
+ messageId,
+ toolCallId,
+ toolName: input.toolName,
+ status: "running",
+ argumentsJson,
+ });
+ this.deps.onChatActivity?.(input.chatId);
+ return {
+ chatId: input.chatId,
+ messageId,
+ eventId: event.id,
+ toolCallId,
+ toolName: input.toolName,
+ argumentsJson,
+ };
+ }
+
+ end(
+ call: RecordedCall,
+ outcome: {
+ /**
+ * The tool returned a result (even an unsuccessful one). Mirrors the
+ * in-app loop, which records any returned result as `succeeded` — only
+ * a thrown execution is `failed`. It matters: proposal cards render
+ * only from `succeeded` events, and a partial or pending write still
+ * has a proposal the user must see.
+ */
+ completed: boolean;
+ ok: boolean;
+ summary: string;
+ /** JSON of the tool's full `data` — what proposal/result cards parse. */
+ resultJson: string | null;
+ error?: string | null;
+ sources?: AiSourceRef[];
+ },
+ ): void {
+ this.deps.conversation.upsertToolEvent({
+ id: call.eventId,
+ chatId: call.chatId,
+ taskId: null,
+ messageId: call.messageId,
+ toolCallId: call.toolCallId,
+ toolName: call.toolName,
+ status: outcome.completed ? "succeeded" : "failed",
+ argumentsJson: call.argumentsJson,
+ resultJson: storedResultJson(call.toolName, outcome.resultJson),
+ errorJson: outcome.error ? JSON.stringify({ message: outcome.error }) : null,
+ sources: outcome.sources ?? [],
+ });
+ this.deps.conversation.appendMessageContent(
+ call.messageId,
+ `\n- ${outcome.ok ? "✓" : "✗"} \`${call.toolName}\` — ${oneLine(outcome.summary)}`,
+ );
+ this.deps.onChatActivity?.(call.chatId);
+ }
+}
diff --git a/src/modules/assistant/backend/mcp/connections.ts b/src/modules/assistant/backend/mcp/connections.ts
new file mode 100644
index 00000000..4cc6b08a
--- /dev/null
+++ b/src/modules/assistant/backend/mcp/connections.ts
@@ -0,0 +1,457 @@
+import { MODULE_SDK_TOKENS, type DesignerSDK } from "../../../../sdks";
+import type { AssistantPromptPresetId } from "../../../../sdks";
+import type { CoreBackendModuleContext } from "../../../../core/contracts/modules/backend-module";
+import type { ConversationStore } from "../conversation-store";
+import type { ContextResolver } from "../context-resolver";
+import type { CapturedIntent } from "../verification/build-intent-capture";
+
+/**
+ * Who is calling the MCP endpoint, and which assistant chats back them.
+ *
+ * The SDK serves 2025-era clients statelessly — a fresh server per POST, no
+ * `Mcp-Session-Id` — so connection state cannot come from the transport. It
+ * comes from headers instead:
+ *
+ * - `clientKey` (`X-OpenPCB-MCP-Client`, else User-Agent) groups a client's
+ * chats. It must be header-stable, never the display name.
+ * - `instanceId` (`X-OpenPCB-MCP-Instance`, one UUID per shim process ≈ one
+ * Claude Code session) keys the design pin, so two concurrent sessions
+ * never steer each other. Direct HTTP clients without the header share
+ * state per `clientKey`.
+ *
+ * Chat model — per SESSION (`clientKey|instanceId`), never shared between two
+ * Claude Code sessions. Chats are the audit trail, not the ownership boundary:
+ * proposals carry an explicit actor (`actor_client_key`, `actor_instance_id`)
+ * and every ownership check (await/get proposal, undo/redo) compares that.
+ * Keeping chats per session on top means "allow this tool for the session" in
+ * the panel (keyed by chat) can never authorise another session, and no two
+ * sessions ever bind the same chat.
+ *
+ * - One **home** chat per session, never bound to a design. Calls that do not
+ * target a design (library search, list/create/resolve design) run there.
+ * - One **design** chat per session per design, bound exactly once. Designer
+ * tools resolve their design from the chat's primary binding
+ * (`ContextResolver.getPrimaryDesign`), so a chat that never changes design
+ * makes every in-app tool work unmodified — and because the metadata has the
+ * `{scope:"designer", designId}` shape `listChatsForDesign` filters on, the
+ * session's activity and approval cards show in that design's dock.
+ * - When a tool binds the home chat (`designer_create_design`,
+ * `designer_resolve_design`), that chat becomes the design's chat and a new
+ * home chat is created lazily (`adoptBoundHomeChat`). Those tools run under
+ * the connection's lock (`serialize`), so two parallel calls from one
+ * session cannot bind the same home chat to two designs.
+ * - Chats from before per-session chats (no `mcp.instanceId`) are left alone.
+ */
+
+export interface McpClientIdentity {
+ clientKey: string;
+ clientName: string;
+ instanceId: string;
+}
+
+export interface McpConnection extends McpClientIdentity {
+ /** Set by `designer_use_design`; beats the UI-active design, loses to an explicit argument. */
+ pinnedDesignId: string | null;
+ /** The last design a call from this connection acted on. */
+ lastDesignId: string | null;
+ /**
+ * Expected BOM from this session's last `library_resolve_bom`, held until a
+ * design exists to attach it to (`designer_verify_build` moves it).
+ */
+ buildIntent: CapturedIntent | null;
+ firstSeen: number;
+ lastSeen: number;
+ callCount: number;
+}
+
+export interface McpConnectionSummary {
+ instanceId: string;
+ clientKey: string;
+ clientName: string;
+ pinnedDesignId: string | null;
+ lastDesignId: string | null;
+ firstSeen: string;
+ lastSeen: string;
+ callCount: number;
+}
+
+export type DesignTargetSource = "explicit" | "pin" | "ui" | "last";
+
+export interface DesignTarget {
+ designId: string;
+ source: DesignTargetSource;
+ warning?: string;
+}
+
+export interface ChatDefaults {
+ providerConfigId: string;
+ model: string;
+ promptPresetId: AssistantPromptPresetId;
+}
+
+export interface McpConnectionDeps {
+ ctx: CoreBackendModuleContext;
+ conversation: ConversationStore;
+ contextResolver: ContextResolver;
+ /**
+ * Provider/model/preset stamped on chats this registry creates. MCP chats
+ * are driven by an external agent, so this must never throw for a missing
+ * in-app provider — the caller falls back to a sentinel.
+ */
+ chatDefaults: () => ChatDefaults;
+ now?: () => number;
+}
+
+/** Connections idle this long are dropped (their pin with them). */
+export const CONNECTION_IDLE_MS = 30 * 60 * 1000;
+
+interface McpChatMetadata {
+ clientKey: string;
+ clientName: string;
+ instanceId: string;
+ role: "home" | "design";
+}
+
+/** `clientKey|instanceId` — the unit that owns chats, pins and proposals. */
+export function sessionKeyOf(connection: McpClientIdentity): string {
+ return `${connection.clientKey}|${connection.instanceId}`;
+}
+
+function pad2(value: number): string {
+ return String(value).padStart(2, "0");
+}
+
+/** "2026-09-25 14:32" in the machine's local time — tells sessions apart in chat titles. */
+function sessionLabel(startedAt: number): string {
+ const d = new Date(startedAt);
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+}
+
+function mcpMetaOf(
+ metadata: Record | null,
+): Partial | null {
+ if (!metadata) return null;
+ const mcp = metadata.mcp;
+ if (typeof mcp !== "object" || mcp === null) return null;
+ return mcp as Partial;
+}
+
+export function normalizeClientKey(raw: string): string {
+ return raw.trim().toLowerCase() || "unknown-client";
+}
+
+export class McpConnectionRegistry {
+ private readonly connections = new Map();
+ /** sessionKey → home chat id. */
+ private readonly homeChats = new Map();
+ /** `${sessionKey}|${designId}` → design chat id. */
+ private readonly designChats = new Map();
+ /** In-flight design-chat creations, so parallel calls share one chat. */
+ private readonly pendingDesignChats = new Map>();
+ /** sessionKey → tail of the connection's serialized-call queue. */
+ private readonly locks = new Map>();
+ private readonly now: () => number;
+
+ constructor(private readonly deps: McpConnectionDeps) {
+ this.now = deps.now ?? Date.now;
+ }
+
+ private designer(): DesignerSDK | undefined {
+ return this.deps.ctx.sdk.get(MODULE_SDK_TOKENS.DESIGNER) ?? undefined;
+ }
+
+ // ─── connections ────────────────────────────────────────────────────
+
+ /** Record activity from a client and return its connection state. */
+ touch(identity: McpClientIdentity): McpConnection {
+ this.evictIdle();
+ const clientKey = normalizeClientKey(identity.clientKey);
+ const instanceId = identity.instanceId.trim() || clientKey;
+ const now = this.now();
+ // Keyed by client AND instance: an instance id is only unique within the
+ // client that minted it, and two clients must never share a pin.
+ const key = `${clientKey}|${instanceId}`;
+ const existing = this.connections.get(key);
+ if (existing) {
+ existing.lastSeen = now;
+ if (identity.clientName && identity.clientName !== existing.clientKey) {
+ existing.clientName = identity.clientName;
+ }
+ return existing;
+ }
+ const connection: McpConnection = {
+ clientKey,
+ clientName: identity.clientName.trim() || clientKey,
+ instanceId,
+ pinnedDesignId: null,
+ lastDesignId: null,
+ buildIntent: null,
+ firstSeen: now,
+ lastSeen: now,
+ callCount: 0,
+ };
+ this.connections.set(key, connection);
+ return connection;
+ }
+
+ list(): McpConnectionSummary[] {
+ this.evictIdle();
+ return [...this.connections.values()]
+ .sort((a, b) => b.lastSeen - a.lastSeen)
+ .map((c) => ({
+ instanceId: c.instanceId,
+ clientKey: c.clientKey,
+ clientName: c.clientName,
+ pinnedDesignId: c.pinnedDesignId,
+ lastDesignId: c.lastDesignId,
+ firstSeen: new Date(c.firstSeen).toISOString(),
+ lastSeen: new Date(c.lastSeen).toISOString(),
+ callCount: c.callCount,
+ }));
+ }
+
+ private evictIdle(): void {
+ const cutoff = this.now() - CONNECTION_IDLE_MS;
+ for (const [id, connection] of this.connections) {
+ if (connection.lastSeen < cutoff) this.connections.delete(id);
+ }
+ }
+
+ /**
+ * Run `fn` after every earlier serialized call of this session finished.
+ * Used for writes and for the tools that bind the home chat: Claude Code
+ * issues tool calls in parallel, and two of those racing on one chat's
+ * binding or on one design's revision must not interleave. Reads are not
+ * serialized.
+ */
+ serialize(connection: McpClientIdentity, fn: () => Promise): Promise {
+ const key = sessionKeyOf(connection);
+ const previous = this.locks.get(key) ?? Promise.resolve();
+ const run = previous.then(fn, fn);
+ const tail = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ this.locks.set(key, tail);
+ void tail.then(() => {
+ if (this.locks.get(key) === tail) this.locks.delete(key);
+ });
+ return run;
+ }
+
+ nextRunId(connection: McpConnection): string {
+ connection.callCount += 1;
+ return `mcp:${connection.instanceId}:${crypto.randomUUID()}`;
+ }
+
+ // ─── design targeting ───────────────────────────────────────────────
+
+ /**
+ * Which design a call acts on: explicit argument → session pin → the design
+ * focused in the OpenPCB UI → the design this connection last used. The UI
+ * pointer is cleared whenever the Designer screen unmounts (the user is on
+ * the Assistant or Library screen), so the last-used fallback keeps a
+ * session working while the user looks elsewhere — with a warning, so the
+ * model knows it is not acting on what the user is looking at.
+ */
+ resolveDesign(
+ connection: McpConnection,
+ explicitDesignId?: string | null,
+ ): DesignTarget | null {
+ if (explicitDesignId) return { designId: explicitDesignId, source: "explicit" };
+ if (connection.pinnedDesignId) {
+ return { designId: connection.pinnedDesignId, source: "pin" };
+ }
+ const active = this.designer()?.getActiveDesignId() ?? null;
+ if (active) return { designId: active, source: "ui" };
+ if (connection.lastDesignId) {
+ return {
+ designId: connection.lastDesignId,
+ source: "last",
+ warning:
+ "No design is focused in OpenPCB, so this call used the design this session last worked on. Pass designId or call designer_use_design to be explicit.",
+ };
+ }
+ return null;
+ }
+
+ // ─── chats ──────────────────────────────────────────────────────────
+
+ private createChat(
+ title: string,
+ metadata: Record,
+ ): string {
+ const defaults = this.deps.chatDefaults();
+ return this.deps.conversation.createChat({
+ title,
+ providerConfigId: defaults.providerConfigId,
+ model: defaults.model,
+ promptPresetId: defaults.promptPresetId,
+ metadata,
+ }).id;
+ }
+
+ private chatExists(chatId: string | undefined): chatId is string {
+ return Boolean(chatId && this.deps.conversation.getChat(chatId));
+ }
+
+ /** The session's unbound home chat; created on first use. */
+ homeChat(connection: McpConnection): string {
+ const key = sessionKeyOf(connection);
+ const cached = this.homeChats.get(key);
+ if (this.chatExists(cached) && !this.deps.contextResolver.getPrimaryDesign(cached)) {
+ return cached;
+ }
+
+ for (const chat of this.deps.conversation.listChats()) {
+ const meta = mcpMetaOf(chat.metadata);
+ if (!this.isSessionChat(meta, connection) || meta?.role !== "home") continue;
+ if (this.deps.contextResolver.getPrimaryDesign(chat.id)) continue;
+ this.homeChats.set(key, chat.id);
+ return chat.id;
+ }
+
+ const chatId = this.createChat(
+ `MCP · ${connection.clientName} · ${sessionLabel(connection.firstSeen)}`,
+ { mcp: this.mcpMeta(connection, "home") },
+ );
+ this.homeChats.set(key, chatId);
+ return chatId;
+ }
+
+ /**
+ * The session's chat for `designId`, bound to it; created (and bound) on
+ * first use. Returns null when the design does not exist — the caller then
+ * runs the tool in the home chat, where it reports the missing design.
+ */
+ async designChat(
+ connection: McpConnection,
+ designId: string,
+ ): Promise {
+ const cacheKey = `${sessionKeyOf(connection)}|${designId}`;
+ const existing = this.findDesignChat(connection, designId);
+ if (existing) return existing;
+ const inFlight = this.pendingDesignChats.get(cacheKey);
+ if (inFlight) return inFlight;
+
+ const creation = (async () => {
+ const design = await this.designer()?.getDesign(designId);
+ if (!design) return null;
+ // Re-check after the await: another call may have created it.
+ const raced = this.findDesignChat(connection, designId);
+ if (raced) return raced;
+ const chatId = this.createChat(
+ `MCP · ${connection.clientName} · ${design.head.name} · ${sessionLabel(connection.firstSeen)}`,
+ {
+ scope: "designer",
+ designId: design.head.id,
+ designName: design.head.name,
+ mcp: this.mcpMeta(connection, "design"),
+ },
+ );
+ this.deps.contextResolver.bindDesignIfUnbound(chatId, {
+ id: design.head.id,
+ name: design.head.name,
+ });
+ this.designChats.set(cacheKey, chatId);
+ return chatId;
+ })();
+ this.pendingDesignChats.set(cacheKey, creation);
+ try {
+ return await creation;
+ } finally {
+ this.pendingDesignChats.delete(cacheKey);
+ }
+ }
+
+ /**
+ * After a tool ran in the home chat: if it bound the chat to a design, that
+ * chat is now the design's chat and the connection pins to the design.
+ * Returns the design id it adopted, or null when nothing changed.
+ */
+ adoptBoundHomeChat(connection: McpConnection, chatId: string): string | null {
+ const primary = this.deps.contextResolver.getPrimaryDesign(chatId);
+ if (!primary) return null;
+ connection.pinnedDesignId = primary.refId;
+ connection.lastDesignId = primary.refId;
+ if (this.findDesignChat(connection, primary.refId, chatId)) {
+ // The session already has a chat for this design (e.g. resolve_design
+ // on a design it worked on before): keep one chat per design — undo the
+ // bind and leave this chat as the home chat.
+ this.deps.conversation.deleteBinding(chatId, primary.id);
+ return primary.refId;
+ }
+ this.markDesignChat(chatId, connection, primary.refId, primary.label);
+ this.homeChats.delete(sessionKeyOf(connection));
+ return primary.refId;
+ }
+
+ private isSessionChat(
+ meta: Partial | null,
+ connection: McpClientIdentity,
+ ): boolean {
+ return Boolean(
+ meta &&
+ meta.clientKey === connection.clientKey &&
+ meta.instanceId === connection.instanceId,
+ );
+ }
+
+ private findDesignChat(
+ connection: McpConnection,
+ designId: string,
+ excludeChatId?: string,
+ ): string | null {
+ const cacheKey = `${sessionKeyOf(connection)}|${designId}`;
+ const cached = this.designChats.get(cacheKey);
+ if (
+ cached !== excludeChatId &&
+ this.chatExists(cached) &&
+ this.deps.contextResolver.getPrimaryDesign(cached)?.refId === designId
+ ) {
+ return cached;
+ }
+ for (const chat of this.deps.conversation.listChats()) {
+ if (chat.id === excludeChatId) continue;
+ const meta = mcpMetaOf(chat.metadata);
+ if (!this.isSessionChat(meta, connection) || meta?.role !== "design") continue;
+ if (this.deps.contextResolver.getPrimaryDesign(chat.id)?.refId === designId) {
+ this.designChats.set(cacheKey, chat.id);
+ return chat.id;
+ }
+ }
+ return null;
+ }
+
+ private markDesignChat(
+ chatId: string,
+ connection: McpConnection,
+ designId: string,
+ designName: string,
+ ): void {
+ const chat = this.deps.conversation.getChat(chatId);
+ this.deps.conversation.updateChat(chatId, {
+ title: `MCP · ${connection.clientName} · ${designName} · ${sessionLabel(connection.firstSeen)}`,
+ metadata: {
+ ...(chat?.metadata ?? {}),
+ scope: "designer",
+ designId,
+ designName,
+ mcp: this.mcpMeta(connection, "design"),
+ },
+ });
+ this.designChats.set(`${sessionKeyOf(connection)}|${designId}`, chatId);
+ }
+
+ private mcpMeta(
+ connection: McpConnection,
+ role: McpChatMetadata["role"],
+ ): McpChatMetadata {
+ return {
+ clientKey: connection.clientKey,
+ clientName: connection.clientName,
+ instanceId: connection.instanceId,
+ role,
+ };
+ }
+}
diff --git a/src/modules/assistant/backend/mcp/handler.ts b/src/modules/assistant/backend/mcp/handler.ts
index 3a8eb19c..c563616b 100644
--- a/src/modules/assistant/backend/mcp/handler.ts
+++ b/src/modules/assistant/backend/mcp/handler.ts
@@ -6,11 +6,19 @@ import {
} from "@modelcontextprotocol/server";
import type { AiToolRegistry } from "@openpcb/ai-core";
import type { ContextResolver } from "../context-resolver";
+import type { ConversationStore } from "../conversation-store";
import type { AssistantSettings } from "../../../../sdks/assistant";
import type { CoreBackendModuleContext } from "../../../../core/contracts/modules/backend-module";
import { checkMcpAuth } from "./auth";
import { buildMcpServer } from "./server";
-import type { McpSessionRegistry } from "./session";
+import type { McpCallRecorder } from "./call-recorder";
+import type { AssistantEventBus } from "../events";
+import type { BuildIntentStore } from "../verification/build-intent-store";
+import {
+ normalizeClientKey,
+ type McpClientIdentity,
+ type McpConnectionRegistry,
+} from "./connections";
/**
* HTTP face of the MCP server.
@@ -19,28 +27,38 @@ import type { McpSessionRegistry } from "./session";
* OpenPCB routes only ever see a Web `Request` and must return a `Response`
* (`core/contracts/modules/backend-module.ts`), which is exactly the shape
* `createMcpHandler` produces. No Node req/res adapter is involved.
+ *
+ * `createMcpHandler` serves 2025-era clients statelessly (a fresh server per
+ * POST, GET/DELETE answer 405) and 2026-era clients per request. Nothing about
+ * a client survives between requests inside the SDK, so identity travels in
+ * headers and is handed to the server factory as `authInfo`.
*/
-const CLIENT_HEADER = "x-openpcb-mcp-client";
+export const MCP_CLIENT_HEADER = "x-openpcb-mcp-client";
+export const MCP_CLIENT_NAME_HEADER = "x-openpcb-mcp-client-name";
+export const MCP_INSTANCE_HEADER = "x-openpcb-mcp-instance";
/**
- * Client identity, used to pick which assistant chat backs the session.
- *
- * Must be derived the same way on every request of a conversation — an
- * `initialize` that resolves to one chat and a `tools/call` that resolves to
- * another would split the transcript. So it comes only from headers, which are
- * stable across a client's requests: the bundled shim sets `CLIENT_HEADER`
- * explicitly, and direct HTTP clients fall back to their User-Agent.
+ * Client identity. `clientKey` must be derived the same way on every request
+ * of a conversation, so it comes only from headers: the bundled shim sets
+ * `X-OpenPCB-MCP-Client` explicitly (from the client's announced name), and
+ * direct HTTP clients fall back to their User-Agent. The display name comes
+ * from the shim's name header, else the `initialize` clientInfo, else the key.
*/
-function clientKeyFor(req: Request): string {
- const explicit = req.headers.get(CLIENT_HEADER)?.trim();
- if (explicit) return explicit;
+export function identityFor(req: Request, parsedBody: unknown): McpClientIdentity {
+ const explicit = req.headers.get(MCP_CLIENT_HEADER)?.trim();
const ua = req.headers.get("user-agent")?.trim();
- if (ua) return ua;
- return "unknown-client";
+ const clientKey = normalizeClientKey(explicit || ua || "unknown-client");
+ const clientName =
+ req.headers.get(MCP_CLIENT_NAME_HEADER)?.trim() ||
+ announcedClientName(parsedBody) ||
+ explicit ||
+ clientKey;
+ const instanceId = req.headers.get(MCP_INSTANCE_HEADER)?.trim() || clientKey;
+ return { clientKey, clientName, instanceId };
}
-/** Prefer the name the client announced at `initialize` for the chat title. */
+/** The name the client announced at `initialize`, if this request is one. */
function announcedClientName(parsedBody: unknown): string | null {
if (typeof parsedBody !== "object" || parsedBody === null) return null;
const body = parsedBody as { method?: unknown; params?: unknown };
@@ -71,41 +89,50 @@ export interface McpEndpointDeps {
ctx: CoreBackendModuleContext;
appVersion: string;
contextResolver: ContextResolver;
- sessions: McpSessionRegistry;
+ conversation: ConversationStore;
+ connections: McpConnectionRegistry;
+ recorder: McpCallRecorder;
+ events: AssistantEventBus;
+ buildIntents: BuildIntentStore;
getSettings(): AssistantSettings;
/**
* Tool registry for this endpoint. `allowWrites` selects whether write tools
- * are present at all; the caller caches per value so Ajv does not recompile
- * every schema on each request.
+ * are present at all; the caller caches it so Ajv does not recompile every
+ * schema on each request.
*/
getRegistry(allowWrites: boolean): AiToolRegistry;
+ pendingProposalHint: (chatTitle: string) => string;
}
export class McpEndpoint {
private handler: McpHttpHandler | null = null;
- /** Per-request client identity, read back inside the server factory. */
- private readonly requestClients = new WeakMap<
- Request,
- { key: string; name: string }
- >();
constructor(private readonly deps: McpEndpointDeps) {}
private ensureHandler(): McpHttpHandler {
if (this.handler) return this.handler;
this.handler = createMcpHandler((ctx) => {
- const identity = ctx.requestInfo
- ? this.requestClients.get(ctx.requestInfo)
- : undefined;
+ const extra = ctx.authInfo?.extra as
+ | { identity?: McpClientIdentity }
+ | undefined;
+ const identity = extra?.identity ?? {
+ clientKey: "unknown-client",
+ clientName: "unknown-client",
+ instanceId: "unknown-client",
+ };
const settings = this.deps.getSettings();
- return buildMcpServer(identity ?? { key: "unknown-client", name: "unknown-client" }, {
+ return buildMcpServer(identity, {
ctx: this.deps.ctx,
appVersion: this.deps.appVersion,
registry: this.deps.getRegistry(settings.mcpAllowWrites),
- sessions: this.deps.sessions,
+ connections: this.deps.connections,
+ recorder: this.deps.recorder,
+ events: this.deps.events,
contextResolver: this.deps.contextResolver,
- contextSizePreference: settings.contextSizePreference,
+ conversation: this.deps.conversation,
+ buildIntents: this.deps.buildIntents,
allowWrites: settings.mcpAllowWrites,
+ pendingProposalHint: this.deps.pendingProposalHint,
});
});
return this.handler;
@@ -118,7 +145,7 @@ export class McpEndpoint {
return jsonRpcError(
503,
-32000,
- "OpenPCB's MCP server is disabled. Enable it in Settings → Assistant → MCP.",
+ "OpenPCB's MCP server is disabled. Enable it in OpenPCB Settings → Assistant → MCP.",
);
}
@@ -148,16 +175,28 @@ export class McpEndpoint {
}
}
- const key = clientKeyFor(req);
- this.requestClients.set(req, {
- key,
- name: announcedClientName(parsedBody) ?? key,
+ const identity = identityFor(req, parsedBody);
+ return this.ensureHandler().fetch(req, {
+ ...(parsedBody === undefined ? {} : { parsedBody }),
+ authInfo: {
+ token: "",
+ clientId: identity.clientKey,
+ scopes: [],
+ extra: { identity },
+ },
});
+ }
- return this.ensureHandler().fetch(
- req,
- parsedBody === undefined ? undefined : { parsedBody },
- );
+ /**
+ * Tell 2026-era clients with an open `subscriptions/listen` stream that the
+ * tool and prompt lists changed (the user toggled writes, or the server).
+ * 2025-era clients are served statelessly and cannot be pushed to — the
+ * bundled shim polls `/mcp-state` and synthesizes the notification for them.
+ */
+ notifyToolsChanged(): void {
+ if (!this.handler) return;
+ this.handler.notify.toolsChanged();
+ this.handler.notify.promptsChanged();
}
async close(): Promise {
diff --git a/src/modules/assistant/backend/mcp/instructions.ts b/src/modules/assistant/backend/mcp/instructions.ts
new file mode 100644
index 00000000..b290cc66
--- /dev/null
+++ b/src/modules/assistant/backend/mcp/instructions.ts
@@ -0,0 +1,28 @@
+/**
+ * Server `instructions` returned at initialize.
+ *
+ * The in-app assistant gets its grounding rules through the system prompt
+ * (`prompt-service.ts`); an MCP client only gets what the server hands it.
+ * Claude Code puts these instructions in its system prompt but truncates them
+ * at 2,048 characters — silently, and after other servers' instructions — so
+ * this text is short, rule-first, and capped by a test (≤ 2,000 chars). The
+ * long-form workflows live in the MCP prompts and the Claude Code plugin skills.
+ *
+ * Every tool named here must exist (a test checks it against `tools/list`).
+ */
+export const MCP_SERVER_INSTRUCTIONS = `OpenPCB is the PCB design app running on this computer. These tools read and edit the design the user has open; the user sees your edits live.
+
+Target: tools act on the designId you pass, else the design pinned with designer_use_design, else the one focused in OpenPCB. Unsure? Call designer_list_designs. Never guess ids.
+
+Rules:
+- Ground every claim in a tool result; never report a change a result did not confirm.
+- Ask before assuming voltages, currents, ratings, packages, pinouts or fab limits; choose only reversible layout details yourself, and say so.
+- Library: search by generic family ("LED", color as a requirement); library_resolve_bom resolves a whole BOM. Never invent parts.
+- Build: prefer compile_circuit for block circuits; else place with designer_propose_schematic_edits, read designer_get_schematic_connectivity, wire in ONE designer_propose_schematic_wires call, then designer_verify_build and fix what it reports.
+- PCB (only when asked): read designer_get_pcb_layout, then pcb_place_footprints / pcb_route (nets by name, pads as REF.PAD, mm), then designer_run_drc.
+- Stable action_id on writes: a retry is a no-op; after a rejection or failure use a new one.
+- Undoable edits apply at once. Deletions, rule changes and DRC waivers wait for the user's approval in OpenPCB's panel: tell the user, then assistant_await_proposal; never re-send.
+- Ids of nets, wires and parts can change after edits: re-read before reusing them.
+- OpenPCB is the ERC/DRC authority; never compute clearances yourself. Report waived and hidden DRC counts: a board is not clean while any are suppressed.
+- The user's notes and specs live in OpenPCB Docs: knowledge_search_pages, knowledge_get_page.
+- No write tools listed? The user has not enabled "Allow writes" in OpenPCB Settings → Assistant → MCP.`;
diff --git a/src/modules/assistant/backend/mcp/prompts.ts b/src/modules/assistant/backend/mcp/prompts.ts
index 56affbcd..f168e440 100644
--- a/src/modules/assistant/backend/mcp/prompts.ts
+++ b/src/modules/assistant/backend/mcp/prompts.ts
@@ -23,20 +23,30 @@ function userText(text: string) {
};
}
-export function registerPrompts(server: McpServer): void {
+export function registerPrompts(
+ server: McpServer,
+ options: { allowWrites: boolean },
+): void {
+ // A build workflow the client cannot execute (no write tools listed) would
+ // only produce a transcript of refusals.
+ if (options.allowWrites) registerBuildPrompt(server);
+ registerReadPrompts(server);
+}
+
+function registerBuildPrompt(server: McpServer): void {
server.registerPrompt(
"openpcb-build-circuit",
{
title: "Build a circuit in OpenPCB",
description:
- "Resolve a BOM from the installed library, create/choose a design, place the parts and wire them — in one pass.",
+ "Settle the electrical requirements, resolve a BOM from the installed library, create/choose a design, then place, wire and verify the parts.",
argsSchema: fromJsonSchema<{ spec: string }>({
type: "object",
properties: {
spec: {
type: "string",
description:
- "What to build, e.g. '5V blinking red LED indicator at ~1Hz'.",
+ "What to build, with the requirements you know, e.g. 'red LED indicator driven from a 3.3 V GPIO, 0805 parts'.",
},
},
required: ["spec"],
@@ -51,11 +61,14 @@ export function registerPrompts(server: McpServer): void {
CORE_TOOL_INSTRUCTIONS,
WRITE_TOOL_INSTRUCTIONS,
"",
- "If no design is open, create one. Finish the build — placed AND wired — before summarising.",
+ "Before building, ask the user for anything electrical or manufacturing-critical the request leaves open (supply/logic voltage, currents and ratings, packages the assembly depends on, connector pinouts); choose only reversible layout details yourself and say what you chose.",
+ "If no design is open, create one. Once the requirements are settled, finish the build — placed, wired and verified with designer_verify_build — before summarising.",
].join("\n"),
),
);
+}
+function registerReadPrompts(server: McpServer): void {
server.registerPrompt(
"openpcb-review-schematic",
{
@@ -70,9 +83,9 @@ export function registerPrompts(server: McpServer): void {
"",
"1. Call designer_get_design_summary, then designer_get_schematic_connectivity.",
"2. Call designer_run_erc.",
- "3. Report: unconnected or floating pins, missing decoupling, missing pull-ups on open-drain/reset lines, power rails that are not driven, and any part whose value looks wrong for its role.",
+ "3. Report two separate groups: ERC findings (exactly what designer_run_erc reported), and engineering observations (heuristics such as missing decoupling or pull-ups, or a value that seems wrong for its role) — each with its evidence, and saying when the component data is not enough to be sure.",
"",
- "Ground every claim in a tool result — cite the reference designators and net names you saw. Do not propose edits unless asked; this is a review.",
+ "Ground every claim in a tool result — cite the reference designators and net names you saw. Never present an observation as a rule violation. Do not propose edits unless asked; this is a review.",
"",
CORE_TOOL_INSTRUCTIONS,
].join("\n"),
@@ -96,6 +109,7 @@ export function registerPrompts(server: McpServer): void {
"3. Group violations by root cause rather than listing them one by one — e.g. 'clearance too tight for the default net class', 'unrouted power net', 'annular ring below fab minimum'. Order by severity.",
"4. For each group, say what would fix it and whether the fix is a rule change or a layout change.",
"",
+ "Report the active, waived and hidden counts from designer_run_drc; never call the board clean while anything is waived or hidden. Waiving or ignoring rules is the user's decision — only on their explicit request.",
"OpenPCB is the authoritative DRC engine — never compute clearances yourself, and re-run designer_run_drc after any change.",
].join("\n"),
),
diff --git a/src/modules/assistant/backend/mcp/proposal-tools.ts b/src/modules/assistant/backend/mcp/proposal-tools.ts
new file mode 100644
index 00000000..6c6647a6
--- /dev/null
+++ b/src/modules/assistant/backend/mcp/proposal-tools.ts
@@ -0,0 +1,246 @@
+import { fromJsonSchema, type McpServer } from "@modelcontextprotocol/server";
+import type { AssistantWriteProposalDto } from "../../../../sdks";
+import type { ConversationStore } from "../conversation-store";
+import type { AssistantEventBus } from "../events";
+import type { McpConnection } from "./connections";
+import { failureResult, toCallToolResult } from "./result-envelope";
+import { withHeartbeat, type McpRequestCtx } from "./tool-projection";
+
+/**
+ * The MCP side of the approval round-trip.
+ *
+ * Destructive writes from an external agent stay pending until the user
+ * approves them in OpenPCB's assistant panel — one approval surface, the one
+ * the user already trusts. What an MCP client lacked was any way to learn the
+ * outcome, so it either stopped or re-sent the write. These tools close that
+ * loop: look a proposal up, list what is waiting, or block until the user
+ * decides (bounded, with heartbeat progress so the client's idle timeout does
+ * not fire while the user reads the card).
+ *
+ * Connection-scoped (registered per request next to `designer_use_design`),
+ * and limited to proposals this session proposed (the actor columns).
+ */
+
+/** Upper bound for one await; Claude Code aborts idle HTTP calls at 5 min. */
+export const MAX_AWAIT_SECONDS = 240;
+const DEFAULT_AWAIT_SECONDS = 120;
+
+export interface ProposalToolDeps {
+ conversation: ConversationStore;
+ events: AssistantEventBus;
+}
+
+/**
+ * A proposal belongs to the session that proposed it — client key AND
+ * instance id from its actor columns. Another Claude Code session of the same
+ * client must not see, await or react to it; the chat it lives in is
+ * presentation only.
+ */
+function ownedBy(connection: McpConnection, record: AssistantWriteProposalDto): boolean {
+ return (
+ record.actor?.clientKey === connection.clientKey &&
+ record.actor.instanceId === connection.instanceId
+ );
+}
+
+interface ApplyResultView {
+ status?: string;
+ appliedCount?: number;
+ failedCount?: number;
+ skippedCount?: number;
+ message?: string;
+ code?: string;
+ expectedRevision?: number;
+ currentRevision?: number;
+}
+
+function describe(record: AssistantWriteProposalDto): Record {
+ const apply = record.applyResult as ApplyResultView | null;
+ return {
+ id: record.id,
+ kind: record.kind,
+ status: record.status,
+ riskLevel: record.riskLevel,
+ designId: record.designId,
+ title: record.title,
+ summary: record.summary,
+ operationCount: record.operations?.length ?? 0,
+ applyResult: apply
+ ? {
+ status: apply.status ?? null,
+ appliedCount: apply.appliedCount ?? null,
+ failedCount: apply.failedCount ?? null,
+ skippedCount: apply.skippedCount ?? null,
+ message: apply.message ?? null,
+ code: apply.code ?? null,
+ ...(apply.code === "STALE_PROPOSAL"
+ ? {
+ expectedRevision: apply.expectedRevision ?? null,
+ currentRevision: apply.currentRevision ?? null,
+ }
+ : {}),
+ }
+ : null,
+ };
+}
+
+function outcomeLine(record: AssistantWriteProposalDto): string {
+ switch (record.status) {
+ case "pending":
+ return `Proposal ${record.id} is still waiting for the user in OpenPCB's assistant panel.`;
+ case "applied":
+ return `The user approved proposal ${record.id}; it was applied.`;
+ case "partial":
+ return `The user approved proposal ${record.id}; it was partially applied — re-read the design before continuing.`;
+ case "rejected":
+ return `The user rejected proposal ${record.id}. Do not re-send it; ask the user what they want instead.`;
+ case "failed": {
+ const apply = record.applyResult as ApplyResultView | null;
+ if (apply?.code === "STALE_PROPOSAL") {
+ return `Proposal ${record.id} was NOT applied: the design changed after it was proposed (revision ${apply.expectedRevision ?? "?"} → ${apply.currentRevision ?? "?"}). Re-read the design and, if it is still wanted, propose again with a new action_id.`;
+ }
+ return `Proposal ${record.id} failed${apply?.message ? `: ${apply.message}` : ""}. Re-read the design before trying again, with a new action_id.`;
+ }
+ default:
+ return `Proposal ${record.id} is ${record.status}.`;
+ }
+}
+
+function lookup(
+ deps: ProposalToolDeps,
+ connection: McpConnection,
+ proposalId: string,
+): AssistantWriteProposalDto | null {
+ const record = deps.conversation.getWriteProposalById(proposalId);
+ if (!record || !ownedBy(connection, record)) return null;
+ return record;
+}
+
+const PROPOSAL_ID_SCHEMA = {
+ type: "string",
+ description: "The proposal id from a write tool's result (structuredContent.proposal.id).",
+};
+
+const READ_ONLY = {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+} as const;
+
+export function registerProposalTools(
+ server: McpServer,
+ connection: McpConnection,
+ deps: ProposalToolDeps,
+): void {
+ server.registerTool(
+ "assistant_get_proposal",
+ {
+ description:
+ "Status of a write proposal this session created: pending (waiting for the user in OpenPCB), applied, partial, rejected or failed, plus the apply result.",
+ inputSchema: fromJsonSchema<{ proposalId: string }>({
+ type: "object",
+ properties: { proposalId: PROPOSAL_ID_SCHEMA },
+ required: ["proposalId"],
+ }),
+ annotations: READ_ONLY,
+ },
+ async (input: { proposalId: string }) => {
+ const record = lookup(deps, connection, input.proposalId);
+ if (!record) {
+ return failureResult(`No proposal '${input.proposalId}' from this session.`);
+ }
+ return toCallToolResult({
+ ok: true,
+ status: "ok",
+ summary: outcomeLine(record),
+ warnings: [],
+ truncated: false,
+ data: describe(record),
+ });
+ },
+ );
+
+ server.registerTool(
+ "assistant_list_pending_proposals",
+ {
+ description:
+ "Write proposals from this session that are still waiting for the user's approval in OpenPCB, newest first. Optionally filter by designId.",
+ inputSchema: fromJsonSchema<{ designId?: string }>({
+ type: "object",
+ properties: { designId: { type: "string" } },
+ }),
+ annotations: READ_ONLY,
+ },
+ async (input: { designId?: string } | undefined) => {
+ const pending = deps.conversation.listWriteProposalsByActor(connection, {
+ status: "pending",
+ designId: input?.designId || undefined,
+ });
+ return toCallToolResult({
+ ok: true,
+ status: "ok",
+ summary:
+ pending.length === 0
+ ? "Nothing is waiting for approval."
+ : `${pending.length} proposal(s) waiting for the user in OpenPCB.`,
+ warnings: [],
+ truncated: false,
+ data: { proposals: pending.map(describe) },
+ });
+ },
+ );
+
+ server.registerTool(
+ "assistant_await_proposal",
+ {
+ description: `Wait until the user approves or rejects a pending proposal in OpenPCB's assistant panel, up to timeoutSeconds (default ${DEFAULT_AWAIT_SECONDS}, max ${MAX_AWAIT_SECONDS}). Returns immediately if it is already decided. On timeout it returns status "pending": remind the user and call again, or move on.`,
+ inputSchema: fromJsonSchema<{ proposalId: string; timeoutSeconds?: number }>({
+ type: "object",
+ properties: {
+ proposalId: PROPOSAL_ID_SCHEMA,
+ timeoutSeconds: {
+ type: "integer",
+ minimum: 1,
+ maximum: MAX_AWAIT_SECONDS,
+ },
+ },
+ required: ["proposalId"],
+ }),
+ annotations: READ_ONLY,
+ },
+ async (
+ input: { proposalId: string; timeoutSeconds?: number },
+ ctx: unknown,
+ ) => {
+ const requestCtx = ctx as McpRequestCtx;
+ let record = lookup(deps, connection, input.proposalId);
+ if (!record) {
+ return failureResult(`No proposal '${input.proposalId}' from this session.`);
+ }
+ if (record.status === "pending") {
+ const timeoutMs =
+ Math.min(
+ Math.max(1, Math.floor(input.timeoutSeconds ?? DEFAULT_AWAIT_SECONDS)),
+ MAX_AWAIT_SECONDS,
+ ) * 1000;
+ await withHeartbeat(requestCtx, "waiting for the user's approval", () =>
+ deps.events.waitForProposal(
+ input.proposalId,
+ (status) => status !== "pending",
+ { timeoutMs, signal: requestCtx?.mcpReq?.signal },
+ ),
+ );
+ record = lookup(deps, connection, input.proposalId) ?? record;
+ }
+ return toCallToolResult({
+ ok: true,
+ status: "ok",
+ summary: outcomeLine(record),
+ warnings: [],
+ truncated: false,
+ data: describe(record),
+ });
+ },
+ );
+}
diff --git a/src/modules/assistant/backend/mcp/resources.ts b/src/modules/assistant/backend/mcp/resources.ts
index 68fa08cd..d1145aa3 100644
--- a/src/modules/assistant/backend/mcp/resources.ts
+++ b/src/modules/assistant/backend/mcp/resources.ts
@@ -1,5 +1,7 @@
import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/server";
import { MODULE_SDK_TOKENS, type DesignerSDK } from "../../../../sdks";
+import { MentionRegistry } from "../../../../core/backend/mentions";
+import { MentionContentResolver } from "../mention-content-resolver";
import type { CoreBackendModuleContext } from "../../../../core/contracts/modules/backend-module";
/**
@@ -129,4 +131,53 @@ export function registerResources(
);
}
+/**
+ * Docs (knowledge) pages as resources, so a client can attach a spec or a
+ * note as context the way the user @mentions it in-app. Same read path as the
+ * `knowledge_*` tools (core MentionRegistry → Tiptap-to-markdown).
+ */
+export function registerKnowledgeResources(server: McpServer): void {
+ if (!MentionRegistry.get().getProvider("knowledge-page")) return;
+ server.registerResource(
+ "openpcb-knowledge-page",
+ new ResourceTemplate(`${SCHEME}knowledge/{pageId}`, {
+ list: async () => {
+ const pages = await MentionRegistry.get().search(
+ { query: "", workspaceId: "default", limit: 100 },
+ ["knowledge-page"],
+ );
+ return {
+ resources: pages.map((page) => ({
+ uri: `${SCHEME}knowledge/${page.id}`,
+ name: page.displayText,
+ description: page.description ?? "OpenPCB Docs page",
+ mimeType: "text/markdown",
+ })),
+ };
+ },
+ }),
+ {
+ title: "OpenPCB Docs page",
+ description: "A page from the user's OpenPCB Docs, as markdown.",
+ mimeType: "text/markdown",
+ },
+ async (uri: URL) => {
+ const pageId = uri.href.slice(`${SCHEME}knowledge/`.length);
+ if (!/^[a-zA-Z0-9-]+$/.test(pageId)) {
+ throw new Error(`Unrecognised OpenPCB Docs resource: ${uri.href}`);
+ }
+ const [page] = await new MentionContentResolver(60_000, 60_000).resolveMessageMentions(
+ `@[knowledge-page:${pageId}|page]`,
+ "default",
+ );
+ if (!page || !page.exists) throw new Error(`No Docs page '${pageId}'.`);
+ return {
+ contents: [
+ { uri: uri.href, mimeType: "text/markdown", text: page.content },
+ ],
+ };
+ },
+ );
+}
+
export { KIND_LABELS as MCP_RESOURCE_KINDS };
diff --git a/src/modules/assistant/backend/mcp/result-envelope.ts b/src/modules/assistant/backend/mcp/result-envelope.ts
new file mode 100644
index 00000000..863680ed
--- /dev/null
+++ b/src/modules/assistant/backend/mcp/result-envelope.ts
@@ -0,0 +1,187 @@
+import type { AiToolResult } from "@openpcb/ai-core";
+
+/**
+ * The shape every projected tool returns to an MCP client.
+ *
+ * Why an envelope rather than `modelData` alone: MCP clients disagree about
+ * which half of a tool result reaches the model. Claude Code (2.1.27x+)
+ * forwards ONLY `structuredContent` when both halves are present; Claude
+ * Desktop reads ONLY `content`. So the readable parts — the one-line summary,
+ * warnings, the error message, the pending-proposal hint — must live inside
+ * `structuredContent` as well as in the text block, or one client family sees
+ * a bare object with no explanation (and a failing read becomes `null`).
+ */
+
+export interface McpProposalRef {
+ id: string;
+ kind: string;
+ /** Persisted proposal status after the call: pending, applied, partial, rejected, failed. */
+ status: string;
+ riskLevel: string | null;
+ designId: string | null;
+ operationCount: number;
+ /** Set while the proposal waits for the user; tells the model what to do next. */
+ approvalHint?: string;
+}
+
+export interface McpToolEnvelope {
+ ok: boolean;
+ status: "ok" | "partial" | "error";
+ summary: string;
+ warnings: string[];
+ error?: { message: string };
+ truncated: boolean;
+ proposal?: McpProposalRef;
+ data: unknown;
+}
+
+export interface McpCallToolResult {
+ [key: string]: unknown;
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent: Record;
+ isError: boolean;
+}
+
+/**
+ * Text block budget (≈6k tokens). The text repeats the structured result for
+ * clients that read only `content` (Claude Desktop), but every client that
+ * reads both pays for both — so the text keeps the summary, warnings and
+ * proposal lines in full and only a bounded slice of the data. The complete
+ * data is always in `structuredContent`; big reads page (e.g. the PCB layout)
+ * so the common case needs no cut at all.
+ */
+export const MAX_TEXT_CHARS = 24_000;
+
+/** Cut at `max` UTF-16 units without splitting a surrogate pair. */
+export function sliceCodePoints(text: string, max: number): string {
+ if (text.length <= max) return text;
+ const code = text.charCodeAt(max - 1);
+ return text.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
+}
+
+function failureMessage(result: AiToolResult): string {
+ const fromWarnings = result.warnings.filter((w) => w.trim().length > 0);
+ if (fromWarnings.length > 0) return fromWarnings.join(" ");
+ if (result.summary && result.summary.trim().length > 0) return result.summary;
+ return "The tool failed without a message.";
+}
+
+/** Several tools put their human-readable outcome in `data.message`. */
+function messageOf(data: unknown): string | null {
+ if (!data || typeof data !== "object") return null;
+ const message = (data as { message?: unknown }).message;
+ return typeof message === "string" && message.trim().length > 0
+ ? message.trim()
+ : null;
+}
+
+export function buildEnvelope(
+ result: AiToolResult,
+ proposal?: McpProposalRef | null,
+): McpToolEnvelope {
+ const payload = result.modelData !== undefined ? result.modelData : result.data;
+ const status: McpToolEnvelope["status"] = result.ok
+ ? result.status === "partial"
+ ? "partial"
+ : "ok"
+ : result.status === "partial"
+ ? "partial"
+ : "error";
+ const summary =
+ result.summary && result.summary.trim().length > 0
+ ? result.summary
+ : result.ok
+ ? (messageOf(result.data) ?? "Done.")
+ : failureMessage(result);
+ const envelope: McpToolEnvelope = {
+ ok: result.ok,
+ status,
+ summary,
+ warnings: result.warnings ?? [],
+ truncated: result.truncated === true,
+ data: payload === undefined ? null : payload,
+ };
+ if (!result.ok) envelope.error = { message: failureMessage(result) };
+ if (proposal) envelope.proposal = proposal;
+ return envelope;
+}
+
+function renderText(envelope: McpToolEnvelope): string {
+ const lines: string[] = [envelope.summary];
+ if (envelope.error && envelope.error.message !== envelope.summary) {
+ lines.push(`Error: ${envelope.error.message}`);
+ }
+ for (const warning of envelope.warnings) {
+ if (envelope.error?.message.includes(warning)) continue;
+ lines.push(`Warning: ${warning}`);
+ }
+ if (envelope.proposal) {
+ const p = envelope.proposal;
+ lines.push(
+ `Proposal ${p.id} (${p.kind}) is ${p.status}${p.riskLevel ? `, risk ${p.riskLevel}` : ""}.`,
+ );
+ if (p.approvalHint) lines.push(p.approvalHint);
+ }
+ if (envelope.truncated) {
+ lines.push("Result truncated — narrow the request to see the rest.");
+ }
+ if (envelope.data !== null && envelope.data !== undefined) {
+ let json: string;
+ try {
+ json = JSON.stringify(envelope.data);
+ } catch {
+ json = "\"\"";
+ }
+ lines.push(json);
+ }
+ const text = lines.join("\n");
+ if (text.length <= MAX_TEXT_CHARS) return text;
+ const kept = sliceCodePoints(text, MAX_TEXT_CHARS);
+ return `${kept}\n… [${text.length - kept.length} more chars not shown in text: the complete result is in structuredContent. Narrow the request (filters, paging) to read it as text.]`;
+}
+
+export function toCallToolResult(envelope: McpToolEnvelope): McpCallToolResult {
+ return {
+ content: [{ type: "text", text: renderText(envelope) }],
+ structuredContent: envelope as unknown as Record,
+ isError: !envelope.ok && envelope.status === "error",
+ };
+}
+
+/** A result for failures that happen around a tool (thrown errors, missing context). */
+export function failureResult(message: string): McpCallToolResult {
+ return toCallToolResult({
+ ok: false,
+ status: "error",
+ summary: message,
+ warnings: [],
+ error: { message },
+ truncated: false,
+ data: null,
+ });
+}
+
+/**
+ * Where a write tool put its proposal id. Schematic/PCB envelopes carry
+ * `{id, kind}` (the same shape `MessageCard` keys its proposal cards on);
+ * placement proposals carry `proposalId`.
+ */
+export function extractProposalRef(
+ data: unknown,
+): { id: string; kind: string } | null {
+ if (!data || typeof data !== "object") return null;
+ const record = data as { id?: unknown; kind?: unknown; proposalId?: unknown };
+ if (typeof record.id === "string" && typeof record.kind === "string") {
+ return { id: record.id, kind: record.kind };
+ }
+ if (typeof record.proposalId === "string") {
+ return {
+ id: record.proposalId,
+ kind:
+ typeof record.kind === "string"
+ ? record.kind
+ : "designer_place_components",
+ };
+ }
+ return null;
+}
diff --git a/src/modules/assistant/backend/mcp/server.ts b/src/modules/assistant/backend/mcp/server.ts
index ea2e2803..728b6452 100644
--- a/src/modules/assistant/backend/mcp/server.ts
+++ b/src/modules/assistant/backend/mcp/server.ts
@@ -1,13 +1,20 @@
import { McpServer } from "@modelcontextprotocol/server";
import type { AiToolRegistry } from "@openpcb/ai-core";
import type { ContextResolver } from "../context-resolver";
-import type { McpSessionRegistry } from "./session";
+import type { ConversationStore } from "../conversation-store";
+import type { McpCallRecorder } from "./call-recorder";
+import type { McpClientIdentity, McpConnectionRegistry } from "./connections";
import {
registerProjectedTools,
registerUseDesignTool,
} from "./tool-projection";
-import { registerResources } from "./resources";
+import { registerKnowledgeResources, registerResources } from "./resources";
import { registerPrompts } from "./prompts";
+import { MCP_SERVER_INSTRUCTIONS } from "./instructions";
+import { registerProposalTools } from "./proposal-tools";
+import { registerVerifyTool } from "./verify-tool";
+import type { BuildIntentStore } from "../verification/build-intent-store";
+import type { AssistantEventBus } from "../events";
import { MODULE_SDK_TOKENS, type DesignerSDK } from "../../../../sdks";
import type { CoreBackendModuleContext } from "../../../../core/contracts/modules/backend-module";
@@ -17,39 +24,56 @@ export interface BuildMcpServerDeps {
ctx: CoreBackendModuleContext;
appVersion: string;
registry: AiToolRegistry;
- sessions: McpSessionRegistry;
+ connections: McpConnectionRegistry;
+ recorder: McpCallRecorder;
+ events: AssistantEventBus;
contextResolver: ContextResolver;
- contextSizePreference: "small" | "medium" | "large";
+ conversation: ConversationStore;
+ buildIntents: BuildIntentStore;
allowWrites: boolean;
+ pendingProposalHint: (chatTitle: string) => string;
}
/**
- * Build the MCP server for one client.
+ * Build the MCP server for one request.
*
- * `createMcpHandler` calls this per request, so it must stay cheap: the session
- * (and its backing chat) is looked up, not recreated, and the tool registry is
- * built once by the caller.
+ * `createMcpHandler` calls this per HTTP request (the 2025-era path is
+ * stateless), so it must stay cheap: connection state and chats are looked
+ * up in `McpConnectionRegistry`, the tool registry is built once by the
+ * caller, and converted input schemas are cached per tool.
*/
export function buildMcpServer(
- identity: { key: string; name: string },
+ identity: McpClientIdentity,
deps: BuildMcpServerDeps,
): McpServer {
- const server = new McpServer({
- name: MCP_SERVER_NAME,
- version: deps.appVersion,
- });
+ const server = new McpServer(
+ { name: MCP_SERVER_NAME, version: deps.appVersion },
+ {
+ instructions: MCP_SERVER_INSTRUCTIONS,
+ // Advertised so clients listen for list changes: the tool set changes
+ // when the user toggles "Allow writes" (see McpEndpoint.notifyToolsChanged).
+ capabilities: {
+ tools: { listChanged: true },
+ prompts: { listChanged: true },
+ resources: { listChanged: true },
+ },
+ },
+ );
- const session = deps.sessions.acquire(identity);
+ const connection = deps.connections.touch(identity);
- registerProjectedTools(server, session, {
+ registerProjectedTools(server, connection, {
registry: deps.registry,
- sessions: deps.sessions,
+ connections: deps.connections,
+ recorder: deps.recorder,
contextResolver: deps.contextResolver,
- contextSizePreference: deps.contextSizePreference,
+ conversation: deps.conversation,
+ buildIntents: deps.buildIntents,
allowWrites: deps.allowWrites,
+ pendingProposalHint: deps.pendingProposalHint,
});
- registerUseDesignTool(server, session, { sessions: deps.sessions }, async () => {
+ registerUseDesignTool(server, connection, async () => {
const designer = deps.ctx.sdk.get(MODULE_SDK_TOKENS.DESIGNER);
if (!designer) return [];
return (await designer.listDesigns()).map((d) => ({
@@ -58,8 +82,22 @@ export function buildMcpServer(
}));
});
+ registerVerifyTool(server, connection, {
+ connections: deps.connections,
+ conversation: deps.conversation,
+ buildIntents: deps.buildIntents,
+ designer: () =>
+ deps.ctx.sdk.get(MODULE_SDK_TOKENS.DESIGNER) ?? undefined,
+ });
+
+ registerProposalTools(server, connection, {
+ conversation: deps.conversation,
+ events: deps.events,
+ });
+
registerResources(server, deps.ctx);
- registerPrompts(server);
+ registerKnowledgeResources(server);
+ registerPrompts(server, { allowWrites: deps.allowWrites });
return server;
}
diff --git a/src/modules/assistant/backend/mcp/session.ts b/src/modules/assistant/backend/mcp/session.ts
deleted file mode 100644
index 264ea5a0..00000000
--- a/src/modules/assistant/backend/mcp/session.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { MODULE_SDK_TOKENS, type DesignerSDK } from "../../../../sdks";
-import type { CoreBackendModuleContext } from "../../../../core/contracts/modules/backend-module";
-import type { ConversationStore } from "../conversation-store";
-import type { ContextResolver } from "../context-resolver";
-
-/**
- * MCP sessions are backed by a real assistant chat.
- *
- * Every designer tool resolves its target design through the chat's context
- * bindings (`ContextResolver.getPrimaryDesign(chatId)` — see
- * `tools/designer-tools.ts`). Giving each MCP client a chat therefore makes all
- * of those tools work unmodified, and has the side benefit that tool events and
- * write proposals persist down the normal path: the user watches what the
- * external agent did in the assistant panel, and approves pending deletions
- * with the buttons that are already there.
- *
- * One chat per client *name*, not per MCP session: clients reconnect constantly
- * (every restart is a new session id), and a chat per session would bury the
- * sidebar within a day.
- */
-
-const CHAT_TITLE_PREFIX = "MCP";
-
-export interface McpSession {
- /** Stable across reconnects for a given client. */
- clientKey: string;
- clientName: string;
- chatId: string;
- /** Set by `designer_use_design`; beats the UI-active design, loses to an explicit argument. */
- pinnedDesignId: string | null;
- toolCallSeq: number;
-}
-
-export interface McpSessionDeps {
- ctx: CoreBackendModuleContext;
- conversation: ConversationStore;
- contextResolver: ContextResolver;
- /** Creates a chat with the configured default provider/preset. */
- createChat: (input: { title: string }) => { id: string };
-}
-
-function chatTitleFor(clientName: string): string {
- return `${CHAT_TITLE_PREFIX} · ${clientName}`;
-}
-
-function isMcpChatFor(
- metadata: Record | null,
- clientKey: string,
-): boolean {
- if (!metadata) return false;
- const mcp = metadata.mcp;
- if (typeof mcp !== "object" || mcp === null) return false;
- return (mcp as { clientKey?: unknown }).clientKey === clientKey;
-}
-
-export class McpSessionRegistry {
- private readonly sessions = new Map();
-
- constructor(private readonly deps: McpSessionDeps) {}
-
- private designerSdk(): DesignerSDK | undefined {
- return (
- this.deps.ctx.sdk.get(MODULE_SDK_TOKENS.DESIGNER) ?? undefined
- );
- }
-
- /**
- * Get (or lazily create) the session for a client. Reuses the client's
- * existing chat across app restarts by matching `metadata.mcp.clientKey`,
- * so the transcript is continuous.
- *
- * `key` must be stable across every request of a conversation (it comes from
- * a header — see `handler.ts`), while `name` is only cosmetic and may not be
- * known until `initialize`. Keying on the display name instead would split
- * one client across two chats the moment the announced name differs from the
- * header.
- */
- acquire(identity: { key: string; name: string }): McpSession {
- const clientKey = identity.key.trim().toLowerCase() || "unknown-client";
- const clientName = identity.name.trim() || clientKey;
- const cached = this.sessions.get(clientKey);
- if (cached && this.deps.conversation.getChat(cached.chatId)) return cached;
-
- const existing = this.deps.conversation
- .listChats()
- .find((chat) => isMcpChatFor(chat.metadata, clientKey));
-
- const chatId =
- existing?.id ??
- this.deps.createChat({ title: chatTitleFor(clientName) }).id;
-
- if (!existing) {
- this.deps.conversation.updateChat(chatId, {
- metadata: { mcp: { clientKey, clientName } },
- });
- }
-
- const session: McpSession = {
- clientKey,
- clientName,
- chatId,
- pinnedDesignId: null,
- toolCallSeq: 0,
- };
- this.sessions.set(clientKey, session);
- return session;
- }
-
- /**
- * Which design a tool call should act on.
- *
- * Explicit argument wins, then a `designer_use_design` pin, then whatever the
- * user has focused in the designer UI. `null` is a legitimate answer (nothing
- * open, nothing pinned) — callers turn it into a message telling the model to
- * list designs or create one.
- */
- resolveDesignId(
- session: McpSession,
- explicitDesignId?: string | null,
- ): string | null {
- if (explicitDesignId) return explicitDesignId;
- if (session.pinnedDesignId) return session.pinnedDesignId;
- return this.designerSdk()?.getActiveDesignId() ?? null;
- }
-
- /**
- * Point the session's chat at `designId`.
- *
- * `ContextResolver.maybeAutoBindDesign` refuses to move a chat that is
- * already bound elsewhere — correct for a human conversation, wrong here,
- * where one long-lived chat follows the user across designs. So drop a
- * mismatched primary binding first and rebind.
- */
- async bindSessionToDesign(
- session: McpSession,
- designId: string,
- ): Promise {
- const primary = this.deps.contextResolver.getPrimaryDesign(session.chatId);
- if (primary?.refId === designId) return;
- if (primary) {
- this.deps.conversation.deleteBinding(session.chatId, primary.id);
- }
- const design = await this.designerSdk()?.getDesign(designId);
- if (!design) return;
- await this.deps.contextResolver.bindDesign(session.chatId, {
- id: design.head.id,
- name: design.head.name,
- });
- }
-
- /** Monotonic per-session counter, used to build stable run ids. */
- nextRunId(session: McpSession): string {
- session.toolCallSeq += 1;
- return `mcp:${session.clientKey}:${session.toolCallSeq}`;
- }
-}
diff --git a/src/modules/assistant/backend/mcp/tool-policy.ts b/src/modules/assistant/backend/mcp/tool-policy.ts
new file mode 100644
index 00000000..fe597afc
--- /dev/null
+++ b/src/modules/assistant/backend/mcp/tool-policy.ts
@@ -0,0 +1,118 @@
+import type { ToolAnnotations } from "@modelcontextprotocol/server";
+import type { AiTool } from "@openpcb/ai-core";
+
+/**
+ * MCP-side metadata for projected tools that `AiToolDefinition` cannot carry.
+ *
+ * `@openpcb/ai-core` definitions know `effect` (read | write) but not risk,
+ * idempotency or result size, and the in-app registry must not grow fields
+ * just for MCP. So the projection keeps its own table, keyed by tool name.
+ * `assistant-mcp-tools.test.ts` fails when a write tool is registered without
+ * an entry here — a new write tool must decide its annotations explicitly.
+ */
+
+export interface McpToolPolicy {
+ /** Irreversible or removes user work. Maps to `destructiveHint`. */
+ destructive?: boolean;
+ /**
+ * Repeating the call with the same arguments is a no-op. Writes default to
+ * false: their `action_id` idempotency key is optional, so a caller that
+ * omits it can double-apply.
+ */
+ idempotent?: boolean;
+ /**
+ * Claude Code spills results over ~25k tokens to a file unless the tool
+ * declares a larger `anthropic/maxResultSizeChars` (max 500k). Set on tools
+ * whose data scales with the design.
+ */
+ maxResultSizeChars?: number;
+ /**
+ * Changes what the user SEES in the OpenPCB window (e.g. focusing a design)
+ * but no design data. Such tools are `effect: "read"` — so they stay
+ * available with writes off — yet must not claim `readOnlyHint`.
+ */
+ uiSideEffect?: boolean;
+}
+
+const LARGE_RESULT = 400_000;
+
+export const MCP_TOOL_POLICIES: Record = {
+ // ── reads ────────────────────────────────────────────────────────────
+ designer_get_schematic_connectivity: { maxResultSizeChars: LARGE_RESULT },
+ designer_get_design_summary: { maxResultSizeChars: LARGE_RESULT },
+ designer_run_drc: { maxResultSizeChars: LARGE_RESULT },
+ designer_run_erc: { maxResultSizeChars: LARGE_RESULT },
+ designer_get_bom: { maxResultSizeChars: LARGE_RESULT },
+ designer_get_pcb_layout: { maxResultSizeChars: LARGE_RESULT },
+ // ── in-app writes (schematic) ────────────────────────────────────────
+ designer_create_design: {},
+ designer_place_components: {},
+ designer_propose_schematic_edits: {},
+ designer_propose_schematic_wires: {},
+ designer_propose_schematic_updates: {},
+ designer_arrange_schematic: { idempotent: true },
+ designer_propose_schematic_deletions: { destructive: true },
+ compile_circuit: {},
+ // ── MCP-only writes (tools/mcp-pcb-tools.ts, tools/mcp-design-tools.ts) ─
+ pcb_place_footprints: {},
+ pcb_route: {},
+ pcb_delete_routing: { destructive: true },
+ pcb_set_board_outline: {},
+ // Not undoable; always waits for approval (APPROVAL_REQUIRED_KINDS).
+ pcb_set_design_rules: { destructive: true },
+ pcb_add_zone: {},
+ pcb_update_zone: {},
+ pcb_delete_zone: { destructive: true },
+ pcb_add_keepout: {},
+ pcb_update_keepout: {},
+ pcb_delete_keepout: { destructive: true },
+ // Suppress verification: always approval-tier (APPROVAL_REQUIRED_KINDS);
+ // flagged destructive so clients treat them with the same care.
+ pcb_waive_drc_violations: { destructive: true },
+ pcb_set_drc_rule_class_ignores: { destructive: true },
+ designer_rename_design: { idempotent: true },
+ designer_delete_design: { destructive: true },
+ designer_focus_design: { uiSideEffect: true, idempotent: true },
+ designer_undo: {},
+ designer_redo: {},
+};
+
+export function policyFor(name: string): McpToolPolicy | undefined {
+ return MCP_TOOL_POLICIES[name];
+}
+
+export function annotationsFor(tool: AiTool): ToolAnnotations {
+ const policy = policyFor(tool.definition.name) ?? {};
+ if (policy.uiSideEffect) {
+ return {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: policy.idempotent === true,
+ openWorldHint: false,
+ };
+ }
+ const readOnly = tool.definition.effect === "read";
+ return {
+ readOnlyHint: readOnly,
+ destructiveHint: !readOnly && policy.destructive === true,
+ idempotentHint: readOnly || policy.idempotent === true,
+ openWorldHint: false,
+ };
+}
+
+export function metaFor(tool: AiTool): Record | undefined {
+ const size = policyFor(tool.definition.name)?.maxResultSizeChars;
+ return size ? { "anthropic/maxResultSizeChars": size } : undefined;
+}
+
+/**
+ * Claude Code truncates each tool description at 2,048 characters. Anything
+ * past the cap is silently lost, so keep a margin.
+ */
+export const MAX_DESCRIPTION_CHARS = 2_000;
+
+export function mcpDescription(tool: AiTool): string {
+ const description = tool.definition.description;
+ if (description.length <= MAX_DESCRIPTION_CHARS) return description;
+ return `${description.slice(0, MAX_DESCRIPTION_CHARS - 1)}…`;
+}
diff --git a/src/modules/assistant/backend/mcp/tool-projection.ts b/src/modules/assistant/backend/mcp/tool-projection.ts
index 64ed7ae1..d34f062b 100644
--- a/src/modules/assistant/backend/mcp/tool-projection.ts
+++ b/src/modules/assistant/backend/mcp/tool-projection.ts
@@ -1,7 +1,6 @@
import {
fromJsonSchema,
type McpServer,
- type ToolAnnotations,
} from "@modelcontextprotocol/server";
import type {
AiTool,
@@ -11,72 +10,327 @@ import type {
} from "@openpcb/ai-core";
import { resolveToolLimits } from "@openpcb/ai-core";
import type { ContextResolver } from "../context-resolver";
-import type { McpSession, McpSessionRegistry } from "./session";
+import type { ConversationStore } from "../conversation-store";
+import type { McpCallRecorder } from "./call-recorder";
+import type { McpConnection, McpConnectionRegistry } from "./connections";
+import {
+ buildEnvelope,
+ extractProposalRef,
+ failureResult,
+ toCallToolResult,
+ type McpCallToolResult,
+ type McpProposalRef,
+} from "./result-envelope";
+import { annotationsFor, mcpDescription, metaFor } from "./tool-policy";
+import type { BuildIntentStore } from "../verification/build-intent-store";
+import {
+ intentFromBomResult,
+ intentFromCompileResult,
+} from "../verification/build-intent-capture";
+
+/** Task key the MCP path stores build intents under (see verify-tool.ts). */
+const MCP_INTENT_TASK_ID = "mcp";
/**
* Project the assistant's `AiToolRegistry` onto an MCP server.
*
* `AiToolDefinition` is already MCP-shaped — `{name, description, inputSchema}`
* with a name pattern MCP accepts verbatim — so this is a projection, not a
- * reimplementation. The interesting parts are the two adaptations:
+ * reimplementation. What the projection adds around each call:
*
- * 1. `designId` injection. In-app the design comes from the chat's bindings;
- * an MCP client has no chat, so the session resolves one (explicit arg →
- * session pin → UI-active) and it is written into the input before dispatch.
- * 2. Result mapping. `AiToolResult` already carries a model-facing slim view
- * (`modelData`) and a one-line `summary`, built for the in-app LLM loop.
- * Those are exactly the right payload for MCP, so reuse them rather than
- * shipping the full `data` (which can be megabytes of projection).
+ * 1. **Targeting.** The design comes from explicit arg → session pin → the
+ * UI-focused design → the connection's last design (`connections.ts`), and
+ * the call runs in the chat bound to that design, so every in-app tool
+ * resolves it through its chat binding unchanged.
+ * 2. **Recording.** Every call becomes a tool event on a visible activity
+ * message (`call-recorder.ts`): the user sees what the agent did, and a
+ * write proposal gets the event its approval card is rendered from.
+ * 3. **Result envelope** (`result-envelope.ts`) — readable in both halves of
+ * the MCP result, because clients disagree on which half the model sees.
+ * 4. **Liveness.** A heartbeat progress notification while a call runs (when
+ * the client asked for progress), so a slow DRC or ERC does not trip the
+ * client's idle timeout; and the request's abort signal reaches the tool.
*/
-/** Tools that are destructive rather than merely mutating. */
-const DESTRUCTIVE_TOOLS = new Set([
- "designer_propose_schematic_deletions",
-]);
-
/**
- * Tools that operate on a design and therefore accept `designId`. Detected from
- * the schema rather than a hardcoded list so new tools are picked up for free.
+ * `createMcpHandler` builds a fresh server per request, which re-registers
+ * every tool; converting ~40 JSON Schemas each time is the dominant cost, so
+ * keep the converted schema per tool definition.
*/
-function acceptsDesignId(tool: AiTool): boolean {
+const schemaCache = new WeakMap>();
+
+function inputSchemaFor(tool: AiTool): ReturnType {
+ let schema = schemaCache.get(tool);
+ if (!schema) {
+ schema = fromJsonSchema>(tool.definition.inputSchema);
+ schemaCache.set(tool, schema);
+ }
+ return schema;
+}
+
+/** Tools that act on a design accept `designId` — detected from the schema. */
+export function acceptsDesignId(tool: AiTool): boolean {
const props = tool.definition.inputSchema.properties;
return Boolean(props && "designId" in props);
}
-function annotationsFor(tool: AiTool): ToolAnnotations {
- const readOnly = tool.definition.effect === "read";
- return {
- readOnlyHint: readOnly,
- destructiveHint: DESTRUCTIVE_TOOLS.has(tool.definition.name),
- // Every write tool takes an `action_id` idempotency key and re-issuing an
- // applied action is a no-op (`tools/action-id.ts`).
- idempotentHint: !readOnly,
- openWorldHint: false,
+/** Interval between heartbeat progress notifications. */
+export const HEARTBEAT_MS = 10_000;
+
+/** The slice of the SDK's request context the projection uses. */
+export interface McpRequestCtx {
+ mcpReq?: {
+ signal?: AbortSignal;
+ _meta?: { progressToken?: string | number } & Record;
+ notify?: (notification: {
+ method: string;
+ params?: Record;
+ }) => Promise;
};
}
-function textOf(result: AiToolResult): string {
- if (result.summary && result.summary.length > 0) return result.summary;
- const payload = result.modelData ?? result.data;
+/**
+ * Run `work` while sending `notifications/progress` every HEARTBEAT_MS, if the
+ * client supplied a progress token. Progress resets Claude Code's idle timer
+ * (5 minutes for HTTP servers); it never extends the wall-clock limit.
+ */
+export async function withHeartbeat(
+ ctx: McpRequestCtx | undefined,
+ label: string,
+ work: () => Promise,
+): Promise {
+ const token = ctx?.mcpReq?._meta?.progressToken;
+ const notify = ctx?.mcpReq?.notify;
+ if (token === undefined || !notify) return work();
+ let ticks = 0;
+ const timer = setInterval(() => {
+ ticks += 1;
+ void notify({
+ method: "notifications/progress",
+ params: {
+ progressToken: token,
+ progress: ticks,
+ message: `${label}: still working (${(ticks * HEARTBEAT_MS) / 1000}s)`,
+ },
+ }).catch(() => undefined);
+ }, HEARTBEAT_MS);
try {
- return JSON.stringify(payload);
- } catch {
- return result.ok ? "ok" : "error";
+ return await work();
+ } finally {
+ clearInterval(timer);
}
}
export interface ToolProjectionDeps {
registry: AiToolRegistry;
- sessions: McpSessionRegistry;
+ connections: McpConnectionRegistry;
+ recorder: McpCallRecorder;
contextResolver: ContextResolver;
- contextSizePreference: "small" | "medium" | "large";
+ conversation: ConversationStore;
+ buildIntents: BuildIntentStore;
/** When false, `effect: "write"` tools are not registered at all. */
allowWrites: boolean;
+ /** Next step the model should take while a proposal waits for the user. */
+ pendingProposalHint: (chatTitle: string) => string;
+}
+
+function proposalRefFor(
+ deps: ToolProjectionDeps,
+ chatId: string,
+ data: unknown,
+): McpProposalRef | null {
+ const ref = extractProposalRef(data);
+ if (!ref) return null;
+ // A dedup hit returns an earlier proposal, which may live in another of
+ // this session's chats.
+ const record =
+ deps.conversation.getWriteProposal(chatId, ref.id) ??
+ deps.conversation.getWriteProposalById(ref.id);
+ const status = record?.status ?? "pending";
+ const proposal: McpProposalRef = {
+ id: ref.id,
+ kind: record?.kind ?? ref.kind,
+ status,
+ riskLevel: record?.riskLevel ?? null,
+ designId: record?.designId ?? null,
+ operationCount: record?.operations?.length ?? 0,
+ };
+ if (status === "pending") {
+ const title = deps.conversation.getChat(chatId)?.title ?? "the MCP chat";
+ proposal.approvalHint = deps.pendingProposalHint(title);
+ }
+ return proposal;
+}
+
+function thrownResult(message: string): AiToolResult {
+ return {
+ ok: false,
+ data: null,
+ summary: message,
+ sources: [],
+ warnings: [message],
+ truncated: false,
+ limits: resolveToolLimits({ preference: "large" }),
+ };
+}
+
+/**
+ * Read tools that may bind the session's home chat to a design. They run
+ * under the connection lock like writes, so two parallel calls cannot bind
+ * one chat twice (see `McpConnectionRegistry.serialize`).
+ */
+const BINDING_READ_TOOLS = new Set(["designer_resolve_design"]);
+
+/**
+ * One projected call: target → chat → record → execute → adopt/pin → envelope.
+ * Writes and chat-binding tools are serialized per session; reads run
+ * concurrently. Exported for tests.
+ */
+export async function runProjectedTool(
+ tool: AiTool,
+ connection: McpConnection,
+ deps: ToolProjectionDeps,
+ input: Record | undefined,
+ requestCtx?: McpRequestCtx,
+): Promise {
+ const def = tool.definition;
+ if (def.effect === "write" || BINDING_READ_TOOLS.has(def.name)) {
+ return deps.connections.serialize(connection, () =>
+ runProjectedToolNow(tool, connection, deps, input, requestCtx),
+ );
+ }
+ return runProjectedToolNow(tool, connection, deps, input, requestCtx);
+}
+
+async function runProjectedToolNow(
+ tool: AiTool,
+ connection: McpConnection,
+ deps: ToolProjectionDeps,
+ input: Record | undefined,
+ requestCtx?: McpRequestCtx,
+): Promise {
+ const def = tool.definition;
+ const args: Record = { ...(input ?? {}) };
+ const extraWarnings: string[] = [];
+
+ let chatId: string | null = null;
+ let targetDesignId: string | null = null;
+ if (acceptsDesignId(tool)) {
+ const target = deps.connections.resolveDesign(
+ connection,
+ typeof args.designId === "string" && args.designId.trim()
+ ? args.designId.trim()
+ : null,
+ );
+ if (target) {
+ args.designId = target.designId;
+ targetDesignId = target.designId;
+ if (target.warning) extraWarnings.push(target.warning);
+ chatId = await deps.connections.designChat(connection, target.designId);
+ }
+ }
+ const ranInHomeChat = chatId === null;
+ if (chatId === null) chatId = deps.connections.homeChat(connection);
+
+ const call = deps.recorder.begin({
+ chatId,
+ connection,
+ toolName: def.name,
+ args,
+ });
+
+ const execCtx: AiToolExecutionContext = {
+ runId: deps.connections.nextRunId(connection),
+ chatId,
+ bindings: deps.contextResolver.listBindings(chatId),
+ // Claude-class models have large contexts; the in-app "small/medium"
+ // presets exist for local models and do not apply to an external agent.
+ limits: resolveToolLimits({ preference: "large" }),
+ signal: requestCtx?.mcpReq?.signal,
+ metadata: { mcp: { clientKey: connection.clientKey, instanceId: connection.instanceId } },
+ };
+
+ let result: AiToolResult;
+ let completed = true;
+ try {
+ result = await withHeartbeat(requestCtx, def.name, () =>
+ tool.execute(execCtx, args),
+ );
+ } catch (error) {
+ completed = false;
+ const message = error instanceof Error ? error.message : String(error);
+ result = thrownResult(`${def.name} failed: ${message}`);
+ }
+
+ if (ranInHomeChat) {
+ const adopted = deps.connections.adoptBoundHomeChat(connection, chatId);
+ if (adopted) targetDesignId = adopted;
+ }
+ if (targetDesignId && result.ok) connection.lastDesignId = targetDesignId;
+
+ if (extraWarnings.length > 0) {
+ result = { ...result, warnings: [...(result.warnings ?? []), ...extraWarnings] };
+ }
+
+ let resultJson: string | null = null;
+ try {
+ resultJson = result.data === undefined ? null : JSON.stringify(result.data);
+ } catch {
+ resultJson = null;
+ }
+
+ if (completed && result.ok && resultJson) {
+ captureBuildIntent(def.name, resultJson, connection, chatId, deps);
+ }
+
+ const proposal = proposalRefFor(deps, chatId, result.data);
+ const envelope = buildEnvelope(result, proposal);
+ deps.recorder.end(call, {
+ completed,
+ ok: result.ok,
+ summary: envelope.summary,
+ resultJson,
+ error: envelope.error?.message ?? null,
+ sources: result.sources,
+ });
+
+ return completed ? toCallToolResult(envelope) : failureResult(envelope.summary);
+}
+
+/**
+ * Parity with the in-app loop, which records what the user asked to build so
+ * the Definition-of-Done verifier can check the result (`run-service.ts`).
+ * A resolved BOM usually precedes the design, so it waits on the connection
+ * until `designer_verify_build` attaches it; a compile already runs in the
+ * design's chat, so it is stored there directly.
+ */
+function captureBuildIntent(
+ toolName: string,
+ resultJson: string,
+ connection: McpConnection,
+ chatId: string,
+ deps: ToolProjectionDeps,
+): void {
+ if (toolName === "library_resolve_bom") {
+ const intent = intentFromBomResult(resultJson);
+ if (intent) connection.buildIntent = intent;
+ return;
+ }
+ if (toolName === "compile_circuit") {
+ const intent = intentFromCompileResult(resultJson);
+ if (!intent) return;
+ try {
+ deps.buildIntents.save({ chatId, taskId: MCP_INTENT_TASK_ID, ...intent });
+ connection.buildIntent = null;
+ } catch {
+ // Best-effort, like the in-app capture.
+ }
+ }
}
export function registerProjectedTools(
server: McpServer,
- session: McpSession,
+ connection: McpConnection,
deps: ToolProjectionDeps,
): void {
for (const tool of deps.registry.list()) {
@@ -85,69 +339,45 @@ export function registerProjectedTools(
// write tool gets a clean capability picture instead of a runtime denial.
if (!deps.allowWrites && def.effect === "write") continue;
+ const meta = metaFor(tool);
server.registerTool(
def.name,
{
- description: def.description,
- inputSchema: fromJsonSchema>(def.inputSchema),
+ description: mcpDescription(tool),
+ inputSchema: inputSchemaFor(tool),
annotations: annotationsFor(tool),
+ ...(meta ? { _meta: meta } : {}),
},
- async (input) => {
- const args: Record = { ...(input ?? {}) };
-
- if (acceptsDesignId(tool)) {
- const designId = deps.sessions.resolveDesignId(
- session,
- typeof args.designId === "string" ? args.designId : null,
- );
- if (designId) {
- args.designId = designId;
- // Keep the chat's binding aligned with the design being acted on,
- // so the tools that read the binding (rather than the argument)
- // agree with the ones that read the argument.
- await deps.sessions.bindSessionToDesign(session, designId);
- }
- }
-
- const execCtx: AiToolExecutionContext = {
- runId: deps.sessions.nextRunId(session),
- chatId: session.chatId,
- bindings: deps.contextResolver.listBindings(session.chatId),
- limits: resolveToolLimits({
- preference: deps.contextSizePreference,
- }),
- };
-
- const result = await tool.execute(execCtx, args);
-
- return {
- content: [{ type: "text" as const, text: textOf(result) }],
- structuredContent: asStructured(result),
- isError: !result.ok,
- };
- },
+ (input: unknown, ctx: unknown) =>
+ runProjectedTool(
+ tool,
+ connection,
+ deps,
+ (input ?? {}) as Record,
+ ctx as McpRequestCtx,
+ ),
);
}
}
/**
- * Pin the session to a design.
+ * Pin this connection to a design.
*
- * Session-scoped, so it lives here rather than in the shared registry: it is
- * the only tool that writes to `McpSession`, and there is no session to write
- * to when the same registry backs the in-app assistant.
+ * Connection-scoped, so it lives here rather than in the shared registry: it
+ * is the only tool that writes connection state, and there is no connection
+ * when the same registry backs the in-app assistant. It changes nothing in the
+ * design or any chat, hence read-only.
*/
export function registerUseDesignTool(
server: McpServer,
- session: McpSession,
- deps: Pick,
+ connection: McpConnection,
listDesigns: () => Promise>,
): void {
server.registerTool(
"designer_use_design",
{
description:
- "Pin this MCP session to a design so later calls do not need designId. The pin beats the design the user has focused in the OpenPCB UI; pass null to drop it and follow the UI again. Call designer_list_designs first to get an id.",
+ "Pin this MCP session to a design so later calls do not need designId. The pin beats the design the user has focused in the OpenPCB UI and lasts until the session goes idle; pass null to drop it and follow the UI again. Call designer_list_designs first to get an id.",
inputSchema: fromJsonSchema<{ designId?: string | null }>({
type: "object",
properties: {
@@ -164,54 +394,35 @@ export function registerUseDesignTool(
openWorldHint: false,
},
},
- async (input) => {
+ async (input: { designId?: string | null } | undefined) => {
const requested = (input ?? {}).designId ?? null;
if (requested === null) {
- session.pinnedDesignId = null;
- return {
- content: [
- {
- type: "text" as const,
- text: "Pin cleared; following the design focused in OpenPCB.",
- },
- ],
- structuredContent: { pinnedDesignId: null },
- };
+ connection.pinnedDesignId = null;
+ return toCallToolResult({
+ ok: true,
+ status: "ok",
+ summary: "Pin cleared; following the design focused in OpenPCB.",
+ warnings: [],
+ truncated: false,
+ data: { pinnedDesignId: null },
+ });
}
const match = (await listDesigns()).find((d) => d.id === requested);
if (!match) {
- return {
- content: [
- {
- type: "text" as const,
- text: `No design with id '${requested}'. Call designer_list_designs for valid ids.`,
- },
- ],
- structuredContent: { pinnedDesignId: session.pinnedDesignId },
- isError: true,
- };
+ return failureResult(
+ `No design with id '${requested}'. Call designer_list_designs for valid ids.`,
+ );
}
- session.pinnedDesignId = match.id;
- await deps.sessions.bindSessionToDesign(session, match.id);
- return {
- content: [
- { type: "text" as const, text: `Pinned to "${match.name}".` },
- ],
- structuredContent: { pinnedDesignId: match.id, name: match.name },
- };
+ connection.pinnedDesignId = match.id;
+ connection.lastDesignId = match.id;
+ return toCallToolResult({
+ ok: true,
+ status: "ok",
+ summary: `Pinned to "${match.name}".`,
+ warnings: [],
+ truncated: false,
+ data: { pinnedDesignId: match.id, name: match.name },
+ });
},
);
}
-
-/**
- * MCP requires `structuredContent` to be a JSON object. Tool results are
- * usually objects already, but wrap anything else so a scalar or array result
- * does not make the whole call invalid.
- */
-function asStructured(result: AiToolResult): Record {
- const payload = result.modelData ?? result.data;
- if (payload && typeof payload === "object" && !Array.isArray(payload)) {
- return payload as Record;
- }
- return { result: payload };
-}
diff --git a/src/modules/assistant/backend/mcp/verify-tool.ts b/src/modules/assistant/backend/mcp/verify-tool.ts
new file mode 100644
index 00000000..fc56ec3a
--- /dev/null
+++ b/src/modules/assistant/backend/mcp/verify-tool.ts
@@ -0,0 +1,110 @@
+import { fromJsonSchema, type McpServer } from "@modelcontextprotocol/server";
+import type { DesignerSDK } from "../../../../sdks";
+import type { ConversationStore } from "../conversation-store";
+import type { BuildIntentStore } from "../verification/build-intent-store";
+import { runDefinitionOfDone } from "../verification/run-dod";
+import type { McpConnection, McpConnectionRegistry } from "./connections";
+import { failureResult, toCallToolResult } from "./result-envelope";
+import { withHeartbeat, type McpRequestCtx } from "./tool-projection";
+
+/**
+ * `designer_verify_build` — the in-app Definition-of-Done check, for MCP.
+ *
+ * In-app, the run loop verifies every build itself (`run-dod.ts`: BOM placed,
+ * required nets wired, no dangling power, ERC clean) and runs correction
+ * passes. An external agent is its own loop, so it gets the verifier as a
+ * tool and the server instructions tell it to call it after a build. The
+ * expected BOM comes from this session's last `library_resolve_bom` or
+ * `compile_circuit` (captured by the projection); without one, the checks
+ * that need it pass and ERC still runs.
+ */
+
+/** Task key the MCP path stores build intents under (per design chat); see tool-projection.ts. */
+const MCP_INTENT_TASK_ID = "mcp";
+
+export interface VerifyToolDeps {
+ connections: McpConnectionRegistry;
+ conversation: ConversationStore;
+ buildIntents: BuildIntentStore;
+ designer: () => DesignerSDK | undefined;
+}
+
+export function registerVerifyTool(
+ server: McpServer,
+ connection: McpConnection,
+ deps: VerifyToolDeps,
+): void {
+ server.registerTool(
+ "designer_verify_build",
+ {
+ description:
+ "Verify a finished build the way OpenPCB's own assistant does: every part from the last library_resolve_bom / compile_circuit is placed, required power/ground nets are wired, no power pin is left dangling, and ERC reports no errors. Call it after building; fix what it reports, then call it again.",
+ inputSchema: fromJsonSchema<{ designId?: string }>({
+ type: "object",
+ properties: {
+ designId: {
+ type: "string",
+ description: "Design to verify. Omit to use the pinned or focused design.",
+ },
+ },
+ }),
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ async (input: { designId?: string } | undefined, ctx: unknown) => {
+ const designer = deps.designer();
+ if (!designer) return failureResult("Designer module is not available.");
+ const target = deps.connections.resolveDesign(connection, input?.designId ?? null);
+ if (!target) {
+ return failureResult(
+ "No design to verify. Pass designId, or open a design in OpenPCB.",
+ );
+ }
+ const chatId = await deps.connections.designChat(connection, target.designId);
+ if (!chatId) return failureResult(`Design '${target.designId}' not found.`);
+
+ // Move this session's pending intent (captured before the design
+ // existed, e.g. resolve → create → build) onto the design's chat.
+ if (connection.buildIntent) {
+ try {
+ deps.buildIntents.save({
+ chatId,
+ taskId: MCP_INTENT_TASK_ID,
+ ...connection.buildIntent,
+ });
+ connection.buildIntent = null;
+ } catch {
+ // Best-effort, like the in-app capture; verification still runs.
+ }
+ }
+
+ const report = await withHeartbeat(ctx as McpRequestCtx, "verifying the build", () =>
+ runDefinitionOfDone({
+ designer,
+ conversation: deps.conversation,
+ buildIntents: deps.buildIntents,
+ chatId,
+ taskId: MCP_INTENT_TASK_ID,
+ designId: target.designId,
+ }),
+ );
+ const failing = report.checks.filter((check) => !check.passed);
+ const summary =
+ failing.length === 0
+ ? "Build verified: all checks pass."
+ : `${failing.length} check(s) failing: ${failing.map((c) => `${c.id} — ${c.message}`).join("; ")}`;
+ return toCallToolResult({
+ ok: true,
+ status: failing.length === 0 ? "ok" : "partial",
+ summary,
+ warnings: target.warning ? [target.warning] : [],
+ truncated: false,
+ data: { designId: target.designId, ...report },
+ });
+ },
+ );
+}
diff --git a/src/modules/assistant/backend/migrations/0015_write_proposal_actor.sql b/src/modules/assistant/backend/migrations/0015_write_proposal_actor.sql
new file mode 100644
index 00000000..2b78e62b
--- /dev/null
+++ b/src/modules/assistant/backend/migrations/0015_write_proposal_actor.sql
@@ -0,0 +1,12 @@
+-- MCP actor provenance on write proposals. Chats are presentation containers
+-- and must not define write ownership: an MCP proposal records WHO proposed
+-- it (the client key and the per-session instance id from the MCP identity
+-- headers), and ownership checks — awaiting a proposal, undoing a change it
+-- landed — compare these columns. NULL for in-app and cloud proposals.
+ALTER TABLE assistant_write_proposal ADD COLUMN actor_client_key TEXT;
+--> statement-breakpoint
+ALTER TABLE assistant_write_proposal ADD COLUMN actor_instance_id TEXT;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS idx_assistant_write_proposal_actor
+ ON assistant_write_proposal(actor_client_key, actor_instance_id, design_id)
+ WHERE actor_instance_id IS NOT NULL;
diff --git a/src/modules/assistant/backend/migrations/0016_write_proposal_idempotency_scope.sql b/src/modules/assistant/backend/migrations/0016_write_proposal_idempotency_scope.sql
new file mode 100644
index 00000000..3e7a72a0
--- /dev/null
+++ b/src/modules/assistant/backend/migrations/0016_write_proposal_idempotency_scope.sql
@@ -0,0 +1,24 @@
+-- Idempotency is scoped to whoever issued the write, not to the whole design.
+--
+-- 0010 made (design_id, action_id) unique across every chat. The in-memory
+-- dedup only looked inside one chat, so when the two disagreed (the same
+-- deterministic action_id from another chat, another MCP session, or a retry
+-- after a rejection) createWriteProposal fell back to the existing row while
+-- its caller auto-applied the NEW envelope anyway and then failed to find it.
+--
+-- Now both the lookup and the index use one key: (design_id,
+-- idempotency_scope, action_id), where the scope is the MCP session
+-- ("mcp::") or, in-app, the chat ("chat:").
+-- A conflict on this index means "this exact action already exists" and the
+-- caller returns that proposal without applying anything.
+ALTER TABLE assistant_write_proposal ADD COLUMN idempotency_scope TEXT;
+--> statement-breakpoint
+UPDATE assistant_write_proposal
+ SET idempotency_scope = 'chat:' || chat_id
+ WHERE idempotency_scope IS NULL;
+--> statement-breakpoint
+DROP INDEX IF EXISTS idx_assistant_write_proposal_action;
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS idx_assistant_write_proposal_action_scope
+ ON assistant_write_proposal(design_id, idempotency_scope, action_id)
+ WHERE action_id IS NOT NULL;
diff --git a/src/modules/assistant/backend/proposals/proposal-apply-service.ts b/src/modules/assistant/backend/proposals/proposal-apply-service.ts
index 0b2d1cc1..b61891de 100644
--- a/src/modules/assistant/backend/proposals/proposal-apply-service.ts
+++ b/src/modules/assistant/backend/proposals/proposal-apply-service.ts
@@ -8,6 +8,7 @@ import {
applySchematicProposalOperations,
applyDesignerPlaceComponentsProposal,
isAssistantProposalApplyError,
+ ProposalStaleError,
type SchematicApplyResult,
type SchematicProposalEnvelope,
} from "../tools/designer-tools";
@@ -41,9 +42,21 @@ export async function applyAssistantWriteProposal(
) {
return applyDesignerSchematicEditsProposal(input);
}
- if (kind === "designer_pcb_place_batch" || kind === "designer_pcb_route_batch") {
+ if (
+ kind === "designer_pcb_place_batch" ||
+ kind === "designer_pcb_route_batch" ||
+ // MCP-only PCB tools: their operations are real DesignerCommands too.
+ kind === "designer_pcb_board_edits" ||
+ kind === "designer_pcb_rules_edits" ||
+ kind === "designer_pcb_deletions" ||
+ kind === "designer_pcb_drc_waivers" ||
+ kind === "designer_pcb_drc_rule_ignores"
+ ) {
return applyDesignerPcbBatchProposal(input);
}
+ if (kind === "designer_design_delete") {
+ return applyDesignDeleteProposal(input);
+ }
if (kind !== "designer_place_components") {
throw new Error(`Unsupported proposal kind: ${kind}`);
}
@@ -60,6 +73,44 @@ export function applyFailureResult(err: unknown): unknown | null {
return isAssistantProposalApplyError(err) ? err.applyResult : null;
}
+/**
+ * MCP `designer_delete_design`: deleting a whole design is not a
+ * DesignerCommand (it has no revision to apply against, and no undo), so the
+ * proposal carries a descriptive operation only and approval calls the SDK.
+ */
+async function applyDesignDeleteProposal(
+ input: ApplyAssistantWriteProposalInput,
+): Promise {
+ const designId = input.record.designId;
+ // Irreversible: refuse unless the design is exactly what the user saw when
+ // it was proposed. A newer edit (by the user or anyone) means the approval
+ // is for a design that no longer exists in that form — no apply-anyway.
+ const current = await input.designer.getDesign(designId);
+ if (
+ current &&
+ input.record.baseRevision !== null &&
+ current.head.revision !== input.record.baseRevision
+ ) {
+ throw new ProposalStaleError(input.record.baseRevision, current.head.revision);
+ }
+ const deleted = current ? await input.designer.deleteDesign(designId) : false;
+ const operationId = input.record.operations?.[0]?.id ?? `${input.record.id}:delete`;
+ return {
+ proposalId: input.record.id,
+ status: deleted ? "applied" : "failed",
+ designId,
+ appliedCount: deleted ? 1 : 0,
+ skippedCount: 0,
+ failedCount: deleted ? 0 : 1,
+ operations: [
+ deleted
+ ? { operationId, status: "applied" }
+ : { operationId, status: "failed", error: "Design not found" },
+ ],
+ message: deleted ? "Design deleted." : "The design no longer exists.",
+ };
+}
+
/** S8: cloud auto-layout batches (pcb_move/rotate/flip, pcb_add_trace/via). The
* generic op dispatcher already handles them — operation.payload is a real
* DesignerCommand, so dispatch runs the same command executor (incl. fab
diff --git a/src/modules/assistant/backend/routes.ts b/src/modules/assistant/backend/routes.ts
index c5694e39..f17768d0 100644
--- a/src/modules/assistant/backend/routes.ts
+++ b/src/modules/assistant/backend/routes.ts
@@ -12,6 +12,8 @@ import type {
} from "../../../sdks/assistant";
import { isFeatureEnabled } from "../../../core/contracts/feature-flags/backend";
import { getAssistantService } from "./assistant-service";
+import { checkMcpAuth } from "./mcp/auth";
+import { assistantEventStream } from "./events";
import { CopilotHttpError } from "./cloud/copilot-client";
function json(data: unknown, status = 200): Response {
@@ -471,8 +473,34 @@ export function registerRoutes(
router.post("/mcp", mcp);
router.get("/mcp", mcp);
router.delete("/mcp", mcp);
+
+ // Shim state probe (bearer-gated like the endpoint, but answered even
+ // while the server is switched off so the shim can say so).
+ router.get("/mcp-state", ({ req }) => {
+ const failure = checkMcpAuth(req);
+ if (failure) {
+ return json(
+ { error: failure.message },
+ failure.code === "unauthorized" ? 401 : 500,
+ );
+ }
+ return json(getAssistantService().mcpState());
+ });
+
+ // Connected clients for the Settings panel (loopback UI, like the rest of
+ // this module's routes).
+ router.get("/mcp/clients", () =>
+ json({ clients: getAssistantService().listMcpClients() }),
+ );
}
+ // Live change notifications (chat activity, proposal status) as SSE, so the
+ // panel refreshes for changes made outside its own runs — MCP clients above
+ // all. Ids only; the panel refetches through the routes above.
+ router.get("/events", ({ req }) =>
+ assistantEventStream(getAssistantService().events, req.signal),
+ );
+
// Settings
router.get("/settings", () => json(getAssistantService().getSettings()));
router.put("/settings", async (ctx) =>
diff --git a/src/modules/assistant/backend/run-service.ts b/src/modules/assistant/backend/run-service.ts
index f191e14c..dd41b75a 100644
--- a/src/modules/assistant/backend/run-service.ts
+++ b/src/modules/assistant/backend/run-service.ts
@@ -41,6 +41,10 @@ import {
type CloudWorkspaceContext,
} from "./cloud/cloud-context";
import { isFeatureEnabled } from "../../../core/contracts/feature-flags/backend";
+import {
+ intentFromBomResult,
+ intentFromCompileResult,
+} from "./verification/build-intent-capture";
import { BuildIntentStore } from "./verification/build-intent-store";
import { runDefinitionOfDone } from "./verification/run-dod";
import { buildDesignContextSummary } from "./context-summary";
@@ -198,79 +202,6 @@ export function looksLikeEmulatedToolCall(content: string): boolean {
return false;
}
-/** Minimal shape of the library_resolve_bom result we read for BuildIntent. */
-interface BomResultShape {
- goal?: unknown;
- items?: Array<{
- role?: unknown;
- quantity?: number;
- value?: unknown;
- selected?: { componentId: string } | null;
- }>;
-}
-
-/** Minimal shape of the compile_circuit result we read for BuildIntent. */
-interface CompileResultShape {
- placedCount?: number;
- bom?: Array<{
- role?: unknown;
- componentId?: string;
- quantity?: number;
- value?: unknown;
- }>;
-}
-
-/**
- * Canonical power-rail net name for a single voltage token. Keeps distinct rails
- * distinct: +5V → "+5V", 3V3/3.3V → "+3V3", 12V → "+12V". Returns null for tokens
- * that are not a recognisable rail. F7a: do NOT collapse every rail to "VCC" —
- * a multi-rail build (e.g. +5V and +3V3) must keep them separate so the DoD
- * `nets_wired` check is meaningful.
- */
-function railNetName(token: string): string | null {
- // Accept 5V, +5V, 3.3V, 3V3, 1V8, 12V. `whole` digits, optional fractional
- // digits separated by "." or "v" (either before or after the trailing V).
- const m = /^[+]?(\d+)(?:\.(\d+)v|v(\d+)|v)$/i.exec(token.replace(/\s+/g, ""));
- if (!m) return null;
- const whole = m[1]!;
- const frac = m[2] ?? m[3];
- return frac ? `+${whole}V${frac}` : `+${whole}V`;
-}
-
-/**
- * Deterministically derive the nets a BOM item is expected to participate in
- * from its role keyword plus any explicit voltage in its value/role text. Used by
- * the DoD `nets_wired` check. Conservative: only power/ground rails are inferred,
- * since those are the connections a build is most likely to leave dangling.
- *
- * F7a: explicit rails keep their REAL names (+5V, +3V3, +12V); only a bare,
- * voltage-less power role falls back to the generic "VCC".
- */
-function requiredNetsForItem(
- role: string,
- value: string | undefined,
-): string[] {
- const r = role.toLowerCase();
- const nets = new Set();
- if (/(gnd|ground|return)/.test(r)) nets.add("GND");
- const isPower = /(vcc|vdd|\+?\d+v|3\.3v|power|supply|rail)/.test(r);
- if (isPower) {
- // Pull explicit rail tokens out of the role text and the item value.
- const haystack = `${role} ${value ?? ""}`;
- const tokens = haystack.match(/[+]?\d+(?:\.\d+v|v\d+|v)\b/gi) ?? [];
- let added = false;
- for (const token of tokens) {
- const rail = railNetName(token);
- if (rail) {
- nets.add(rail);
- added = true;
- }
- }
- if (!added) nets.add("VCC");
- }
- return [...nets];
-}
-
/** Max correction passes after the main run; each pass re-primes + re-runs. */
const MAX_DOD_CORRECTION_PASSES = 3;
@@ -304,36 +235,10 @@ export class RunService {
taskId: string,
resultJson: string,
): void {
- let parsed: BomResultShape;
- try {
- parsed = JSON.parse(resultJson) as BomResultShape;
- } catch {
- return;
- }
- const items = Array.isArray(parsed.items) ? parsed.items : [];
- const intentItems = items
- .filter((item) => item.selected?.componentId)
- .map((item) => ({
- role: typeof item.role === "string" ? item.role : "part",
- componentId: item.selected!.componentId,
- quantity:
- Number.isFinite(item.quantity) && (item.quantity ?? 0) > 0
- ? Math.floor(item.quantity!)
- : 1,
- value: typeof item.value === "string" ? item.value : undefined,
- requiredNets: requiredNetsForItem(
- typeof item.role === "string" ? item.role : "",
- typeof item.value === "string" ? item.value : undefined,
- ),
- }));
- if (intentItems.length === 0) return;
+ const intent = intentFromBomResult(resultJson);
+ if (!intent) return;
try {
- this.buildIntents.save({
- chatId,
- taskId,
- goal: typeof parsed.goal === "string" ? parsed.goal : "",
- items: intentItems,
- });
+ this.buildIntents.save({ chatId, taskId, ...intent });
} catch {
// Persisting intent is best-effort; never fail the run over it.
}
@@ -342,41 +247,17 @@ export class RunService {
/**
* Parse a compile_circuit result and persist its BOM as a BuildIntent so the
* Definition-of-Done harness verifies the composed circuit (ERC/wiring) and can
- * drive correction passes. Only a compile that actually placed parts is worth
- * verifying — unresolved/failed compiles placed nothing and already told the
- * model to fix the IR.
+ * drive correction passes.
*/
private captureCompiledBuildIntent(
chatId: string,
taskId: string,
resultJson: string,
): void {
- let parsed: CompileResultShape;
- try {
- parsed = JSON.parse(resultJson) as CompileResultShape;
- } catch {
- return;
- }
- if (!parsed || (parsed.placedCount ?? 0) <= 0) return;
- const bom = Array.isArray(parsed.bom) ? parsed.bom : [];
- const intentItems = bom
- .filter((item) => typeof item.componentId === "string" && item.componentId)
- .map((item) => ({
- role: typeof item.role === "string" ? item.role : "part",
- componentId: item.componentId!,
- quantity:
- Number.isFinite(item.quantity) && (item.quantity ?? 0) > 0
- ? Math.floor(item.quantity!)
- : 1,
- value: typeof item.value === "string" ? item.value : undefined,
- requiredNets: requiredNetsForItem(
- typeof item.role === "string" ? item.role : "",
- typeof item.value === "string" ? item.value : undefined,
- ),
- }));
- if (intentItems.length === 0) return;
+ const intent = intentFromCompileResult(resultJson);
+ if (!intent) return;
try {
- this.buildIntents.save({ chatId, taskId, goal: "", items: intentItems });
+ this.buildIntents.save({ chatId, taskId, ...intent });
} catch {
// Persisting intent is best-effort; never fail the run over it.
}
diff --git a/src/modules/assistant/backend/tools/designer-tools.ts b/src/modules/assistant/backend/tools/designer-tools.ts
index a43894b8..7159200b 100644
--- a/src/modules/assistant/backend/tools/designer-tools.ts
+++ b/src/modules/assistant/backend/tools/designer-tools.ts
@@ -9,6 +9,7 @@ import type { CoreBackendModuleContext } from "../../../../core/contracts/module
import {
MODULE_SDK_TOKENS,
type AssistantPlacementProposal,
+ type AssistantWriteProposalActor,
type AssistantWriteProposalDto,
type DesignerDesignSummary,
type DesignerSDK,
@@ -17,7 +18,10 @@ import {
type DesignerPcbProjection,
} from "../../../../sdks";
import type { ContextResolver } from "../context-resolver";
-import type { ConversationStore } from "../conversation-store";
+import {
+ writeProposalIdempotencyScope,
+ type ConversationStore,
+} from "../conversation-store";
import { ACTION_ID_DESC, isValidActionId } from "./action-id";
import {
buildProjectionIndex,
@@ -29,7 +33,21 @@ import {
type WireEndpoint,
} from "./schematic-targeting";
-const AI_DESIGNER_SESSION_ID = "designer-ui-session";
+/**
+ * The MCP session behind a tool call (set by the MCP projection in
+ * `execCtx.metadata.mcp`), recorded on every proposal it creates so ownership
+ * never depends on which chat the proposal happens to live in. Null in-app.
+ */
+export function mcpActorOf(
+ execCtx: { metadata?: unknown } | undefined,
+): AssistantWriteProposalActor | null {
+ const mcp = (execCtx?.metadata as { mcp?: { clientKey?: unknown; instanceId?: unknown } } | undefined)
+ ?.mcp;
+ if (typeof mcp?.clientKey !== "string" || typeof mcp.instanceId !== "string") return null;
+ return { type: "mcp", clientKey: mcp.clientKey, instanceId: mcp.instanceId };
+}
+
+export const AI_DESIGNER_SESSION_ID = "designer-ui-session";
const SCHEMATIC_GRID_NM = 2_000_000;
const DEFAULT_GRID_SPACING_X_NM = 24_000_000;
const DEFAULT_GRID_SPACING_Y_NM = 16_000_000;
@@ -78,7 +96,14 @@ export interface SchematicProposalEnvelope {
| "designer_schematic_updates"
| "designer_schematic_deletions"
| "designer_pcb_place_batch"
- | "designer_pcb_route_batch";
+ | "designer_pcb_route_batch"
+ // MCP-only PCB / design tools (tools/mcp-pcb-tools.ts, mcp-design-tools.ts)
+ | "designer_pcb_board_edits"
+ | "designer_pcb_rules_edits"
+ | "designer_pcb_deletions"
+ | "designer_pcb_drc_waivers"
+ | "designer_pcb_drc_rule_ignores"
+ | "designer_design_delete";
toolName:
| "designer_propose_schematic_edits"
| "designer_propose_schematic_wires"
@@ -88,7 +113,22 @@ export interface SchematicProposalEnvelope {
// S8: cloud-copilot layout batches mirrored via the cloud proposal path
| "cloud_copilot"
| "copilot_run_placement"
- | "copilot_run_routing";
+ | "copilot_run_routing"
+ // MCP-only tools
+ | "pcb_place_footprints"
+ | "pcb_route"
+ | "pcb_delete_routing"
+ | "pcb_set_board_outline"
+ | "pcb_set_design_rules"
+ | "pcb_add_zone"
+ | "pcb_update_zone"
+ | "pcb_delete_zone"
+ | "pcb_add_keepout"
+ | "pcb_update_keepout"
+ | "pcb_delete_keepout"
+ | "pcb_waive_drc_violations"
+ | "pcb_set_drc_rule_class_ignores"
+ | "designer_delete_design";
/** Idempotency key from the model (Track D); dedup re-runs by design + key. */
actionId?: string;
title: string;
@@ -139,6 +179,12 @@ export interface SchematicApplyResult {
result?: unknown;
}>;
message: string;
+ /**
+ * Every designer commandId this apply dispatched (primaries and follow-ups),
+ * in order. Lets a caller recognise its own entries on the shared undo stack
+ * (MCP `designer_undo` only undoes what its own proposals landed).
+ */
+ commandIds?: string[];
}
// ─── idempotency + slim model-facing result helpers (Track D) ──────────
@@ -151,62 +197,59 @@ interface WriteToolModelData {
}
/**
- * Look for a prior write proposal in this chat that carries the same
- * `actionId` for the same design — regardless of status. Used to make write
- * tools idempotent and to block duplicate in-flight dispatches: re-issuing the
- * same action_id must never duplicate placements/wires.
- *
- * Returns the most recent matching record (proposals are listed created-at
- * ASC) so the dedup decision reflects the latest known state of that action.
+ * The prior write proposal holding the same idempotency key — design +
+ * scope (the MCP session, or the chat in-app) + `actionId` — regardless of
+ * status. The same key the database enforces as UNIQUE, so the lookup and
+ * the race backstop can never disagree.
*/
function findPriorByActionId(
conversation: ConversationStore,
chatId: string,
designId: string,
actionId: string,
+ actor: AssistantWriteProposalActor | null,
): AssistantWriteProposalDto | null {
- let match: AssistantWriteProposalDto | null = null;
- for (const record of conversation.listWriteProposals(chatId)) {
- if (record.designId !== designId) continue;
- const envelope = (record as { envelope?: unknown }).envelope as
- | { actionId?: unknown }
- | null
- | undefined;
- if (envelope && envelope.actionId === actionId) match = record;
- }
- return match;
+ return conversation.getWriteProposalByActionKey(
+ designId,
+ writeProposalIdempotencyScope(chatId, actor),
+ actionId,
+ );
}
/**
* Idempotency / duplicate guard for write tools. Returns a terminal tool
- * result when a prior proposal for the same (designId + actionId) means we must
- * NOT dispatch again, or null when this action is fresh and may proceed:
- * - applied / partial → already landed; replay its persisted apply result.
- * - pending → an earlier identical action is still in-flight (auto-
- * apply gated or concurrent); block to avoid a duplicate
- * write.
- * - failed → a prior identical action errored; a blind re-dispatch
- * could double-write whatever partially landed. Block
- * and report so correction goes through a fresh action.
- * - rejected → user declined; allow a fresh attempt (returns null).
+ * result when a prior proposal with the same key means we must NOT dispatch
+ * again, or null when this action is fresh and may proceed:
+ * - applied / partial → already landed; replay its persisted result.
+ * - pending → still waiting (approval or in flight); block.
+ * - failed / rejected → the action was tried and did not land, or the user
+ * declined it. Re-sending the same id must not act as
+ * a silent retry: block, and require a NEW action_id
+ * for a deliberate new attempt.
*/
-function dedupByActionId(
+export function dedupByActionId(
conversation: ConversationStore,
chatId: string,
designId: string,
actionId: string,
limits: AiToolResult["limits"],
+ actor: AssistantWriteProposalActor | null = null,
): AiToolResult | null {
- const prior = findPriorByActionId(conversation, chatId, designId, actionId);
+ const prior = findPriorByActionId(conversation, chatId, designId, actionId, actor);
if (!prior) return null;
+ return priorActionResult(prior, actionId, limits);
+}
+
+/** The terminal result for an action that already exists (see dedupByActionId). */
+function priorActionResult(
+ prior: AssistantWriteProposalDto,
+ actionId: string,
+ limits: AiToolResult["limits"],
+): AiToolResult {
if (prior.status === "applied" || prior.status === "partial") {
return alreadyAppliedResult(prior, actionId, limits);
}
- if (prior.status === "pending" || prior.status === "failed") {
- return duplicateInFlightResult(prior, actionId, limits);
- }
- // rejected (or any future non-blocking status): allow a fresh attempt.
- return null;
+ return duplicateInFlightResult(prior, actionId, limits);
}
/**
@@ -270,7 +313,9 @@ function duplicateInFlightResult(
const reason =
record.status === "failed"
? `action_id "${actionId}" already attempted and failed; do not re-issue it. Inspect the design state and use a new action_id only for the parts that still need fixing.`
- : `action_id "${actionId}" is already in-flight (pending). Wait for it to settle instead of re-issuing the same write.`;
+ : record.status === "rejected"
+ ? `action_id "${actionId}" was rejected by the user. Do not re-send it; if the user now wants a changed version, propose it with a new action_id.`
+ : `action_id "${actionId}" is already in-flight (pending). Wait for it to settle instead of re-issuing the same write.`;
const modelData: WriteToolModelData = {
appliedCount: 0,
skipped: [{ id: actionId, reason }],
@@ -1023,15 +1068,19 @@ export function makeDesignerCreateDesignTool(
const name = normalizeDesignName(input.name);
const created = await designer.createDesign({ name });
- await contextResolver.bindDesign(chatId, {
+ // createDesign was awaited: bind only if the chat is still unbound.
+ const binding = contextResolver.bindDesignIfUnbound(chatId, {
id: created.id,
name: created.name,
});
+ const bound = binding.binding.refId === created.id;
const output: DesignerCreateDesignOutput = {
design: summarizeCreatedDesign(created),
- bound: true,
- message: `Created and bound new design "${created.name}".`,
+ bound,
+ message: bound
+ ? `Created and bound new design "${created.name}".`
+ : `Created design "${created.name}", but this chat was bound to "${binding.binding.label}" meanwhile; start a new chat to work on it.`,
};
return {
ok: true,
@@ -1243,6 +1292,7 @@ export function makeDesignerPlaceComponentsTool(
designId,
actionId,
execCtx.limits,
+ mcpActorOf(execCtx),
);
if (dedup) return dedup;
}
@@ -1357,15 +1407,24 @@ export function makeDesignerPlaceComponentsTool(
warnings: proposalWarnings,
actionId,
});
- conversation.createWriteProposal({
+ const stored = conversation.createWriteProposal({
id: proposalId,
chatId,
+ actor: mcpActorOf(execCtx),
kind: "designer_place_components",
designId,
baseRevision: designRecord.head.revision,
proposal,
envelope,
});
+ if (actionId && stored && stored.id !== proposalId) {
+ // Same idempotency key already holds an action: never place twice.
+ return priorActionResult(
+ stored,
+ actionId,
+ execCtx.limits,
+ );
+ }
// Build-time skips (unresolved components, truncation). These never reach
// the apply path, so they are reported as skipped regardless of apply.
const buildSkipped: WriteToolModelData["skipped"] = skipped.map(
@@ -1434,7 +1493,10 @@ export function makeDesignerPlaceComponentsTool(
chatId,
proposalId,
writeProposalTerminalStatus(applyResult),
- applyResult ?? { message },
+ applyResult ??
+ (isProposalStaleError(err)
+ ? err.toApplyResult(proposalId, designId)
+ : { message }),
);
const appliedCount = applyResult?.applied?.length ?? 0;
modelData = {
@@ -1915,6 +1977,7 @@ export function makeDesignerProposeSchematicEditsTool(
designId,
actionId,
execCtx.limits,
+ mcpActorOf(execCtx),
);
if (dedup) return dedup;
}
@@ -2241,6 +2304,7 @@ export function makeDesignerProposeSchematicEditsTool(
return finalizeAndMaybeApply({
designer,
conversation,
+ actor: mcpActorOf(execCtx),
chatId,
designId,
baseRevision: designRecord.head.revision,
@@ -2309,6 +2373,7 @@ export function makeDesignerArrangeSchematicTool(
designId,
actionId,
execCtx.limits,
+ mcpActorOf(execCtx),
);
if (dedup) return dedup;
}
@@ -2353,6 +2418,7 @@ export function makeDesignerArrangeSchematicTool(
return finalizeAndMaybeApply({
designer,
conversation,
+ actor: mcpActorOf(execCtx),
chatId,
designId,
baseRevision: designRecord.head.revision,
@@ -2490,6 +2556,7 @@ export function makeDesignerProposeSchematicWiresTool(
designId,
actionId,
execCtx.limits,
+ mcpActorOf(execCtx),
);
if (dedup) return dedup;
}
@@ -2683,6 +2750,7 @@ export function makeDesignerProposeSchematicWiresTool(
return finalizeAndMaybeApply({
designer,
conversation,
+ actor: mcpActorOf(execCtx),
chatId,
designId,
baseRevision: designRecord.head.revision,
@@ -2868,6 +2936,7 @@ export function makeDesignerProposeSchematicUpdatesTool(
designId,
actionId,
execCtx.limits,
+ mcpActorOf(execCtx),
);
if (dedup) return dedup;
}
@@ -3119,6 +3188,7 @@ export function makeDesignerProposeSchematicUpdatesTool(
return finalizeAndMaybeApply({
designer,
conversation,
+ actor: mcpActorOf(execCtx),
chatId,
designId,
baseRevision: designRecord.head.revision,
@@ -3242,6 +3312,7 @@ export function makeDesignerProposeSchematicDeletionsTool(
designId,
actionId,
execCtx.limits,
+ mcpActorOf(execCtx),
);
if (dedup) return dedup;
}
@@ -3322,6 +3393,7 @@ export function makeDesignerProposeSchematicDeletionsTool(
return finalizeAndMaybeApply({
designer,
conversation,
+ actor: mcpActorOf(execCtx),
chatId,
designId,
baseRevision: designRecord.head.revision,
@@ -3388,7 +3460,7 @@ const PIN_TARGET_SCHEMA = {
* session policy opts them in. Centralizes the create+apply boilerplate shared
* by all four schematic-write tools.
*/
-async function finalizeAndMaybeApply(params: {
+export async function finalizeAndMaybeApply(params: {
designer: DesignerSDK;
conversation: ConversationStore;
chatId: string;
@@ -3399,6 +3471,8 @@ async function finalizeAndMaybeApply(params: {
sources: AiSourceRef[];
limits: AiToolResult["limits"];
options: DesignerToolOptions;
+ /** The MCP session proposing (from `mcpActorOf(execCtx)`); null in-app. */
+ actor?: AssistantWriteProposalActor | null;
}): Promise> {
const {
designer,
@@ -3411,16 +3485,26 @@ async function finalizeAndMaybeApply(params: {
sources,
limits,
options,
+ actor,
} = params;
- conversation.createWriteProposal({
+ const stored = conversation.createWriteProposal({
id: envelope.id,
chatId,
+ actor: actor ?? null,
kind: envelope.kind,
designId,
baseRevision,
proposal: envelope,
envelope,
});
+ const envelopeActionId = (envelope as { actionId?: string }).actionId;
+ if (envelopeActionId && stored && stored.id !== envelope.id) {
+ // The store returned an EXISTING proposal: the idempotency key (design +
+ // scope + action_id) already holds a concurrent or earlier identical
+ // action. Report that one and apply nothing. (Only an action_id can
+ // collide here, hence the guard.)
+ return priorActionResult(stored, envelopeActionId, limits);
+ }
// Auto-apply is decided by the session policy callback. Production wiring
// (assistant-service) allows non-destructive schematic proposals by default
// and gates destructive ones behind an explicit allowance.
@@ -3482,7 +3566,7 @@ async function finalizeAndMaybeApply(params: {
op.status === "failed" ||
(op.status === "applied" && op.error != null),
)
- .map((op) => ({ id: op.operationId, reason: op.error ?? "failed" }));
+ .map((op) => ({ id: op.operationId, reason: operationFailureReason(op) }));
const skipped = [...buildSkipped, ...failedSkips];
// A failed/partial apply must surface ok:false/partial — never ok:true.
if (applyResult.status === "applied") {
@@ -3511,16 +3595,23 @@ async function finalizeAndMaybeApply(params: {
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
- conversation.updateWriteProposalStatus(chatId, envelope.id, "failed", {
- proposalId: envelope.id,
- status: "failed",
- designId,
- appliedCount: 0,
- skippedCount: 0,
- failedCount: envelope.operations.length,
- operations: [],
- message,
- });
+ conversation.updateWriteProposalStatus(
+ chatId,
+ envelope.id,
+ "failed",
+ isProposalStaleError(err)
+ ? err.toApplyResult(envelope.id, designId)
+ : {
+ proposalId: envelope.id,
+ status: "failed",
+ designId,
+ appliedCount: 0,
+ skippedCount: 0,
+ failedCount: envelope.operations.length,
+ operations: [],
+ message,
+ },
+ );
finalWarnings.push(`Auto-apply failed: ${message}`);
toolOk = false;
toolStatus = "partial";
@@ -3558,9 +3649,7 @@ export async function applySchematicProposalOperations(input: {
input.baseRevision !== null &&
design.head.revision !== input.baseRevision
) {
- throw new Error(
- `Design changed since proposal was created (expected revision ${input.baseRevision}, current ${design.head.revision}). Regenerate the proposal.`,
- );
+ throw new ProposalStaleError(input.baseRevision, design.head.revision);
}
if (
(input.envelope.warnings?.length ?? 0) > 0 &&
@@ -3577,11 +3666,14 @@ export async function applySchematicProposalOperations(input: {
const stopOnError = input.envelope.riskLevel === "destructive";
let stoppedAtOperationId: string | undefined;
- const dispatch = (command: DesignerCommandEnvelope["command"]) =>
- input.designer.dispatchCommand(
+ const commandIds: string[] = [];
+ const dispatch = (command: DesignerCommandEnvelope["command"]) => {
+ const commandId = crypto.randomUUID();
+ commandIds.push(commandId);
+ return input.designer.dispatchCommand(
input.designId,
{
- commandId: crypto.randomUUID(),
+ commandId,
sessionId: AI_DESIGNER_SESSION_ID,
aggregateId: input.designId,
baseRevision,
@@ -3590,6 +3682,7 @@ export async function applySchematicProposalOperations(input: {
},
{ actor: "assistant" },
);
+ };
for (const operation of input.envelope.operations) {
const revisionBefore = baseRevision;
@@ -3730,6 +3823,7 @@ export async function applySchematicProposalOperations(input: {
failedCount,
...(stoppedAtOperationId ? { stoppedAtOperationId } : {}),
operations,
+ commandIds,
message:
status === "applied"
? `Applied ${appliedCount} schematic operation(s).`
@@ -3755,6 +3849,8 @@ export async function applyDesignerPlaceComponentsProposal(input: {
}>;
skipped: AssistantPlacementProposal["skipped"];
results: Awaited>[];
+ /** Every commandId dispatched, in order (see SchematicApplyResult.commandIds). */
+ commandIds: string[];
}> {
if (input.proposal.skipped.length > 0 && !input.allowPartial) {
throw new Error(
@@ -3767,9 +3863,7 @@ export async function applyDesignerPlaceComponentsProposal(input: {
input.baseRevision !== null &&
design.head.revision !== input.baseRevision
) {
- throw new Error(
- `Design changed since proposal was created (expected revision ${input.baseRevision}, current ${design.head.revision}). Regenerate the proposal.`,
- );
+ throw new ProposalStaleError(input.baseRevision, design.head.revision);
}
let baseRevision: number | null = input.baseRevision;
@@ -3780,6 +3874,7 @@ export async function applyDesignerPlaceComponentsProposal(input: {
revision: number;
}> = [];
const results: Awaited>[] = [];
+ const commandIds: string[] = [];
const failWithPartial = (message: string): never => {
throw new AssistantProposalApplyError(message, {
proposalId: input.proposal.proposalId,
@@ -3788,6 +3883,7 @@ export async function applyDesignerPlaceComponentsProposal(input: {
applied,
skipped: input.proposal.skipped,
results,
+ commandIds,
message,
});
};
@@ -3806,6 +3902,7 @@ export async function applyDesignerPlaceComponentsProposal(input: {
mirrored: placement.mirrored,
},
};
+ commandIds.push(envelope.commandId);
const result = await input.designer.dispatchCommand(
input.designId,
envelope,
@@ -3852,6 +3949,7 @@ export async function applyDesignerPlaceComponentsProposal(input: {
...(propertiesJson ? { propertiesJson } : {}),
},
};
+ commandIds.push(updateEnvelope.commandId);
const updateResult = await input.designer.dispatchCommand(
input.designId,
updateEnvelope,
@@ -3882,12 +3980,65 @@ export async function applyDesignerPlaceComponentsProposal(input: {
proposalId: input.proposal.proposalId,
status: "applied",
designId: input.designId,
+ commandIds,
applied,
skipped: input.proposal.skipped,
results,
};
}
+/**
+ * A proposal was approved after the design moved on: its `baseRevision` is no
+ * longer the head. Nothing was applied, and there is deliberately no
+ * "apply anyway" — the user or agent must propose again against the current
+ * design. Persisted as the proposal's failed apply result (`toApplyResult`),
+ * so the panel card and `assistant_await_proposal` can say why.
+ */
+/**
+ * Why an operation failed, for the agent: the dispatch code plus the
+ * executor's human detail when it gave one ("PCB_COPPER_ILLEGAL: trace on
+ * In1.Cu is not on the board stackup") — a bare code gives a model nothing
+ * to correct.
+ */
+function operationFailureReason(op: { error?: string | null; result?: unknown }): string {
+ const detail = (op.result as { detail?: unknown } | undefined)?.detail;
+ if (!op.error) return "failed";
+ return typeof detail === "string" && detail.trim() ? `${op.error}: ${detail.trim()}` : op.error;
+}
+
+export class ProposalStaleError extends Error {
+ readonly code = "STALE_PROPOSAL" as const;
+ constructor(
+ readonly expectedRevision: number,
+ readonly currentRevision: number,
+ ) {
+ super(
+ `Design changed since proposal was created (expected revision ${expectedRevision}, current ${currentRevision}). Regenerate the proposal.`,
+ );
+ this.name = "ProposalStaleError";
+ }
+
+ toApplyResult(proposalId: string, designId: string): Record {
+ return {
+ proposalId,
+ status: "failed",
+ designId,
+ code: this.code,
+ expectedRevision: this.expectedRevision,
+ currentRevision: this.currentRevision,
+ appliedCount: 0,
+ skippedCount: 0,
+ failedCount: 0,
+ operations: [],
+ message: this.message,
+ };
+ }
+}
+
+export function isProposalStaleError(err: unknown): err is ProposalStaleError {
+ return err instanceof ProposalStaleError;
+}
+
export class AssistantProposalApplyError extends Error {
constructor(
message: string,
diff --git a/src/modules/assistant/backend/tools/drc-counts.ts b/src/modules/assistant/backend/tools/drc-counts.ts
new file mode 100644
index 00000000..cb178c48
--- /dev/null
+++ b/src/modules/assistant/backend/tools/drc-counts.ts
@@ -0,0 +1,57 @@
+import type { DrcReport } from "../../../../sdks";
+
+/**
+ * DRC numbers an agent may quote. A report's `violations.length` counts
+ * waived violations too, and says nothing about violations an ignored rule
+ * class or an "ignore" severity override hid. An agent that reports that one
+ * number can call a board with suppressed problems "clean" — so MCP tools
+ * report the split, and the summary line spells the suppression out.
+ */
+export interface DrcCounts {
+ /** Violations that still count: listed and not waived. */
+ active: number;
+ errors: number;
+ warnings: number;
+ infos: number;
+ /** Listed with `waived: true` — acknowledged by the user, still present. */
+ waived: number;
+ /** Hidden entirely by `drcIgnoredRuleClasses`. */
+ ignoredByRuleClass: number;
+ /** Hidden entirely by a per-code "ignore" severity override. */
+ ignoredBySeverity: number;
+ /** Everything the checks found, before any suppression. */
+ raw: number;
+}
+
+export function drcCounts(report: DrcReport): DrcCounts {
+ const waived = report.violations.filter((v) => v.waived).length;
+ const ignoredByRuleClass = report.suppressed?.byRuleClass ?? 0;
+ const ignoredBySeverity = report.suppressed?.bySeverityOverride ?? 0;
+ const active = report.violations.length - waived;
+ return {
+ active,
+ errors: report.summary.errors,
+ warnings: report.summary.warnings,
+ infos: report.summary.infos,
+ waived,
+ ignoredByRuleClass,
+ ignoredBySeverity,
+ raw: active + waived + ignoredByRuleClass + ignoredBySeverity,
+ };
+}
+
+/** One line for tool summaries; never "clean" while anything is suppressed. */
+export function drcCountsLine(counts: DrcCounts): string {
+ const head = `DRC: ${counts.active} active violation(s) (${counts.errors} error(s), ${counts.warnings} warning(s))`;
+ const hidden = counts.ignoredByRuleClass + counts.ignoredBySeverity;
+ if (counts.waived === 0 && hidden === 0) return `${head}.`;
+ const parts: string[] = [];
+ if (counts.waived > 0) parts.push(`${counts.waived} waived`);
+ if (counts.ignoredByRuleClass > 0) {
+ parts.push(`${counts.ignoredByRuleClass} hidden by ignored rule classes`);
+ }
+ if (counts.ignoredBySeverity > 0) {
+ parts.push(`${counts.ignoredBySeverity} hidden by "ignore" severity overrides`);
+ }
+ return `${head}; plus ${parts.join(", ")} — ${counts.raw} found before suppression. Do not describe the board as clean while any are suppressed.`;
+}
diff --git a/src/modules/assistant/backend/tools/knowledge-tools.ts b/src/modules/assistant/backend/tools/knowledge-tools.ts
new file mode 100644
index 00000000..7d593460
--- /dev/null
+++ b/src/modules/assistant/backend/tools/knowledge-tools.ts
@@ -0,0 +1,136 @@
+import type { AiTool, AiToolRegistry, AiToolResult } from "@openpcb/ai-core";
+import { MentionRegistry } from "../../../../core/backend/mentions";
+import { MentionContentResolver } from "../mention-content-resolver";
+
+/**
+ * Knowledge pages ("Docs") for MCP clients.
+ *
+ * In-app, the user @mentions a page and the run loop resolves it into the
+ * prompt (`mention-content-resolver.ts`). An MCP client has no composer, so it
+ * gets the same data as tools: search by title, then read a page as markdown.
+ * Both go through the same core `MentionRegistry` path the in-app mentions use
+ * — the knowledge module exposes no SDK, and this keeps one read path.
+ *
+ * MCP-only (registered next to the extended reads), for the same reason: the
+ * in-app registry's prompt and DoD harness are tuned against its current set.
+ */
+
+const KNOWLEDGE_PAGE = "knowledge-page";
+const WORKSPACE = "default";
+/** Same id shape the mention parser accepts. */
+const PAGE_ID = /^[a-zA-Z0-9-]+$/;
+/** Per-page cap for an external agent: generous, but not a whole manual. */
+const MAX_PAGE_CHARS = 60_000;
+
+function failed(message: string, limits: AiToolResult["limits"]): AiToolResult {
+ return {
+ ok: false,
+ data: null,
+ summary: message,
+ sources: [],
+ warnings: [message],
+ truncated: false,
+ limits,
+ };
+}
+
+function makeSearchTool(): AiTool {
+ return {
+ definition: {
+ name: "knowledge_search_pages",
+ version: "1",
+ effect: "read",
+ capability: "knowledge.read",
+ description:
+ "Search the user's OpenPCB Docs (knowledge pages: notes, specs, imported PDFs) by title. Returns page ids and titles; read one with knowledge_get_page.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ query: { type: "string", description: "Title text to match." },
+ limit: { type: "integer", minimum: 1, maximum: 50 },
+ },
+ required: ["query"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const { query, limit } = (input ?? {}) as { query?: string; limit?: number };
+ if (!MentionRegistry.get().getProvider(KNOWLEDGE_PAGE)) {
+ return failed("The Docs (knowledge) module is not available.", execCtx.limits);
+ }
+ const hits = await MentionRegistry.get().search(
+ { query: query ?? "", workspaceId: WORKSPACE, limit: limit ?? 20 },
+ [KNOWLEDGE_PAGE],
+ );
+ const pages = hits.map((hit) => ({
+ id: hit.id,
+ title: hit.displayText,
+ description: hit.description ?? null,
+ }));
+ return {
+ ok: true,
+ data: { pages },
+ summary:
+ pages.length === 0
+ ? `No Docs page matches "${query ?? ""}".`
+ : `${pages.length} Docs page(s) match "${query ?? ""}".`,
+ sources: [],
+ warnings: [],
+ truncated: false,
+ limits: execCtx.limits,
+ };
+ },
+ };
+}
+
+function makeGetPageTool(): AiTool {
+ return {
+ definition: {
+ name: "knowledge_get_page",
+ version: "1",
+ effect: "read",
+ capability: "knowledge.read",
+ description:
+ "Read one OpenPCB Docs page as markdown (the same content the in-app assistant gets when the user @mentions it). Images are omitted; PDF pages return a stub.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ pageId: { type: "string", description: "Page id from knowledge_search_pages." },
+ },
+ required: ["pageId"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const { pageId } = (input ?? {}) as { pageId?: string };
+ if (!pageId || !PAGE_ID.test(pageId)) {
+ return failed("pageId must be an id from knowledge_search_pages.", execCtx.limits);
+ }
+ if (!MentionRegistry.get().getProvider(KNOWLEDGE_PAGE)) {
+ return failed("The Docs (knowledge) module is not available.", execCtx.limits);
+ }
+ const resolver = new MentionContentResolver(MAX_PAGE_CHARS, MAX_PAGE_CHARS);
+ const [page] = await resolver.resolveMessageMentions(
+ `@[${KNOWLEDGE_PAGE}:${pageId}|page]`,
+ WORKSPACE,
+ );
+ if (!page || !page.exists) {
+ return failed(`No Docs page with id '${pageId}'.`, execCtx.limits);
+ }
+ return {
+ ok: true,
+ data: { id: pageId, title: page.displayText, markdown: page.content },
+ summary: `Docs page "${page.displayText}" (${page.content.length} chars).`,
+ sources: [
+ { id: `knowledge_${pageId}`, kind: "file", refId: pageId, label: page.displayText },
+ ],
+ warnings: [],
+ truncated: page.content.length >= MAX_PAGE_CHARS,
+ limits: execCtx.limits,
+ };
+ },
+ };
+}
+
+export function registerKnowledgeTools(registry: AiToolRegistry): void {
+ registry.register(makeSearchTool());
+ registry.register(makeGetPageTool());
+}
diff --git a/src/modules/assistant/backend/tools/mcp-design-tools.ts b/src/modules/assistant/backend/tools/mcp-design-tools.ts
new file mode 100644
index 00000000..16f54d7e
--- /dev/null
+++ b/src/modules/assistant/backend/tools/mcp-design-tools.ts
@@ -0,0 +1,329 @@
+import type {
+ AiJsonSchemaObject,
+ AiTool,
+ AiToolExecutionContext,
+ AiToolRegistry,
+ AiToolResult,
+} from "@openpcb/ai-core";
+import type { CoreBackendModuleContext } from "../../../../core/contracts/modules/backend-module";
+import { MODULE_SDK_TOKENS, type DesignerSDK } from "../../../../sdks";
+import type { ContextResolver } from "../context-resolver";
+import type { ConversationStore } from "../conversation-store";
+import {
+ AI_DESIGNER_SESSION_ID,
+ finalizeAndMaybeApply,
+ mcpActorOf,
+ resolveDesignForTool,
+ type SchematicProposalEnvelope,
+} from "./designer-tools";
+
+/**
+ * Design management and history for MCP clients: rename, delete (waits for
+ * approval), focus a design in the UI, read history, undo / redo.
+ *
+ * Undo and redo act on `designer-ui-session`, the undo session the UI, the
+ * in-app assistant and MCP clients share — which is what lets the user
+ * Ctrl+Z an agent's edit. The flip side is that an agent's undo could revert
+ * the USER's last edit, so `designer_undo` / `designer_redo` only act when
+ * the entry on top of the stack is a command this SESSION applied (matched by
+ * command id against the proposals whose actor is this client key + instance
+ * id). Anything else — the user's edit, or another session's — is not its to
+ * undo.
+ */
+
+type Limits = AiToolResult["limits"];
+
+function failed(message: string, limits: Limits): AiToolResult {
+ return {
+ ok: false,
+ data: null,
+ summary: message,
+ sources: [],
+ warnings: [message],
+ truncated: false,
+ limits,
+ };
+}
+
+function ok(summary: string, data: unknown, limits: Limits): AiToolResult {
+ return { ok: true, data, summary, sources: [], warnings: [], truncated: false, limits };
+}
+
+const DESIGN_ID: AiJsonSchemaObject = {
+ type: "string",
+ description: "Target design. Omit to use the pinned or focused design.",
+};
+
+function designerOf(ctx: CoreBackendModuleContext): DesignerSDK | undefined {
+ return ctx.sdk.get(MODULE_SDK_TOKENS.DESIGNER) ?? undefined;
+}
+
+function targetDesign(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ execCtx: AiToolExecutionContext,
+ requested: string | undefined,
+): { designer: DesignerSDK; designId: string } | string {
+ const designer = designerOf(ctx);
+ if (!designer) return "Designer module is not available.";
+ const resolved = resolveDesignForTool({
+ chatId: execCtx.chatId,
+ requestedDesignId: requested,
+ contextResolver,
+ });
+ return resolved.ok ? { designer, designId: resolved.designId } : resolved.warning;
+}
+
+function makeRenameTool(ctx: CoreBackendModuleContext, contextResolver: ContextResolver): AiTool {
+ return {
+ definition: {
+ name: "designer_rename_design",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.design",
+ description: "Rename a design.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ name: { type: "string", minLength: 1, maxLength: 120 },
+ },
+ required: ["name"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = input as { designId?: string; name: string };
+ const target = targetDesign(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const name = args.name.trim();
+ if (!name) return failed("The name must not be empty.", execCtx.limits);
+ const updated = await target.designer.updateDesign(target.designId, { name });
+ if (!updated) return failed(`Design '${target.designId}' not found.`, execCtx.limits);
+ return ok(`Renamed the design to "${updated.name}".`, { id: updated.id, name: updated.name }, execCtx.limits);
+ },
+ };
+}
+
+function makeDeleteTool(
+ ctx: CoreBackendModuleContext,
+ conversation: ConversationStore,
+): AiTool {
+ return {
+ definition: {
+ name: "designer_delete_design",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.design.delete",
+ description:
+ "Delete an entire design — schematic, PCB, history. Irreversible, so it always waits for the user's approval in OpenPCB (then call assistant_await_proposal). designId is required: never delete by default.",
+ inputSchema: {
+ type: "object",
+ properties: { designId: { type: "string" } },
+ required: ["designId"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const { designId } = input as { designId: string };
+ const designer = designerOf(ctx);
+ if (!designer) return failed("Designer module is not available.", execCtx.limits);
+ if (!execCtx.chatId) return failed("Chat context missing.", execCtx.limits);
+ const design = await designer.getDesign(designId);
+ if (!design) return failed(`Design '${designId}' not found.`, execCtx.limits);
+ const proposalId = crypto.randomUUID();
+ const sources = [
+ { id: `design_${designId}`, kind: "design" as const, refId: designId, label: design.head.name },
+ ];
+ const envelope: SchematicProposalEnvelope = {
+ id: proposalId,
+ kind: "designer_design_delete",
+ toolName: "designer_delete_design",
+ title: `Delete design "${design.head.name}"`,
+ summary: `Delete the whole design "${design.head.name}" (irreversible).`,
+ riskLevel: "destructive",
+ designId,
+ baseRevision: design.head.revision,
+ // Descriptive only: approval runs DesignerSDK.deleteDesign
+ // (proposal-apply-service `designer_design_delete`); the payload is
+ // never dispatched as a command.
+ operations: [
+ {
+ id: `${proposalId}:delete`,
+ kind: "designer.delete_design",
+ title: `Delete design "${design.head.name}"`,
+ summary: "Removes the schematic, PCB and history.",
+ riskLevel: "destructive",
+ payload: { type: "pcb_set_view_state", patch: {} },
+ sources,
+ warnings: [],
+ },
+ ],
+ payload: null,
+ sources,
+ warnings: [],
+ };
+ // Never auto-applied, not even under a session allowance: deleting a
+ // whole design is irreversible, so every one is a fresh decision.
+ return (await finalizeAndMaybeApply({
+ designer,
+ conversation,
+ actor: mcpActorOf(execCtx),
+ chatId: execCtx.chatId,
+ designId,
+ baseRevision: design.head.revision,
+ envelope,
+ warnings: [],
+ sources,
+ limits: execCtx.limits,
+ options: {
+ isSessionAutoApplyAllowed: () => false,
+ },
+ })) as AiToolResult;
+ },
+ };
+}
+
+function makeFocusTool(ctx: CoreBackendModuleContext): AiTool {
+ return {
+ definition: {
+ name: "designer_focus_design",
+ version: "1",
+ // A UI side effect, not a design write (tool-policy `uiSideEffect`):
+ // available with writes off, never flagged read-only.
+ effect: "read",
+ capability: "designer.ui.focus",
+ description:
+ "Open and focus a design in the OpenPCB window so the user sees what you are working on. Changes no design data.",
+ inputSchema: {
+ type: "object",
+ properties: { designId: { type: "string" } },
+ required: ["designId"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const { designId } = input as { designId: string };
+ const designer = designerOf(ctx);
+ if (!designer) return failed("Designer module is not available.", execCtx.limits);
+ const design = await designer.getDesign(designId);
+ if (!design) return failed(`Design '${designId}' not found.`, execCtx.limits);
+ const { delivered } = designer.requestFocus(designId);
+ return ok(
+ delivered
+ ? `Focused "${design.head.name}" in OpenPCB.`
+ : `OpenPCB's designer screen is not open, so nothing was focused; the user can open "${design.head.name}" themselves.`,
+ { designId, delivered },
+ execCtx.limits,
+ );
+ },
+ };
+}
+
+function makeHistoryTool(ctx: CoreBackendModuleContext, contextResolver: ContextResolver): AiTool {
+ return {
+ definition: {
+ name: "designer_get_history",
+ version: "1",
+ effect: "read",
+ capability: "designer.read.history",
+ description:
+ "Undo/redo state of a design (shared by the OpenPCB UI and agents): current revision, depth of each stack, and the command the next undo / redo would affect.",
+ inputSchema: { type: "object", properties: { designId: DESIGN_ID } },
+ },
+ async execute(execCtx, input): Promise> {
+ const { designId } = (input ?? {}) as { designId?: string };
+ const target = targetDesign(ctx, contextResolver, execCtx, designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const design = await target.designer.getDesign(target.designId);
+ if (!design) return failed(`Design '${target.designId}' not found.`, execCtx.limits);
+ const history = await target.designer.getHistory(target.designId, AI_DESIGNER_SESSION_ID);
+ return ok(
+ `Revision ${design.head.revision}: ${history.undoDepth} undoable, ${history.redoDepth} redoable.`,
+ { designId: target.designId, revision: design.head.revision, ...history },
+ execCtx.limits,
+ );
+ },
+ };
+}
+
+function makeUndoRedoTool(
+ direction: "undo" | "redo",
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+): AiTool {
+ const name = direction === "undo" ? "designer_undo" : "designer_redo";
+ return {
+ definition: {
+ name,
+ version: "1",
+ effect: "write",
+ capability: "designer.write.history",
+ description:
+ direction === "undo"
+ ? "Undo the most recent change IF this session made it (OpenPCB refuses to undo the user's own edits — they undo those in the app). Pass expectedRevision from your last read to also refuse if anything changed since."
+ : "Redo the most recently undone change IF this session made it. Pass expectedRevision to refuse if anything changed since.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ expectedRevision: { type: "integer", minimum: 0 },
+ },
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = (input ?? {}) as { designId?: string; expectedRevision?: number };
+ const target = targetDesign(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const design = await target.designer.getDesign(target.designId);
+ if (!design) return failed(`Design '${target.designId}' not found.`, execCtx.limits);
+ if (
+ args.expectedRevision !== undefined &&
+ design.head.revision !== args.expectedRevision
+ ) {
+ return failed(
+ `The design moved to revision ${design.head.revision} since revision ${args.expectedRevision} — someone else edited it. Re-read it before ${direction === "undo" ? "undoing" : "redoing"}.`,
+ execCtx.limits,
+ );
+ }
+ const history = await target.designer.getHistory(target.designId, AI_DESIGNER_SESSION_ID);
+ const next = direction === "undo" ? history.nextUndo : history.nextRedo;
+ if (!next) return failed(`Nothing to ${direction}.`, execCtx.limits);
+ // Ownership is the proposing session (actor columns on its applied
+ // proposals), not the chat: another Claude Code session of the same
+ // client is "another agent" here too.
+ const actor = mcpActorOf(execCtx);
+ if (
+ !actor ||
+ !conversation.commandIdsAppliedByActor(actor, target.designId).has(next.commandId)
+ ) {
+ return failed(
+ `The next ${direction} (${next.commandType}) was not made by this session — it is the user's or another agent's change. Ask the user to ${direction} it in OpenPCB if they want to.`,
+ execCtx.limits,
+ );
+ }
+ const result =
+ direction === "undo"
+ ? await target.designer.undo(target.designId, AI_DESIGNER_SESSION_ID)
+ : await target.designer.redo(target.designId, AI_DESIGNER_SESSION_ID);
+ if (!result.ok) return failed(`Nothing to ${direction}.`, execCtx.limits);
+ return ok(
+ `${direction === "undo" ? "Undid" : "Redid"} ${next.commandType}; the design is at revision ${result.revision}.`,
+ { designId: target.designId, revision: result.revision, history: result.history },
+ execCtx.limits,
+ );
+ },
+ };
+}
+
+export function registerMcpDesignTools(
+ registry: AiToolRegistry,
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+): void {
+ registry.register(makeRenameTool(ctx, contextResolver));
+ registry.register(makeDeleteTool(ctx, conversation));
+ registry.register(makeFocusTool(ctx));
+ registry.register(makeHistoryTool(ctx, contextResolver));
+ registry.register(makeUndoRedoTool("undo", ctx, contextResolver, conversation));
+ registry.register(makeUndoRedoTool("redo", ctx, contextResolver, conversation));
+}
diff --git a/src/modules/assistant/backend/tools/mcp-pcb-tools.ts b/src/modules/assistant/backend/tools/mcp-pcb-tools.ts
new file mode 100644
index 00000000..fcad355e
--- /dev/null
+++ b/src/modules/assistant/backend/tools/mcp-pcb-tools.ts
@@ -0,0 +1,1731 @@
+import type {
+ AiJsonSchemaObject,
+ AiSourceRef,
+ AiTool,
+ AiToolExecutionContext,
+ AiToolRegistry,
+ AiToolResult,
+} from "@openpcb/ai-core";
+import type { CoreBackendModuleContext } from "../../../../core/contracts/modules/backend-module";
+import {
+ copperLayersForCount,
+ DRC_RULE_CLASSES,
+ MODULE_SDK_TOKENS,
+ type DesignerCommandEnvelope,
+ type DrcRuleClass,
+ type DesignerPcbProjection,
+ type DesignerSDK,
+ type PcbBoardOutline,
+ type PcbCopperLayerId,
+ type PcbKeepoutRestrictions,
+ type PcbNetClass,
+ type PcbPlacedPart,
+ type PcbZonePadConnection,
+} from "../../../../sdks";
+import {
+ padWorldPositionMm,
+ placementPads,
+} from "../../../../shared/pcb-geometry/pad-geometry";
+import { buildTracePathThroughAnchors } from "../../../../shared/pcb-geometry/pcb-trace-geometry";
+import { resolveNetClassId } from "../../../../shared/pcb-areas/net-class-resolver";
+import { NON_OVERRIDABLE } from "../../../../shared/drc/severity";
+import { canonicalizeRing } from "../../../../shared/pcb-geometry/ring-utils";
+import { ringSelfIntersects } from "../../../../shared/pcb-geometry/segment-predicates";
+import type { ContextResolver } from "../context-resolver";
+import type { ConversationStore } from "../conversation-store";
+import {
+ dedupByActionId,
+ finalizeAndMaybeApply,
+ mcpActorOf,
+ resolveDesignForTool,
+ type DesignerToolOptions,
+ type SchematicProposalEnvelope,
+} from "./designer-tools";
+import { drcCounts, drcCountsLine } from "./drc-counts";
+import { applyNetClassPatches, clearanceProblems } from "./rules-validation";
+
+/**
+ * PCB tools for MCP clients (Claude Code).
+ *
+ * The in-app assistant is schematic-only; none of the ~40 `pcb_*` designer
+ * commands had a tool. These close that gap for external agents, MCP-only
+ * (CLAUDE.md: the in-app registry's prompt and DoD harness are tuned against
+ * its current set).
+ *
+ * Conventions every tool here follows:
+ * - **Millimetres** at the tool boundary; trace geometry is converted to the
+ * integer nanometres the store persists (`NM_PER_MM`), never floats.
+ * - **Stable addressing.** Parts by reference designator; pads as `REF.PAD`
+ * (resolved to `${placement.id}|${pad.number}`); nets by NAME, resolved to
+ * the ephemeral net id from a fresh projection immediately before the
+ * proposal is built (`designer/AGENTS.md`: net ids change on edits).
+ * - **Through the proposal system** (`finalizeAndMaybeApply`): every write is
+ * persisted, renders as a card in the design chat, dedupes on `action_id`,
+ * auto-applies under the session policy (undoable), and — for deletions and
+ * non-undoable rule changes — waits for the user's approval.
+ * - Copper commits use `legality: "refuse"`: an illegal route is rejected with
+ * `PCB_COPPER_ILLEGAL` instead of landing a DRC violation.
+ * - No manufacturing constants are invented here: widths, clearances and via
+ * sizes default from the design's own net classes and rules; a physical
+ * dimension the design cannot supply (a corner radius, a board size) must
+ * come from the caller.
+ * - **Validated against the design, not just the schema**: layers against the
+ * board's real copper stack, sizes > 0, via drill < via pad, unique net
+ * class ids/names, simple outline polygons. MCP writes bypass the HTTP
+ * route parsers, so these checks live here.
+ * - **One risk per tool**: add/update and delete are separate tools, so a
+ * tool's annotations say exactly what it can do.
+ */
+
+const NM_PER_MM = 1_000_000;
+/** Layout paging: keeps one read well inside a client's result budget. */
+const DEFAULT_LAYOUT_PAGE = 100;
+const MAX_LAYOUT_PAGE = 500;
+const MAX_LAYOUT_TRACES = 2_000;
+
+// ─── shared helpers ────────────────────────────────────────────────────
+
+type Limits = AiToolResult["limits"];
+
+function failed(message: string, limits: Limits): AiToolResult {
+ return {
+ ok: false,
+ data: null,
+ summary: message,
+ sources: [],
+ warnings: [message],
+ truncated: false,
+ limits,
+ };
+}
+
+function round(value: number, digits = 4): number {
+ const f = 10 ** digits;
+ return Math.round(value * f) / f;
+}
+
+function designerOf(ctx: CoreBackendModuleContext): DesignerSDK | undefined {
+ return ctx.sdk.get(MODULE_SDK_TOKENS.DESIGNER) ?? undefined;
+}
+
+interface PcbTarget {
+ designer: DesignerSDK;
+ designId: string;
+ pcb: DesignerPcbProjection;
+}
+
+async function loadTarget(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ execCtx: AiToolExecutionContext,
+ requestedDesignId: string | undefined,
+): Promise {
+ const designer = designerOf(ctx);
+ if (!designer) return "Designer module is not available.";
+ const resolved = resolveDesignForTool({
+ chatId: execCtx.chatId,
+ requestedDesignId,
+ contextResolver,
+ });
+ if (!resolved.ok) return resolved.warning;
+ const pcb = await designer.getPcbProjection(resolved.designId);
+ if (!pcb) return `Design '${resolved.designId}' has no PCB.`;
+ return { designer, designId: resolved.designId, pcb };
+}
+
+/** Case-insensitive net name → current net id. */
+function netIdByName(pcb: DesignerPcbProjection, name: string): string | null {
+ const wanted = name.trim().toUpperCase();
+ for (const [id, netName] of Object.entries(pcb.netNames)) {
+ if (netName.trim().toUpperCase() === wanted) return id;
+ }
+ return null;
+}
+
+function netNameOf(pcb: DesignerPcbProjection, netId: string | null | undefined): string | null {
+ if (!netId) return null;
+ return pcb.netNames[netId] ?? null;
+}
+
+function placementByRef(pcb: DesignerPcbProjection, ref: string): PcbPlacedPart | null {
+ const wanted = ref.trim().toUpperCase();
+ return pcb.placements.find((p) => p.reference.toUpperCase() === wanted) ?? null;
+}
+
+interface ResolvedPad {
+ placement: PcbPlacedPart;
+ padNumber: string;
+ positionMm: { x: number; y: number };
+ netId: string | null;
+}
+
+/** `"U1.3"` → the pad's world position and net. Pad numbers may contain dots, so split on the first. */
+function resolvePad(pcb: DesignerPcbProjection, address: string): ResolvedPad | string {
+ const dot = address.indexOf(".");
+ if (dot <= 0) return `Pad '${address}' must be REF.PAD, e.g. "U1.3".`;
+ const ref = address.slice(0, dot);
+ const padNumber = address.slice(dot + 1);
+ const placement = placementByRef(pcb, ref);
+ if (!placement) return `No footprint '${ref}' on the board.`;
+ const pad = placementPads(placement).find((p) => p.number === padNumber);
+ if (!pad) return `Footprint ${placement.reference} has no pad '${padNumber}'.`;
+ return {
+ placement,
+ padNumber,
+ positionMm: padWorldPositionMm(placement, pad),
+ netId: pcb.padNets?.[`${placement.id}|${padNumber}`] ?? null,
+ };
+}
+
+function padAddressOf(pcb: DesignerPcbProjection, placementId: string, padNumber: string): string {
+ const placement = pcb.placements.find((p) => p.id === placementId);
+ return `${placement?.reference ?? placementId}.${padNumber}`;
+}
+
+function sourceFor(designId: string, label: string): AiSourceRef[] {
+ return [{ id: `design_${designId}`, kind: "pcb", refId: designId, label }];
+}
+
+type Risk = SchematicProposalEnvelope["riskLevel"];
+
+interface ProposeInput {
+ conversation: ConversationStore;
+ options: DesignerToolOptions;
+ execCtx: AiToolExecutionContext;
+ target: PcbTarget;
+ kind: SchematicProposalEnvelope["kind"];
+ toolName: SchematicProposalEnvelope["toolName"];
+ title: string;
+ summary: string;
+ riskLevel: Risk;
+ actionId?: string;
+ operations: Array<{ title: string; payload: DesignerCommandEnvelope["command"] }>;
+ warnings?: string[];
+}
+
+/**
+ * Persist the operations as a write proposal and let the session policy decide
+ * whether it applies now (undoable edits) or waits for the user (deletions,
+ * non-undoable rule changes). After an applied copper change, attach the DRC
+ * picture so the agent sees what its edit did.
+ */
+async function propose(input: ProposeInput): Promise> {
+ const { execCtx, target } = input;
+ const chatId = execCtx.chatId;
+ if (!chatId) return failed("Chat context missing.", execCtx.limits);
+ if (input.operations.length === 0) {
+ return failed("Nothing to do: no operations were built.", execCtx.limits);
+ }
+ if (input.actionId !== undefined && !ACTION_ID_FORMAT.test(input.actionId)) {
+ return failed(
+ "action_id may only contain letters, digits and . _ : - (1–200 characters).",
+ execCtx.limits,
+ );
+ }
+ if (input.actionId) {
+ const dup = dedupByActionId(
+ input.conversation,
+ chatId,
+ target.designId,
+ input.actionId,
+ execCtx.limits,
+ mcpActorOf(execCtx),
+ );
+ if (dup) return dup as AiToolResult;
+ }
+ const design = await target.designer.getDesign(target.designId);
+ if (!design) return failed(`Design '${target.designId}' not found.`, execCtx.limits);
+ const proposalId = crypto.randomUUID();
+ const sources = sourceFor(target.designId, design.head.name);
+ const envelope: SchematicProposalEnvelope = {
+ id: proposalId,
+ kind: input.kind,
+ toolName: input.toolName,
+ ...(input.actionId ? { actionId: input.actionId } : {}),
+ title: input.title,
+ summary: input.summary,
+ riskLevel: input.riskLevel,
+ designId: target.designId,
+ baseRevision: design.head.revision,
+ operations: input.operations.map((op, index) => ({
+ id: `${proposalId}:${input.kind}:${index}`,
+ kind: op.payload.type,
+ title: op.title,
+ summary: op.title,
+ riskLevel: input.riskLevel,
+ payload: op.payload,
+ sources,
+ warnings: [],
+ })),
+ payload: null,
+ sources,
+ warnings: input.warnings ?? [],
+ };
+ const result = await finalizeAndMaybeApply({
+ designer: target.designer,
+ conversation: input.conversation,
+ actor: mcpActorOf(execCtx),
+ chatId,
+ designId: target.designId,
+ baseRevision: design.head.revision,
+ envelope,
+ warnings: input.warnings ?? [],
+ sources,
+ limits: execCtx.limits,
+ options: input.options,
+ });
+ const record = input.conversation.getWriteProposal(chatId, proposalId);
+ const applied = record?.status === "applied" || record?.status === "partial";
+ if (applied && input.kind !== "designer_pcb_rules_edits") {
+ const drc = await target.designer.runDrc(target.designId).catch(() => null);
+ if (drc) {
+ const counts = drcCounts(drc);
+ const after = await target.designer.getDesign(target.designId);
+ result.modelData = {
+ ...(result.modelData as Record),
+ revision: after?.head.revision ?? null,
+ drc: counts,
+ };
+ result.summary = `${result.summary ?? ""} Now: ${drcCountsLine(counts)}`.trim();
+ }
+ }
+ return result as AiToolResult;
+}
+
+const ACTION_ID: AiJsonSchemaObject = {
+ type: "string",
+ minLength: 1,
+ maxLength: 200,
+ description:
+ "Stable idempotency key you choose (letters, digits, . _ : -), e.g. `route_VCC_`. Re-sending it returns the earlier result instead of acting twice; after a rejection or failure, use a new one.",
+};
+
+/** Same alphabet the description promises; checked in code (the schema type has no `pattern`). */
+const ACTION_ID_FORMAT = /^[A-Za-z0-9._:-]{1,200}$/;
+
+const DESIGN_ID: AiJsonSchemaObject = {
+ type: "string",
+ description: "Target design. Omit to use the pinned or focused design.",
+};
+
+const POINT_MM: AiJsonSchemaObject = {
+ type: "object",
+ properties: { x: { type: "number" }, y: { type: "number" } },
+ required: ["x", "y"],
+};
+
+const COPPER_LAYER: AiJsonSchemaObject = {
+ type: "string",
+ description: 'Copper layer, e.g. "F.Cu", "B.Cu", "In1.Cu".',
+};
+
+// ─── read: layout ──────────────────────────────────────────────────────
+
+function netClassSummary(nc: PcbNetClass) {
+ return {
+ id: nc.id,
+ name: nc.name,
+ traceWidthMm: nc.traceWidthMm,
+ clearanceMm: nc.clearanceMm,
+ viaDiameterMm: nc.viaDiameterMm,
+ viaDrillMm: nc.viaDrillMm,
+ };
+}
+
+function makeGetLayoutTool(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+): AiTool {
+ return {
+ definition: {
+ name: "designer_get_pcb_layout",
+ version: "1",
+ effect: "read",
+ capability: "designer.read.pcb",
+ description:
+ "PCB geometry for placement and routing: board outline and rules, net classes, footprints (ref, position, rotation, side) with pad positions and nets, per-net copper (traces, vias), unrouted connections as pad pairs (REF.PAD), zones and keepouts. All coordinates in mm. Filter with refs / nets; detail 'summary' omits pads and copper. Footprints are paged (offset/limit; follow page.nextOffset) and traces are capped — filter by nets on big boards. Read this before pcb_place_footprints or pcb_route.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ detail: { type: "string", enum: ["summary", "full"] },
+ refs: { type: "array", items: { type: "string" }, maxItems: 200 },
+ nets: { type: "array", items: { type: "string" }, maxItems: 200 },
+ offset: { type: "integer", minimum: 0, description: "First footprint to return (default 0)." },
+ limit: {
+ type: "integer",
+ minimum: 1,
+ maximum: MAX_LAYOUT_PAGE,
+ description: `Footprints per page (default ${DEFAULT_LAYOUT_PAGE}).`,
+ },
+ },
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = (input ?? {}) as {
+ designId?: string;
+ detail?: "summary" | "full";
+ refs?: string[];
+ nets?: string[];
+ offset?: number;
+ limit?: number;
+ };
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const { pcb } = target;
+ const full = args.detail !== "summary";
+ const refFilter = args.refs?.length
+ ? new Set(args.refs.map((r) => r.toUpperCase()))
+ : null;
+ const netFilter = args.nets?.length
+ ? new Set(args.nets.map((n) => n.toUpperCase()))
+ : null;
+ const netWanted = (netId: string | null | undefined) => {
+ if (!netFilter) return true;
+ const name = netNameOf(pcb, netId);
+ return name !== null && netFilter.has(name.toUpperCase());
+ };
+
+ const matching = pcb.placements.filter(
+ (p) => !refFilter || refFilter.has(p.reference.toUpperCase()),
+ );
+ const offset = Math.max(0, Math.floor(args.offset ?? 0));
+ const limit = Math.min(MAX_LAYOUT_PAGE, Math.max(1, Math.floor(args.limit ?? DEFAULT_LAYOUT_PAGE)));
+ const nextOffset = offset + limit < matching.length ? offset + limit : null;
+ const placements = matching
+ .slice(offset, offset + limit)
+ .map((p) => ({
+ ref: p.reference,
+ placementId: p.id,
+ footprint: p.footprint.name,
+ positionMm: { x: round(p.positionMm.x), y: round(p.positionMm.y) },
+ rotationDeg: p.rotationDeg,
+ side: p.layer === "B.Cu" ? "bottom" : "top",
+ ...(full
+ ? {
+ pads: placementPads(p).map((pad) => {
+ const pos = padWorldPositionMm(p, pad);
+ return {
+ pad: pad.number,
+ net: netNameOf(pcb, pcb.padNets?.[`${p.id}|${pad.number}`]),
+ xMm: round(pos.x),
+ yMm: round(pos.y),
+ widthMm: pad.widthMm,
+ heightMm: pad.heightMm,
+ shape: pad.shape,
+ drilled: (pad.drillDiameterMm ?? 0) > 0,
+ };
+ }),
+ }
+ : {}),
+ }));
+
+ const nets = Object.entries(pcb.netNames)
+ .filter(([id]) => netWanted(id))
+ .map(([id, name]) => {
+ const pads = Object.entries(pcb.padNets ?? {})
+ .filter(([, netId]) => netId === id)
+ .map(([key]) => {
+ const [placementId, padNumber] = key.split("|");
+ return padAddressOf(pcb, placementId!, padNumber!);
+ });
+ return {
+ name,
+ netClass: resolveNetClassId(
+ name,
+ pcb.board.netClasses,
+ pcb.board.perNetClassAssignments,
+ id,
+ ),
+ pads,
+ traces: pcb.traces.filter((t) => t.netId === id).length,
+ vias: pcb.vias.filter((v) => v.netId === id).length,
+ unrouted: pcb.ratsnest.filter((r) => r.netId === id).length,
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ const endpoint = (e: (typeof pcb.ratsnest)[number]["from"]) =>
+ e.kind === "pad" ? padAddressOf(pcb, e.placementId, e.padNumber) : `freePad:${e.freePadId}`;
+ const unrouted = pcb.ratsnest
+ .filter((r) => netWanted(r.netId))
+ .map((r) => ({
+ net: netNameOf(pcb, r.netId),
+ from: endpoint(r.from),
+ to: endpoint(r.to),
+ fromMm: { x: round(r.fromMm.x), y: round(r.fromMm.y) },
+ toMm: { x: round(r.toMm.x), y: round(r.toMm.y) },
+ }));
+
+ const board = pcb.board;
+ const data: Record = {
+ designId: pcb.designId,
+ revision: pcb.revision,
+ board: {
+ outline: {
+ kind: board.outline.kind,
+ widthMm: board.outline.widthMm,
+ heightMm: board.outline.heightMm,
+ centerMm: board.outline.centerMm,
+ },
+ layerCount: board.layerCount,
+ fabricator: board.fabricator,
+ boardThicknessMm: board.boardThicknessMm ?? null,
+ clearanceMm: board.designRules.clearance,
+ netClasses: board.netClasses.map(netClassSummary),
+ },
+ placements,
+ page: { offset, limit, total: matching.length, nextOffset },
+ nets,
+ unrouted,
+ zones: pcb.zones.map((z) => ({
+ id: z.id,
+ name: z.name ?? null,
+ layer: z.layer,
+ net: z.netName ?? netNameOf(pcb, z.netId),
+ region: z.region.kind,
+ })),
+ keepouts: pcb.keepouts.map((k) => ({
+ id: k.id,
+ name: k.name ?? null,
+ layers: k.layers,
+ })),
+ };
+ let tracesCut = 0;
+ if (full) {
+ const wantedTraces = pcb.traces.filter((t) => netWanted(t.netId));
+ tracesCut = Math.max(0, wantedTraces.length - MAX_LAYOUT_TRACES);
+ data.traces = wantedTraces
+ .slice(0, MAX_LAYOUT_TRACES)
+ .map((t) => ({
+ id: t.id,
+ net: netNameOf(pcb, t.netId),
+ layer: t.layer,
+ widthMm: t.widthMm,
+ pointsMm: t.pointsNm.map((pt) => ({
+ x: round(pt.x / NM_PER_MM),
+ y: round(pt.y / NM_PER_MM),
+ })),
+ }));
+ data.vias = pcb.vias
+ .filter((v) => netWanted(v.netId))
+ .map((v) => ({
+ id: v.id,
+ net: netNameOf(pcb, v.netId),
+ xMm: round(v.centerMm.x),
+ yMm: round(v.centerMm.y),
+ diameterMm: v.diameterMm,
+ drillMm: v.drillMm,
+ }));
+ }
+ const notes: string[] = [];
+ if (nextOffset !== null) {
+ notes.push(`Footprints ${offset + 1}–${offset + placements.length} of ${matching.length}; call again with offset ${nextOffset} for more.`);
+ }
+ if (tracesCut > 0) {
+ notes.push(`${tracesCut} more trace(s) not listed — filter by nets.`);
+ }
+ return {
+ ok: true,
+ data,
+ summary: `PCB rev ${pcb.revision}: ${pcb.placements.length} footprint(s), ${pcb.traces.length} trace(s), ${pcb.vias.length} via(s), ${pcb.ratsnest.length} unrouted connection(s).${notes.length ? ` ${notes.join(" ")}` : ""}`,
+ sources: sourceFor(pcb.designId, "PCB layout"),
+ warnings: pcb.warnings.slice(0, 10),
+ truncated: nextOffset !== null || tracesCut > 0,
+ limits: execCtx.limits,
+ };
+ },
+ };
+}
+
+// ─── write: placement ──────────────────────────────────────────────────
+
+interface PlaceInput {
+ designId?: string;
+ action_id?: string;
+ placements: Array<{
+ ref: string;
+ xMm?: number;
+ yMm?: number;
+ rotationDeg?: number;
+ side?: "top" | "bottom";
+ }>;
+}
+
+function makePlaceTool(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ return {
+ definition: {
+ name: "pcb_place_footprints",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.place",
+ description:
+ "Move, rotate and/or flip footprints on the PCB, by reference designator. Positions are the footprint origin in board mm; rotation is 0/90/180/270; side 'bottom' flips to B.Cu. Applies at once (undoable in OpenPCB) and reports the DRC count afterwards.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ action_id: ACTION_ID,
+ placements: {
+ type: "array",
+ minItems: 1,
+ maxItems: 200,
+ items: {
+ type: "object",
+ properties: {
+ ref: { type: "string" },
+ xMm: { type: "number" },
+ yMm: { type: "number" },
+ rotationDeg: { type: "number", enum: [0, 90, 180, 270] },
+ side: { type: "string", enum: ["top", "bottom"] },
+ },
+ required: ["ref"],
+ },
+ },
+ },
+ required: ["placements"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = input as PlaceInput;
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const { pcb } = target;
+ const moves: Array<{ placementId: string; positionMm: { x: number; y: number } }> = [];
+ const operations: ProposeInput["operations"] = [];
+ const warnings: string[] = [];
+ const flips: string[] = [];
+ for (const entry of args.placements) {
+ const placement = placementByRef(pcb, entry.ref);
+ if (!placement) {
+ warnings.push(`No footprint '${entry.ref}' on the board; skipped.`);
+ continue;
+ }
+ if (entry.xMm !== undefined || entry.yMm !== undefined) {
+ moves.push({
+ placementId: placement.id,
+ positionMm: {
+ x: entry.xMm ?? placement.positionMm.x,
+ y: entry.yMm ?? placement.positionMm.y,
+ },
+ });
+ }
+ if (entry.side) {
+ const onBottom = placement.layer === "B.Cu";
+ if ((entry.side === "bottom") !== onBottom) flips.push(placement.id);
+ }
+ if (entry.rotationDeg !== undefined && entry.rotationDeg !== placement.rotationDeg) {
+ operations.push({
+ title: `Rotate ${placement.reference} to ${entry.rotationDeg}°`,
+ payload: {
+ type: "pcb_rotate_placement",
+ placementId: placement.id,
+ rotationDeg: entry.rotationDeg as 0 | 90 | 180 | 270,
+ },
+ });
+ }
+ }
+ if (moves.length > 0) {
+ operations.unshift({
+ title: `Move ${moves.length} footprint(s)`,
+ payload: { type: "pcb_move_placements", updates: moves },
+ });
+ }
+ if (flips.length > 0) {
+ operations.push({
+ title: `Flip ${flips.length} footprint(s)`,
+ payload: { type: "pcb_flip_placements", placementIds: flips },
+ });
+ }
+ if (operations.length === 0) {
+ return failed(
+ warnings[0] ?? "Nothing changes: the footprints are already there.",
+ execCtx.limits,
+ );
+ }
+ return propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ kind: "designer_pcb_place_batch",
+ toolName: "pcb_place_footprints",
+ title: "Place footprints",
+ summary: `Place ${args.placements.length} footprint(s)`,
+ riskLevel: "medium",
+ actionId: args.action_id,
+ operations,
+ warnings,
+ });
+ },
+ };
+}
+
+// ─── write: routing ────────────────────────────────────────────────────
+
+interface RouteInput {
+ designId?: string;
+ action_id?: string;
+ traces?: Array<{
+ net: string;
+ layer: string;
+ widthMm?: number;
+ from?: string;
+ to?: string;
+ waypointsMm?: Array<{ x: number; y: number }>;
+ }>;
+ vias?: Array<{ net: string; xMm: number; yMm: number }>;
+}
+
+function makeRouteTool(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ return {
+ definition: {
+ name: "pcb_route",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.route",
+ description:
+ "Commit copper for named nets as ONE atomic, undoable change: traces (from a pad REF.PAD, through optional waypoints in mm, to a pad) and vias. Waypoints are joined with 45°/90° elbows like the interactive router. Width and via size default to the net's class. Both end pads must be on the named net. OpenPCB refuses copper that would violate DRC (PCB_COPPER_ILLEGAL) — then adjust the path. Read designer_get_pcb_layout first.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ action_id: ACTION_ID,
+ traces: {
+ type: "array",
+ maxItems: 100,
+ items: {
+ type: "object",
+ properties: {
+ net: { type: "string", description: "Net name, e.g. GND or VCC." },
+ layer: COPPER_LAYER,
+ widthMm: { type: "number", minimum: 0 },
+ from: { type: "string", description: 'Start pad, e.g. "U1.3".' },
+ to: { type: "string", description: 'End pad, e.g. "R1.1".' },
+ waypointsMm: { type: "array", items: POINT_MM, maxItems: 100 },
+ },
+ required: ["net", "layer"],
+ },
+ },
+ vias: {
+ type: "array",
+ maxItems: 100,
+ items: {
+ type: "object",
+ properties: {
+ net: { type: "string" },
+ xMm: { type: "number" },
+ yMm: { type: "number" },
+ },
+ required: ["net", "xMm", "yMm"],
+ },
+ },
+ },
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = input as RouteInput;
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const { pcb } = target;
+ const board = pcb.board;
+ const classFor = (netId: string, name: string): PcbNetClass | null => {
+ const id = resolveNetClassId(name, board.netClasses, board.perNetClassAssignments, netId);
+ return board.netClasses.find((c) => c.id === id) ?? board.netClasses[0] ?? null;
+ };
+
+ const traces: Array["traces"][number]> = [];
+ for (const [index, t] of (args.traces ?? []).entries()) {
+ const label = `trace ${index + 1} (${t.net})`;
+ const layerIssue = layerProblem(pcb, t.layer);
+ if (layerIssue) return failed(`${label}: ${layerIssue}`, execCtx.limits);
+ if (t.widthMm !== undefined && !positiveMm(t.widthMm)) {
+ return failed(`${label}: widthMm must be > 0 (omit it to use the net class width).`, execCtx.limits);
+ }
+ const netId = netIdByName(pcb, t.net);
+ if (!netId) return failed(`${label}: no net named '${t.net}'.`, execCtx.limits);
+ const anchorsMm: Array<{ x: number; y: number }> = [];
+ for (const [end, address] of [["from", t.from], ["to", t.to]] as const) {
+ if (!address) continue;
+ const pad = resolvePad(pcb, address);
+ if (typeof pad === "string") return failed(`${label}: ${pad}`, execCtx.limits);
+ if (pad.netId !== netId) {
+ return failed(
+ `${label}: pad ${address} is on net ${netNameOf(pcb, pad.netId) ?? "(none)"}, not ${t.net}.`,
+ execCtx.limits,
+ );
+ }
+ if (end === "from") anchorsMm.unshift(pad.positionMm);
+ else anchorsMm.push(pad.positionMm);
+ }
+ const middle = t.waypointsMm ?? [];
+ const ordered = [
+ ...(t.from ? [anchorsMm[0]!] : []),
+ ...middle,
+ ...(t.to ? [anchorsMm[anchorsMm.length - 1]!] : []),
+ ];
+ if (ordered.length < 2) {
+ return failed(`${label}: give from/to pads and/or at least two waypoints.`, execCtx.limits);
+ }
+ const netClass = classFor(netId, t.net);
+ if (!netClass) return failed("The board has no net classes.", execCtx.limits);
+ const anchorsNm = ordered.map((p) => ({
+ x: Math.round(p.x * NM_PER_MM),
+ y: Math.round(p.y * NM_PER_MM),
+ }));
+ const pointsNm = buildTracePathThroughAnchors(anchorsNm, "manhattan-45").map((p) => ({
+ x: Math.round(p.x),
+ y: Math.round(p.y),
+ }));
+ traces.push({
+ layer: t.layer as PcbCopperLayerId,
+ pointsNm,
+ widthMm: t.widthMm ?? netClass.traceWidthMm,
+ netId,
+ netClassId: netClass.id,
+ segmentMode: "manhattan-45",
+ });
+ }
+
+ const vias: Array["vias"][number]> = [];
+ for (const [index, v] of (args.vias ?? []).entries()) {
+ const netId = netIdByName(pcb, v.net);
+ if (!netId) return failed(`via ${index + 1}: no net named '${v.net}'.`, execCtx.limits);
+ const netClass = classFor(netId, v.net);
+ if (!netClass) return failed("The board has no net classes.", execCtx.limits);
+ vias.push({ centerMm: { x: v.xMm, y: v.yMm }, netId, netClassId: netClass.id });
+ }
+
+ if (traces.length === 0 && vias.length === 0) {
+ return failed("Give at least one trace or via.", execCtx.limits);
+ }
+ const nets = [...new Set([...(args.traces ?? []), ...(args.vias ?? [])].map((x) => x.net))];
+ return propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ kind: "designer_pcb_route_batch",
+ toolName: "pcb_route",
+ title: `Route ${nets.join(", ")}`,
+ summary: `${traces.length} trace(s), ${vias.length} via(s)`,
+ riskLevel: "medium",
+ actionId: args.action_id,
+ operations: [
+ {
+ title: `Route ${nets.join(", ")}: ${traces.length} trace(s), ${vias.length} via(s)`,
+ payload: { type: "pcb_commit_route", traces, vias, legality: "refuse" },
+ },
+ ],
+ });
+ },
+ };
+}
+
+function makeDeleteRoutingTool(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ return {
+ definition: {
+ name: "pcb_delete_routing",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.delete",
+ description:
+ "Delete traces and vias — by id (from designer_get_pcb_layout) and/or every trace and via of named nets. Destructive: it waits for the user's approval in OpenPCB (then call assistant_await_proposal).",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ action_id: ACTION_ID,
+ traceIds: { type: "array", items: { type: "string" }, maxItems: 500 },
+ viaIds: { type: "array", items: { type: "string" }, maxItems: 500 },
+ nets: { type: "array", items: { type: "string" }, maxItems: 50 },
+ },
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = (input ?? {}) as {
+ designId?: string;
+ action_id?: string;
+ traceIds?: string[];
+ viaIds?: string[];
+ nets?: string[];
+ };
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const { pcb } = target;
+ const traceIds = new Set(args.traceIds ?? []);
+ const viaIds = new Set(args.viaIds ?? []);
+ const warnings: string[] = [];
+ for (const name of args.nets ?? []) {
+ const netId = netIdByName(pcb, name);
+ if (!netId) {
+ warnings.push(`No net named '${name}'.`);
+ continue;
+ }
+ for (const t of pcb.traces) if (t.netId === netId) traceIds.add(t.id);
+ for (const v of pcb.vias) if (v.netId === netId) viaIds.add(v.id);
+ }
+ const known = (ids: Set, pool: Array<{ id: string }>) =>
+ [...ids].filter((id) => pool.some((x) => x.id === id));
+ const traces = known(traceIds, pcb.traces);
+ const vias = known(viaIds, pcb.vias);
+ const unknownIds = [
+ ...[...(args.traceIds ?? [])].filter((id) => !pcb.traces.some((t) => t.id === id)),
+ ...[...(args.viaIds ?? [])].filter((id) => !pcb.vias.some((v) => v.id === id)),
+ ];
+ if (unknownIds.length > 0) {
+ warnings.push(
+ `Not on the board (already deleted, or stale ids — re-read designer_get_pcb_layout): ${unknownIds.slice(0, 10).join(", ")}${unknownIds.length > 10 ? ", …" : ""}.`,
+ );
+ }
+ if (traces.length + vias.length === 0) {
+ return failed(warnings.join(" ") || "No matching traces or vias.", execCtx.limits);
+ }
+ // Skipped names/ids go in the summary (the approval card shows it) and
+ // back to the agent — not as proposal warnings, which would make the
+ // destructive proposal un-appliable without "apply anyway".
+ const skippedNote = warnings.length > 0 ? ` Skipped: ${warnings.join(" ")}` : "";
+ const proposed = await propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ kind: "designer_pcb_deletions",
+ toolName: "pcb_delete_routing",
+ title: "Delete routing",
+ summary: `Delete ${traces.length} trace(s) and ${vias.length} via(s).${skippedNote}`,
+ riskLevel: "destructive",
+ actionId: args.action_id,
+ operations: [
+ ...traces.map((traceId) => ({
+ title: `Delete trace ${traceId}`,
+ payload: { type: "pcb_delete_trace" as const, traceId },
+ })),
+ ...vias.map((viaId) => ({
+ title: `Delete via ${viaId}`,
+ payload: { type: "pcb_delete_via" as const, viaId },
+ })),
+ ],
+ });
+ if (warnings.length > 0) {
+ proposed.warnings = [...(proposed.warnings ?? []), ...warnings];
+ }
+ return proposed;
+ },
+ };
+}
+
+// ─── write: board + rules ──────────────────────────────────────────────
+
+function bbox(points: Array<{ x: number; y: number }>) {
+ const xs = points.map((p) => p.x);
+ const ys = points.map((p) => p.y);
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const minY = Math.min(...ys);
+ const maxY = Math.max(...ys);
+ return {
+ widthMm: maxX - minX,
+ heightMm: maxY - minY,
+ centerMm: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
+ };
+}
+
+const positiveMm = (value: number | undefined): value is number =>
+ typeof value === "number" && Number.isFinite(value) && value > 0;
+
+/**
+ * The outline an agent asked for, or why not. No geometry is defaulted: a
+ * rounded rectangle needs its radius, a circle its diameter — the tool never
+ * invents a physical dimension (the old `cornerRadiusMm ?? 1` did). A circle
+ * and an oval are both kind "circle" in the model (bounding-box diameters,
+ * `PcbBoardOutline`), so the tool names them apart to keep "circle" round.
+ */
+export function buildOutline(
+ args: {
+ shape: "rect" | "roundrect" | "circle" | "oval" | "polygon";
+ widthMm?: number;
+ heightMm?: number;
+ diameterMm?: number;
+ centerMm?: { x: number; y: number };
+ cornerRadiusMm?: number;
+ pointsMm?: Array<{ x: number; y: number }>;
+ },
+ currentCenter: { x: number; y: number },
+): PcbBoardOutline | string {
+ const centerMm = args.centerMm ?? currentCenter;
+ if (args.shape === "polygon") {
+ const points = canonicalizeRing(args.pointsMm ?? []);
+ if (points.length < 3) return "A polygon outline needs at least 3 distinct pointsMm.";
+ if (!points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) {
+ return "Every polygon point needs finite x and y.";
+ }
+ if (ringSelfIntersects(points)) {
+ return "The polygon crosses or touches itself; give the corners in order around the board.";
+ }
+ const box = bbox(points);
+ if (!positiveMm(box.widthMm) || !positiveMm(box.heightMm)) {
+ return "The polygon has no area.";
+ }
+ return { kind: "polygon", pointsMm: points, ...box };
+ }
+ if (args.shape === "circle") {
+ if (!positiveMm(args.diameterMm)) return "A circle outline needs diameterMm > 0.";
+ if (args.widthMm !== undefined || args.heightMm !== undefined) {
+ return "A circle takes diameterMm only; use shape 'oval' for different width and height.";
+ }
+ return { kind: "circle", widthMm: args.diameterMm, heightMm: args.diameterMm, centerMm };
+ }
+ if (!positiveMm(args.widthMm) || !positiveMm(args.heightMm)) {
+ return `A ${args.shape} outline needs widthMm and heightMm > 0.`;
+ }
+ const base = { widthMm: args.widthMm, heightMm: args.heightMm, centerMm };
+ if (args.shape === "oval") return { kind: "circle", ...base };
+ if (args.shape === "roundrect") {
+ const limit = Math.min(args.widthMm, args.heightMm) / 2;
+ if (!positiveMm(args.cornerRadiusMm) || args.cornerRadiusMm > limit) {
+ return `A roundrect needs cornerRadiusMm > 0 and ≤ ${round(limit, 3)} (half the shorter side).`;
+ }
+ return { kind: "roundrect", ...base, cornerRadiusMm: args.cornerRadiusMm };
+ }
+ return { kind: "rect", ...base };
+}
+
+function makeBoardOutlineTool(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ return {
+ definition: {
+ name: "pcb_set_board_outline",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.board",
+ description:
+ "Set the board outline around centerMm (default: current center): 'rect' / 'roundrect' from widthMm × heightMm (roundrect also needs cornerRadiusMm), 'circle' from diameterMm, 'oval' from widthMm × heightMm, or 'polygon' from pointsMm (a simple, non-self-intersecting ring). Existing cutouts are kept. Undoable. Use the dimensions the user gives — never invent them.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ action_id: ACTION_ID,
+ shape: { type: "string", enum: ["rect", "roundrect", "circle", "oval", "polygon"] },
+ widthMm: { type: "number", minimum: 0 },
+ heightMm: { type: "number", minimum: 0 },
+ diameterMm: { type: "number", minimum: 0 },
+ centerMm: POINT_MM,
+ cornerRadiusMm: { type: "number", minimum: 0 },
+ pointsMm: { type: "array", items: POINT_MM, minItems: 3, maxItems: 500 },
+ },
+ required: ["shape"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = input as {
+ designId?: string;
+ action_id?: string;
+ shape: "rect" | "roundrect" | "circle" | "oval" | "polygon";
+ widthMm?: number;
+ heightMm?: number;
+ diameterMm?: number;
+ centerMm?: { x: number; y: number };
+ cornerRadiusMm?: number;
+ pointsMm?: Array<{ x: number; y: number }>;
+ };
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const built = buildOutline(args, target.pcb.board.outline.centerMm);
+ if (typeof built === "string") return failed(built, execCtx.limits);
+ const outline = built;
+ return propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ kind: "designer_pcb_board_edits",
+ toolName: "pcb_set_board_outline",
+ title: "Set board outline",
+ summary: `${args.shape} ${round(outline.widthMm, 3)} × ${round(outline.heightMm, 3)} mm`,
+ riskLevel: "medium",
+ actionId: args.action_id,
+ operations: [
+ { title: `Board outline: ${args.shape}`, payload: { type: "pcb_set_board_outline", outline } },
+ ],
+ });
+ },
+ };
+}
+
+interface RulesInput {
+ designId?: string;
+ action_id?: string;
+ clearanceMm?: Partial>;
+ netClasses?: Array<{
+ id?: string;
+ name: string;
+ traceWidthMm?: number;
+ clearanceMm?: number;
+ viaDiameterMm?: number;
+ viaDrillMm?: number;
+ }>;
+ netClassAssignments?: Array<{ net: string; netClass: string }>;
+ boardThicknessMm?: number;
+}
+
+function makeDesignRulesTool(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ return {
+ definition: {
+ name: "pcb_set_design_rules",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.rules",
+ description:
+ "Change board clearances, add or edit net classes (trace width, clearance, via size), assign nets to classes, or set board thickness. Only the fields you pass change; a class is matched by id if given, else by name. All sizes > 0 and via drill < via diameter. NOT undoable, so it always waits for the user's approval in OpenPCB (then call assistant_await_proposal). Use values the user or their fab specifies — never guess manufacturing limits. Class assignments are stored per current net id and must be redone if a net is renamed or re-created.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ action_id: ACTION_ID,
+ clearanceMm: {
+ type: "object",
+ properties: {
+ traceToTraceMm: { type: "number", minimum: 0 },
+ traceToPadMm: { type: "number", minimum: 0 },
+ padToPadMm: { type: "number", minimum: 0 },
+ traceToViaMm: { type: "number", minimum: 0 },
+ viaToViaMm: { type: "number", minimum: 0 },
+ copperToBoardEdgeMm: { type: "number", minimum: 0 },
+ },
+ },
+ netClasses: {
+ type: "array",
+ maxItems: 50,
+ items: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ name: { type: "string" },
+ traceWidthMm: { type: "number", minimum: 0 },
+ clearanceMm: { type: "number", minimum: 0 },
+ viaDiameterMm: { type: "number", minimum: 0 },
+ viaDrillMm: { type: "number", minimum: 0 },
+ },
+ required: ["name"],
+ },
+ },
+ netClassAssignments: {
+ type: "array",
+ maxItems: 500,
+ items: {
+ type: "object",
+ properties: { net: { type: "string" }, netClass: { type: "string" } },
+ required: ["net", "netClass"],
+ },
+ },
+ boardThicknessMm: { type: "number", minimum: 0 },
+ },
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = (input ?? {}) as RulesInput;
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const board = target.pcb.board;
+ const command: Extract = {
+ type: "pcb_set_design_rules",
+ };
+ const changes: string[] = [];
+ const problems: string[] = [];
+ if (args.clearanceMm && Object.keys(args.clearanceMm).length > 0) {
+ problems.push(...clearanceProblems(args.clearanceMm));
+ command.designRules = {
+ ...board.designRules,
+ clearance: { ...board.designRules.clearance, ...args.clearanceMm },
+ };
+ changes.push(`clearances (${Object.keys(args.clearanceMm).join(", ")})`);
+ }
+ let classes = board.netClasses;
+ if (args.netClasses?.length) {
+ const outcome = applyNetClassPatches(board.netClasses, args.netClasses);
+ problems.push(...outcome.problems);
+ classes = outcome.classes;
+ changes.push(...outcome.changes);
+ command.netClasses = classes;
+ }
+ if (args.netClassAssignments?.length) {
+ const assignments = { ...(board.perNetClassAssignments ?? {}) };
+ for (const { net, netClass } of args.netClassAssignments) {
+ const netId = netIdByName(target.pcb, net);
+ if (!netId) return failed(`No net named '${net}'.`, execCtx.limits);
+ const cls = classes.find(
+ (c) => c.id === netClass || c.name.toLowerCase() === netClass.toLowerCase(),
+ );
+ if (!cls) return failed(`No net class '${netClass}'.`, execCtx.limits);
+ assignments[netId] = cls.id;
+ }
+ command.perNetClassAssignments = assignments;
+ changes.push(`${args.netClassAssignments.length} net assignment(s)`);
+ }
+ if (args.boardThicknessMm !== undefined) {
+ if (!Number.isFinite(args.boardThicknessMm) || args.boardThicknessMm <= 0) {
+ problems.push("boardThicknessMm must be a number > 0");
+ }
+ command.boardThicknessMm = args.boardThicknessMm;
+ changes.push(`board thickness ${args.boardThicknessMm} mm`);
+ }
+ if (problems.length > 0) {
+ return failed(`Rule change refused: ${problems.join("; ")}.`, execCtx.limits);
+ }
+ if (changes.length === 0) return failed("No rule changes given.", execCtx.limits);
+ return propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ kind: "designer_pcb_rules_edits",
+ toolName: "pcb_set_design_rules",
+ title: "Change design rules",
+ summary: `Change ${changes.join("; ")} (not undoable)`,
+ riskLevel: "high",
+ actionId: args.action_id,
+ operations: [{ title: `Design rules: ${changes.join("; ")}`, payload: command }],
+ });
+ },
+ };
+}
+
+// ─── write: zones, keepouts, waivers ───────────────────────────────────
+
+/** Why `layer` is not usable on this board, or null. Checked against the REAL stack. */
+function layerProblem(pcb: DesignerPcbProjection, layer: string): string | null {
+ const valid = copperLayersForCount(pcb.board.layerCount) as readonly string[];
+ return valid.includes(layer)
+ ? null
+ : `Layer '${layer}' is not on this ${pcb.board.layerCount}-layer board (copper layers: ${valid.join(", ")}).`;
+}
+
+type ItemAction = "add" | "update" | "delete";
+
+const ZONE_DESCRIPTIONS: Record = {
+ add: "Add a copper zone (pour): a named net on one copper layer of this board, over the whole board (region 'board') or a polygon (region 'polygon' + pointsMm). Applies at once; undoable.",
+ update: "Change a copper zone by zoneId (from designer_get_pcb_layout): layer, net, region, name, priority, pad connection, enabled. Applies at once; undoable.",
+ delete: "Delete a copper zone by zoneId. Destructive: waits for the user's approval in OpenPCB (then assistant_await_proposal).",
+};
+
+function makeZoneTool(
+ action: ItemAction,
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ const toolName = `pcb_${action}_zone` as const;
+ const fields: Record =
+ action === "delete"
+ ? { zoneId: { type: "string" } }
+ : {
+ ...(action === "update" ? { zoneId: { type: "string" } } : {}),
+ layer: COPPER_LAYER,
+ net: { type: "string", description: "Net name to pour, e.g. GND." },
+ region: { type: "string", enum: ["board", "polygon"] },
+ pointsMm: { type: "array", items: POINT_MM, minItems: 3, maxItems: 500 },
+ name: { type: "string" },
+ priority: { type: "integer", minimum: 0 },
+ padConnection: { type: "string", enum: ["solid", "thermal", "thruHoleThermal", "none"] },
+ enabled: { type: "boolean" },
+ };
+ return {
+ definition: {
+ name: toolName,
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.zone",
+ description: ZONE_DESCRIPTIONS[action],
+ inputSchema: {
+ type: "object",
+ properties: { designId: DESIGN_ID, action_id: ACTION_ID, ...fields },
+ required: action === "add" ? ["layer", "net", "region"] : ["zoneId"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = input as {
+ designId?: string;
+ action_id?: string;
+ zoneId?: string;
+ layer?: string;
+ net?: string;
+ region?: "board" | "polygon";
+ pointsMm?: Array<{ x: number; y: number }>;
+ name?: string;
+ priority?: number;
+ padConnection?: PcbZonePadConnection;
+ enabled?: boolean;
+ };
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ if (args.layer) {
+ const problem = layerProblem(target.pcb, args.layer);
+ if (problem) return failed(problem, execCtx.limits);
+ }
+ const region =
+ args.region === "polygon"
+ ? args.pointsMm && args.pointsMm.length >= 3
+ ? { kind: "polygon" as const, pointsMm: args.pointsMm }
+ : null
+ : args.region === "board"
+ ? { kind: "board" as const }
+ : undefined;
+ if (region === null) return failed("A polygon zone needs at least 3 pointsMm.", execCtx.limits);
+ if (args.net && !netIdByName(target.pcb, args.net)) {
+ return failed(`No net named '${args.net}'.`, execCtx.limits);
+ }
+ // Named nets persist by name and re-bind on every projection (zone contract).
+ const netRef = args.net ? { netId: null, netName: args.net.trim().toUpperCase() } : undefined;
+ const exists = Boolean(args.zoneId && target.pcb.zones.some((z) => z.id === args.zoneId));
+
+ let payload: DesignerCommandEnvelope["command"];
+ if (action === "add") {
+ if (!args.layer || !netRef || !region) {
+ return failed("Adding a zone needs layer, net and region.", execCtx.limits);
+ }
+ payload = {
+ type: "pcb_add_zone",
+ layer: args.layer as PcbCopperLayerId,
+ net: netRef,
+ region,
+ ...(args.name !== undefined ? { name: args.name } : {}),
+ ...(args.priority !== undefined ? { priority: args.priority } : {}),
+ ...(args.padConnection ? { padConnection: args.padConnection } : {}),
+ ...(args.enabled !== undefined ? { enabled: args.enabled } : {}),
+ };
+ } else if (action === "update") {
+ if (!exists) return failed(`No zone '${args.zoneId ?? ""}'.`, execCtx.limits);
+ payload = {
+ type: "pcb_update_zone",
+ zoneId: args.zoneId!,
+ ...(args.layer ? { layer: args.layer as PcbCopperLayerId } : {}),
+ ...(netRef ? { net: netRef } : {}),
+ ...(region ? { region } : {}),
+ ...(args.name !== undefined ? { name: args.name } : {}),
+ ...(args.priority !== undefined ? { priority: args.priority } : {}),
+ ...(args.padConnection ? { padConnection: args.padConnection } : {}),
+ ...(args.enabled !== undefined ? { enabled: args.enabled } : {}),
+ };
+ } else {
+ if (!exists) return failed(`No zone '${args.zoneId ?? ""}'.`, execCtx.limits);
+ payload = { type: "pcb_delete_zone", zoneId: args.zoneId! };
+ }
+ const destructive = action === "delete";
+ return propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ kind: destructive ? "designer_pcb_deletions" : "designer_pcb_board_edits",
+ toolName,
+ title: `${action} zone`,
+ summary: `${action} zone${args.net ? ` for ${args.net}` : ""}`,
+ riskLevel: destructive ? "destructive" : "medium",
+ actionId: args.action_id,
+ operations: [{ title: `${action} zone`, payload }],
+ });
+ },
+ };
+}
+
+const KEEPOUT_DESCRIPTIONS: Record = {
+ add: "Add a keepout area: a polygon in mm on copper layers of this board that forbids tracks / vias / pads / copper pour / footprints. Applies at once; undoable.",
+ update: "Change a keepout by keepoutId (from designer_get_pcb_layout): layers, polygon, what it forbids, name, enabled. Applies at once; undoable.",
+ delete: "Delete a keepout by keepoutId. Destructive: waits for the user's approval in OpenPCB (then assistant_await_proposal).",
+};
+
+function makeKeepoutTool(
+ action: ItemAction,
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ const toolName = `pcb_${action}_keepout` as const;
+ const fields: Record =
+ action === "delete"
+ ? { keepoutId: { type: "string" } }
+ : {
+ ...(action === "update" ? { keepoutId: { type: "string" } } : {}),
+ layers: { type: "array", items: COPPER_LAYER, minItems: 1 },
+ pointsMm: { type: "array", items: POINT_MM, minItems: 3, maxItems: 500 },
+ forbid: {
+ type: "array",
+ items: { type: "string", enum: ["tracks", "vias", "pads", "copperPour", "footprints"] },
+ },
+ name: { type: "string" },
+ enabled: { type: "boolean" },
+ };
+ return {
+ definition: {
+ name: toolName,
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.keepout",
+ description: KEEPOUT_DESCRIPTIONS[action],
+ inputSchema: {
+ type: "object",
+ properties: { designId: DESIGN_ID, action_id: ACTION_ID, ...fields },
+ required: action === "add" ? ["layers", "pointsMm", "forbid"] : ["keepoutId"],
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = input as {
+ designId?: string;
+ action_id?: string;
+ keepoutId?: string;
+ layers?: string[];
+ pointsMm?: Array<{ x: number; y: number }>;
+ forbid?: Array;
+ name?: string;
+ enabled?: boolean;
+ };
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ for (const layer of args.layers ?? []) {
+ const problem = layerProblem(target.pcb, layer);
+ if (problem) return failed(problem, execCtx.limits);
+ }
+ const restrictions = args.forbid
+ ? {
+ tracks: args.forbid.includes("tracks"),
+ vias: args.forbid.includes("vias"),
+ pads: args.forbid.includes("pads"),
+ copperPour: args.forbid.includes("copperPour"),
+ footprints: args.forbid.includes("footprints"),
+ }
+ : undefined;
+ const exists = Boolean(args.keepoutId && target.pcb.keepouts.some((k) => k.id === args.keepoutId));
+ let payload: DesignerCommandEnvelope["command"];
+ if (action === "add") {
+ if (!args.layers?.length || !args.pointsMm || !restrictions) {
+ return failed("Adding a keepout needs layers, pointsMm and forbid.", execCtx.limits);
+ }
+ payload = {
+ type: "pcb_add_keepout",
+ layers: args.layers as PcbCopperLayerId[],
+ pointsMm: args.pointsMm,
+ restrictions,
+ ...(args.name !== undefined ? { name: args.name } : {}),
+ ...(args.enabled !== undefined ? { enabled: args.enabled } : {}),
+ };
+ } else if (action === "update") {
+ if (!exists) return failed(`No keepout '${args.keepoutId ?? ""}'.`, execCtx.limits);
+ payload = {
+ type: "pcb_update_keepout",
+ keepoutId: args.keepoutId!,
+ ...(args.layers ? { layers: args.layers as PcbCopperLayerId[] } : {}),
+ ...(args.pointsMm ? { pointsMm: args.pointsMm } : {}),
+ ...(restrictions ? { restrictions } : {}),
+ ...(args.name !== undefined ? { name: args.name } : {}),
+ ...(args.enabled !== undefined ? { enabled: args.enabled } : {}),
+ };
+ } else {
+ if (!exists) return failed(`No keepout '${args.keepoutId ?? ""}'.`, execCtx.limits);
+ payload = { type: "pcb_delete_keepout", keepoutId: args.keepoutId! };
+ }
+ const destructive = action === "delete";
+ return propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ kind: destructive ? "designer_pcb_deletions" : "designer_pcb_board_edits",
+ toolName,
+ title: `${action} keepout`,
+ summary: `${action} keepout`,
+ riskLevel: destructive ? "destructive" : "medium",
+ actionId: args.action_id,
+ operations: [{ title: `${action} keepout`, payload }],
+ });
+ },
+ };
+}
+
+const REASON: AiJsonSchemaObject = {
+ type: "string",
+ minLength: 3,
+ maxLength: 500,
+ description:
+ "Why, in the user's words — shown on the approval card. Required when waiving or ignoring.",
+};
+
+/**
+ * Waive individual violations. A waiver acknowledges ONE known violation (it
+ * stays listed with `waived: true` and leaves the error counts), so it is a
+ * verification decision the user makes: waiving always waits for approval in
+ * the panel. Only ids from the current report can be waived, never a
+ * safety-critical code (NON_OVERRIDABLE — shorts, layer-invalid items…),
+ * which DRC would ignore anyway. Un-waiving only restores checking, so it
+ * applies at once.
+ */
+function makeWaiveViolationsTool(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ return {
+ definition: {
+ name: "pcb_waive_drc_violations",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.drc",
+ description:
+ "Waive (or un-waive) individual DRC violations by id from designer_run_drc. A waived violation stays listed but no longer counts. Waiving ALWAYS waits for the user's approval in OpenPCB (then assistant_await_proposal) and needs a reason; only do it when the user asked. Un-waiving applies at once. Safety-critical codes (shorts, layer-invalid items) cannot be waived.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ action_id: ACTION_ID,
+ waive: { type: "array", items: { type: "string" }, maxItems: 200 },
+ unwaive: { type: "array", items: { type: "string" }, maxItems: 500 },
+ reason: REASON,
+ },
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = (input ?? {}) as {
+ designId?: string;
+ action_id?: string;
+ waive?: string[];
+ unwaive?: string[];
+ reason?: string;
+ };
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const current = new Set(target.pcb.board.viewState?.drcWaivedViolationIds ?? []);
+ const toWaive = [...new Set(args.waive ?? [])].filter((id) => !current.has(id));
+ const toUnwaive = [...new Set(args.unwaive ?? [])].filter((id) => current.has(id));
+ if (toWaive.length === 0 && toUnwaive.length === 0) {
+ return failed(
+ "Nothing to change: every id to waive is already waived and every id to un-waive is not.",
+ execCtx.limits,
+ );
+ }
+ const reason = args.reason?.trim() ?? "";
+ const waiveTitles: string[] = [];
+ if (toWaive.length > 0) {
+ if (reason.length < 3) {
+ return failed("Waiving needs a reason (what the user accepted and why).", execCtx.limits);
+ }
+ const report = await target.designer.runDrc(target.designId);
+ const byId = new Map((report?.violations ?? []).map((v) => [v.id, v]));
+ const unknown = toWaive.filter((id) => !byId.has(id));
+ if (unknown.length > 0) {
+ return failed(
+ `Not in the current DRC report (re-run designer_run_drc; ids change when the geometry does): ${unknown.slice(0, 10).join(", ")}${unknown.length > 10 ? ", …" : ""}.`,
+ execCtx.limits,
+ );
+ }
+ const critical = toWaive.filter((id) => NON_OVERRIDABLE.has(byId.get(id)!.code));
+ if (critical.length > 0) {
+ return failed(
+ `These are safety-critical and can never be waived — fix them instead: ${critical
+ .map((id) => `${id} (${byId.get(id)!.code})`)
+ .join(", ")}.`,
+ execCtx.limits,
+ );
+ }
+ for (const id of toWaive) {
+ const v = byId.get(id)!;
+ waiveTitles.push(`Waive ${v.code}: ${v.message.slice(0, 120)}`);
+ }
+ }
+ const next = new Set(current);
+ for (const id of toWaive) next.add(id);
+ for (const id of toUnwaive) next.delete(id);
+ const waiving = toWaive.length > 0;
+ return propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ // Waiving suppresses verification: approval-tier. Un-waiving only.
+ kind: waiving ? "designer_pcb_drc_waivers" : "designer_pcb_board_edits",
+ toolName: "pcb_waive_drc_violations",
+ title: waiving
+ ? `Waive ${toWaive.length} DRC violation(s)`
+ : `Un-waive ${toUnwaive.length} DRC violation(s)`,
+ summary: waiving
+ ? `Waive ${toWaive.length} violation(s)${toUnwaive.length ? `, un-waive ${toUnwaive.length}` : ""}. Reason: ${reason}`
+ : `Restore checking of ${toUnwaive.length} waived violation(s).`,
+ riskLevel: waiving ? "high" : "medium",
+ actionId: args.action_id,
+ operations: [
+ {
+ title: waiving ? waiveTitles.join("; ").slice(0, 400) : "Un-waive DRC violations",
+ payload: {
+ type: "pcb_set_view_state",
+ patch: { drcWaivedViolationIds: [...next] },
+ },
+ },
+ ],
+ });
+ },
+ };
+}
+
+/**
+ * Ignore whole DRC rule classes. Unlike a waiver this HIDES every current and
+ * future violation of the class from the report — it changes what "DRC
+ * passes" means — so ignoring always needs a fresh approval, never covered by
+ * a session allowance (NEVER_SESSION_ALLOWED_KINDS). Un-ignoring only
+ * restores checking and applies at once.
+ */
+function makeRuleClassIgnoresTool(
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): AiTool {
+ const classes = [...DRC_RULE_CLASSES];
+ return {
+ definition: {
+ name: "pcb_set_drc_rule_class_ignores",
+ version: "1",
+ effect: "write",
+ capability: "designer.write.pcb.drc",
+ description:
+ "Ignore (or stop ignoring) whole DRC rule classes. Ignored classes vanish from designer_run_drc (counted as hidden). Ignoring ALWAYS waits for the user's approval in OpenPCB (then assistant_await_proposal) and needs a reason; only do it when the user explicitly asked. Un-ignoring applies at once.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ designId: DESIGN_ID,
+ action_id: ACTION_ID,
+ ignore: { type: "array", items: { type: "string", enum: classes }, maxItems: classes.length },
+ unignore: { type: "array", items: { type: "string", enum: classes }, maxItems: classes.length },
+ reason: REASON,
+ },
+ },
+ },
+ async execute(execCtx, input): Promise> {
+ const args = (input ?? {}) as {
+ designId?: string;
+ action_id?: string;
+ ignore?: DrcRuleClass[];
+ unignore?: DrcRuleClass[];
+ reason?: string;
+ };
+ const target = await loadTarget(ctx, contextResolver, execCtx, args.designId);
+ if (typeof target === "string") return failed(target, execCtx.limits);
+ const current = new Set(target.pcb.board.viewState?.drcIgnoredRuleClasses ?? []);
+ const toIgnore = [...new Set(args.ignore ?? [])].filter((c) => !current.has(c));
+ const toUnignore = [...new Set(args.unignore ?? [])].filter((c) => current.has(c));
+ if (toIgnore.length === 0 && toUnignore.length === 0) {
+ return failed("Nothing to change.", execCtx.limits);
+ }
+ const reason = args.reason?.trim() ?? "";
+ let hides = "";
+ if (toIgnore.length > 0) {
+ if (reason.length < 3) {
+ return failed("Ignoring a rule class needs a reason (what the user accepted and why).", execCtx.limits);
+ }
+ const report = await target.designer.runDrc(target.designId);
+ const affected = (report?.violations ?? []).filter(
+ (v) => toIgnore.includes(v.ruleClass) && !NON_OVERRIDABLE.has(v.code),
+ ).length;
+ hides = ` It would hide ${affected} current violation(s) and every future one of these classes.`;
+ }
+ const next = new Set(current);
+ for (const c of toIgnore) next.add(c);
+ for (const c of toUnignore) next.delete(c);
+ const ignoring = toIgnore.length > 0;
+ return propose({
+ conversation,
+ options,
+ execCtx,
+ target,
+ kind: ignoring ? "designer_pcb_drc_rule_ignores" : "designer_pcb_board_edits",
+ toolName: "pcb_set_drc_rule_class_ignores",
+ title: ignoring
+ ? `Ignore DRC rule class(es): ${toIgnore.join(", ")}`
+ : `Check DRC rule class(es) again: ${toUnignore.join(", ")}`,
+ summary: ignoring
+ ? `Ignore ${toIgnore.join(", ")}.${hides} Reason: ${reason}`
+ : `Restore checking of ${toUnignore.join(", ")}.`,
+ riskLevel: ignoring ? "high" : "medium",
+ actionId: args.action_id,
+ operations: [
+ {
+ title: ignoring ? `Ignore ${toIgnore.join(", ")}` : `Un-ignore ${toUnignore.join(", ")}`,
+ payload: {
+ type: "pcb_set_view_state",
+ patch: { drcIgnoredRuleClasses: [...next] },
+ },
+ },
+ ],
+ });
+ },
+ };
+}
+
+// ─── registration ──────────────────────────────────────────────────────
+
+/** Proposal kinds that always wait for the user, whatever their risk level. */
+export const APPROVAL_REQUIRED_KINDS: ReadonlySet = new Set([
+ "designer_pcb_rules_edits",
+ "designer_pcb_drc_waivers",
+ "designer_pcb_drc_rule_ignores",
+ "designer_design_delete",
+]);
+
+/**
+ * Approval kinds a session allowance ("allow this tool this session") never
+ * covers: every one is a fresh decision. Deleting a design is irreversible;
+ * ignoring a rule class changes what "DRC passes" means for the whole board.
+ */
+export const NEVER_SESSION_ALLOWED_KINDS: ReadonlySet = new Set([
+ "designer_pcb_drc_rule_ignores",
+ "designer_design_delete",
+]);
+
+export function registerMcpPcbTools(
+ registry: AiToolRegistry,
+ ctx: CoreBackendModuleContext,
+ contextResolver: ContextResolver,
+ conversation: ConversationStore,
+ options: DesignerToolOptions,
+): void {
+ registry.register(makeGetLayoutTool(ctx, contextResolver));
+ registry.register(makePlaceTool(ctx, contextResolver, conversation, options));
+ registry.register(makeRouteTool(ctx, contextResolver, conversation, options));
+ registry.register(makeDeleteRoutingTool(ctx, contextResolver, conversation, options));
+ registry.register(makeBoardOutlineTool(ctx, contextResolver, conversation, options));
+ registry.register(makeDesignRulesTool(ctx, contextResolver, conversation, options));
+ for (const action of ["add", "update", "delete"] as const) {
+ registry.register(makeZoneTool(action, ctx, contextResolver, conversation, options));
+ registry.register(makeKeepoutTool(action, ctx, contextResolver, conversation, options));
+ }
+ registry.register(makeWaiveViolationsTool(ctx, contextResolver, conversation, options));
+ registry.register(makeRuleClassIgnoresTool(ctx, contextResolver, conversation, options));
+}
diff --git a/src/modules/assistant/backend/tools/read-tools.ts b/src/modules/assistant/backend/tools/read-tools.ts
index 3bde01de..d17d5122 100644
--- a/src/modules/assistant/backend/tools/read-tools.ts
+++ b/src/modules/assistant/backend/tools/read-tools.ts
@@ -6,6 +6,7 @@ import type {
} from "@openpcb/ai-core";
import type { CoreBackendModuleContext } from "../../../../core/contracts/modules/backend-module";
import { MODULE_SDK_TOKENS, type DesignerSDK } from "../../../../sdks";
+import { drcCounts, drcCountsLine } from "./drc-counts";
/**
* Read tools that exist for MCP clients only.
@@ -24,11 +25,28 @@ import { MODULE_SDK_TOKENS, type DesignerSDK } from "../../../../sdks";
const NO_DESIGNER: Omit, "limits"> = {
ok: false,
data: null,
+ summary: "Designer module is not available.",
sources: [],
warnings: ["Designer module is not available."],
truncated: false,
};
+/** A failed read whose one-line summary is the reason — never a bare `null`. */
+function failedRead(
+ message: string,
+ limits: AiToolResult["limits"],
+): AiToolResult {
+ return {
+ ok: false,
+ data: null,
+ summary: message,
+ sources: [],
+ warnings: [message],
+ truncated: false,
+ limits,
+ };
+}
+
function designerOf(ctx: CoreBackendModuleContext): DesignerSDK | undefined {
return ctx.sdk.get(MODULE_SDK_TOKENS.DESIGNER) ?? undefined;
}
@@ -118,14 +136,7 @@ function makeGetPcbStateTool(ctx: CoreBackendModuleContext): AiTool {
if (!designer) return { ...NO_DESIGNER, limits: execCtx.limits };
const pcb = designId ? await designer.getPcbProjection(designId) : null;
if (!pcb) {
- return {
- ok: false,
- data: null,
- sources: [],
- warnings: [missingDesign(designId)],
- truncated: false,
- limits: execCtx.limits,
- };
+ return failedRead(missingDesign(designId), execCtx.limits);
}
const board = pcb.board;
const state = {
@@ -157,6 +168,12 @@ function makeGetPcbStateTool(ctx: CoreBackendModuleContext): AiTool {
id: nc.id,
name: nc.name,
})),
+ // What DRC is told to look away from — surfaced so an agent can say
+ // so instead of presenting a filtered report as the whole truth.
+ drcSuppression: {
+ waivedViolations: board.viewState?.drcWaivedViolationIds?.length ?? 0,
+ ignoredRuleClasses: board.viewState?.drcIgnoredRuleClasses ?? [],
+ },
warnings: pcb.warnings,
};
return {
@@ -190,14 +207,7 @@ function makeRunErcTool(ctx: CoreBackendModuleContext): AiTool {
if (!designer) return { ...NO_DESIGNER, limits: execCtx.limits };
const report = designId ? await designer.runErc(designId) : null;
if (!report) {
- return {
- ok: false,
- data: null,
- sources: [],
- warnings: [missingDesign(designId)],
- truncated: false,
- limits: execCtx.limits,
- };
+ return failedRead(missingDesign(designId), execCtx.limits);
}
return {
ok: true,
@@ -221,7 +231,7 @@ function makeRunDrcTool(ctx: CoreBackendModuleContext): AiTool {
effect: "read",
capability: "designer.read.drc",
description:
- "Run Design Rule Check over the PCB and return the violations (clearance, width, annular ring, unrouted nets). OpenPCB stays the authoritative DRC engine — always re-run this after applying layout changes rather than trusting an external calculation.",
+ "Run Design Rule Check over the PCB and return the violations (clearance, width, annular ring, unrouted nets). `counts` splits active, waived and hidden (ignored rule classes / severity overrides) violations — report all of them; the board is not clean while any are suppressed. OpenPCB stays the authoritative DRC engine — always re-run this after applying layout changes rather than trusting an external calculation.",
inputSchema: DESIGN_INPUT_SCHEMA,
},
async execute(execCtx, input): Promise> {
@@ -230,20 +240,15 @@ function makeRunDrcTool(ctx: CoreBackendModuleContext): AiTool {
if (!designer) return { ...NO_DESIGNER, limits: execCtx.limits };
const report = designId ? await designer.runDrc(designId) : null;
if (!report) {
- return {
- ok: false,
- data: null,
- sources: [],
- warnings: [missingDesign(designId)],
- truncated: false,
- limits: execCtx.limits,
- };
+ return failedRead(missingDesign(designId), execCtx.limits);
}
+ const counts = drcCounts(report);
+ const data = { ...report, counts };
return {
ok: true,
- data: report,
- modelData: report,
- summary: `DRC: ${report.violations.length} violation(s).`,
+ data,
+ modelData: data,
+ summary: drcCountsLine(counts),
sources: [],
warnings: [],
truncated: false,
@@ -270,14 +275,7 @@ function makeGetBomTool(ctx: CoreBackendModuleContext): AiTool {
if (!designer) return { ...NO_DESIGNER, limits: execCtx.limits };
const bom = designId ? await designer.getBomProjection(designId) : null;
if (!bom) {
- return {
- ok: false,
- data: null,
- sources: [],
- warnings: [missingDesign(designId)],
- truncated: false,
- limits: execCtx.limits,
- };
+ return failedRead(missingDesign(designId), execCtx.limits);
}
return {
ok: true,
@@ -301,7 +299,7 @@ function makeExportManufacturingTool(ctx: CoreBackendModuleContext): AiTool {
effect: "read",
capability: "designer.read.export",
description:
- "Generate the manufacturing bundle (Gerbers, Excellon drills, optional BOM and pick-and-place) and return its manifest: bundle name, per-file names and byte sizes, and any preflight warnings. File contents are NOT returned — read the openpcb://design/{id}/export/gerber resource for the ZIP.",
+ "Generate the manufacturing bundle (Gerbers, Excellon drills, optional BOM and pick-and-place) and return its manifest: bundle name, per-file names and byte sizes, and any preflight warnings. File contents are NOT returned — this checks exportability and lists what the bundle would contain; the user exports the files from the PCB toolbar's 'Export manufacturing files' button.",
inputSchema: {
type: "object",
properties: {
@@ -337,24 +335,10 @@ function makeExportManufacturingTool(ctx: CoreBackendModuleContext): AiTool {
// about the design, not a tool failure.
const refusal = exportRefusalMessage(error);
if (refusal === null) throw error;
- return {
- ok: false,
- data: null,
- sources: [],
- warnings: [refusal],
- truncated: false,
- limits: execCtx.limits,
- };
+ return failedRead(refusal, execCtx.limits);
}
if (!summary) {
- return {
- ok: false,
- data: null,
- sources: [],
- warnings: [missingDesign(args.designId)],
- truncated: false,
- limits: execCtx.limits,
- };
+ return failedRead(missingDesign(args.designId), execCtx.limits);
}
return {
ok: true,
diff --git a/src/modules/assistant/backend/tools/rules-validation.ts b/src/modules/assistant/backend/tools/rules-validation.ts
new file mode 100644
index 00000000..072620ef
--- /dev/null
+++ b/src/modules/assistant/backend/tools/rules-validation.ts
@@ -0,0 +1,159 @@
+import type { PcbNetClass } from "../../../../sdks";
+
+/**
+ * Validation for agent-proposed design-rule changes (`pcb_set_design_rules`).
+ *
+ * The designer store parses rules leniently (any finite number, duplicate ids
+ * kept) and MCP writes never pass through the HTTP route parsers, so an agent
+ * could otherwise stage a zero-width class, a via whose drill is wider than
+ * its pad, or a second class that silently shares an id with an existing one.
+ * These checks are physical sanity, not manufacturing limits: they never
+ * invent a minimum — anything > 0 that is geometrically coherent passes, and
+ * fab capability stays a DRC matter.
+ */
+
+export interface NetClassPatch {
+ id?: string;
+ name: string;
+ traceWidthMm?: number;
+ clearanceMm?: number;
+ viaDiameterMm?: number;
+ viaDrillMm?: number;
+}
+
+const DIMENSIONS = ["traceWidthMm", "clearanceMm", "viaDiameterMm", "viaDrillMm"] as const;
+
+export function normalizeClassName(name: string): string {
+ return name.trim().toLowerCase();
+}
+
+export function slugId(name: string): string {
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "class";
+}
+
+/** `base`, else `base-2`, `base-3`… — never an id already taken. */
+export function uniqueId(base: string, taken: ReadonlySet): string {
+ if (!taken.has(base)) return base;
+ for (let n = 2; ; n += 1) {
+ const candidate = `${base}-${n}`;
+ if (!taken.has(candidate)) return candidate;
+ }
+}
+
+function positive(value: unknown): boolean {
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
+}
+
+/** Problems with one class's geometry (empty when coherent). */
+export function netClassProblems(netClass: Pick): string[] {
+ const problems: string[] = [];
+ const label = netClass.name.trim() || "(unnamed)";
+ if (!netClass.name.trim()) problems.push("a net class name must not be empty");
+ for (const key of DIMENSIONS) {
+ if (!positive(netClass[key])) problems.push(`${label}: ${key} must be a number > 0`);
+ }
+ if (
+ positive(netClass.viaDrillMm) &&
+ positive(netClass.viaDiameterMm) &&
+ netClass.viaDrillMm >= netClass.viaDiameterMm
+ ) {
+ problems.push(
+ `${label}: viaDrillMm (${netClass.viaDrillMm}) must be smaller than viaDiameterMm (${netClass.viaDiameterMm})`,
+ );
+ }
+ return problems;
+}
+
+export interface NetClassPatchOutcome {
+ classes: PcbNetClass[];
+ changes: string[];
+ problems: string[];
+}
+
+/**
+ * Apply patches to the board's classes. Matching: by `id` when one is given,
+ * else by name (trimmed, case-insensitive). A new class needs all four
+ * dimensions, gets a unique id (an explicit id that collides is refused), and
+ * copies only presentation fields (color, default via protection) from the
+ * first class — never electrical metadata (voltage, current, pair gap) that
+ * belongs to another class. Every class a patch touched is validated; ids and
+ * names must be unique across the result.
+ */
+export function applyNetClassPatches(
+ existing: readonly PcbNetClass[],
+ patches: readonly NetClassPatch[],
+): NetClassPatchOutcome {
+ const problems: string[] = [];
+ const changes: string[] = [];
+ const classes = existing.map((c) => ({ ...c }));
+ const template = existing[0];
+ const touched = new Set();
+
+ for (const patch of patches) {
+ const name = patch.name?.trim() ?? "";
+ const index = patch.id
+ ? classes.findIndex((c) => c.id === patch.id)
+ : classes.findIndex((c) => normalizeClassName(c.name) === normalizeClassName(name));
+ if (index >= 0) {
+ if (touched.has(index)) {
+ problems.push(`net class '${classes[index]!.name}' is changed twice in one call`);
+ continue;
+ }
+ touched.add(index);
+ const next = { ...classes[index]! };
+ if (name) next.name = name;
+ for (const key of DIMENSIONS) {
+ if (patch[key] !== undefined) next[key] = patch[key]!;
+ }
+ classes[index] = next;
+ changes.push(`net class ${next.name}`);
+ continue;
+ }
+ if (!template) {
+ problems.push("the board has no net classes to extend");
+ continue;
+ }
+ const missing = DIMENSIONS.filter((k) => patch[k] === undefined);
+ if (missing.length > 0) {
+ problems.push(`new net class '${name || patch.id}' needs ${missing.join(", ")} — give the values explicitly`);
+ continue;
+ }
+ const taken = new Set(classes.map((c) => c.id));
+ if (patch.id && taken.has(patch.id)) {
+ problems.push(`net class id '${patch.id}' is already used`);
+ continue;
+ }
+ const created: PcbNetClass = {
+ id: patch.id ?? uniqueId(slugId(name), taken),
+ name,
+ traceWidthMm: patch.traceWidthMm!,
+ clearanceMm: patch.clearanceMm!,
+ viaDiameterMm: patch.viaDiameterMm!,
+ viaDrillMm: patch.viaDrillMm!,
+ color: template.color,
+ defaultViaProtection: template.defaultViaProtection,
+ };
+ classes.push(created);
+ touched.add(classes.length - 1);
+ changes.push(`new net class ${name}`);
+ }
+
+ for (const index of touched) problems.push(...netClassProblems(classes[index]!));
+ const ids = new Set();
+ const names = new Map();
+ for (const c of classes) {
+ if (ids.has(c.id)) problems.push(`net class id '${c.id}' is used twice`);
+ ids.add(c.id);
+ const key = normalizeClassName(c.name);
+ if (names.has(key)) problems.push(`net class name '${c.name}' is used twice`);
+ names.set(key, c.id);
+ }
+ return { classes, changes, problems };
+}
+
+/** Clearance values must be finite and > 0 (a zero clearance is a short). */
+export function clearanceProblems(clearance: Record): string[] {
+ return Object.entries(clearance)
+ .filter(([, value]) => value !== undefined && !positive(value))
+ .map(([key]) => `clearance ${key} must be a number > 0`);
+}
diff --git a/src/modules/assistant/backend/verification/build-intent-capture.ts b/src/modules/assistant/backend/verification/build-intent-capture.ts
new file mode 100644
index 00000000..ce4c2132
--- /dev/null
+++ b/src/modules/assistant/backend/verification/build-intent-capture.ts
@@ -0,0 +1,145 @@
+/**
+ * BuildIntent capture: turn a `library_resolve_bom` or `compile_circuit`
+ * result into the expected BOM + required nets the Definition-of-Done verifier
+ * (`run-dod.ts`) checks a finished design against.
+ *
+ * Pure and shared: the in-app run loop captures after those tool calls
+ * (`run-service.ts`), and so does the MCP projection, so `designer_verify_build`
+ * gives an external agent the same verification the in-app assistant runs.
+ */
+
+import type { BuildIntentItem } from "./types";
+
+export interface CapturedIntent {
+ goal: string;
+ items: BuildIntentItem[];
+}
+
+/** Minimal shape of the library_resolve_bom result we read for BuildIntent. */
+interface BomResultShape {
+ goal?: unknown;
+ items?: Array<{
+ role?: unknown;
+ quantity?: number;
+ value?: unknown;
+ selected?: { componentId: string } | null;
+ }>;
+}
+
+/** Minimal shape of the compile_circuit result we read for BuildIntent. */
+interface CompileResultShape {
+ placedCount?: number;
+ bom?: Array<{
+ role?: unknown;
+ componentId?: string;
+ quantity?: number;
+ value?: unknown;
+ }>;
+}
+
+/**
+ * Canonical power-rail net name for a single voltage token. Keeps distinct rails
+ * distinct: +5V → "+5V", 3V3/3.3V → "+3V3", 12V → "+12V". Returns null for tokens
+ * that are not a recognisable rail. F7a: do NOT collapse every rail to "VCC" —
+ * a multi-rail build (e.g. +5V and +3V3) must keep them separate so the DoD
+ * `nets_wired` check is meaningful.
+ */
+function railNetName(token: string): string | null {
+ // Accept 5V, +5V, 3.3V, 3V3, 1V8, 12V. `whole` digits, optional fractional
+ // digits separated by "." or "v" (either before or after the trailing V).
+ const m = /^[+]?(\d+)(?:\.(\d+)v|v(\d+)|v)$/i.exec(token.replace(/\s+/g, ""));
+ if (!m) return null;
+ const whole = m[1]!;
+ const frac = m[2] ?? m[3];
+ return frac ? `+${whole}V${frac}` : `+${whole}V`;
+}
+
+/**
+ * Deterministically derive the nets a BOM item is expected to participate in
+ * from its role keyword plus any explicit voltage in its value/role text. Used by
+ * the DoD `nets_wired` check. Conservative: only power/ground rails are inferred,
+ * since those are the connections a build is most likely to leave dangling.
+ *
+ * F7a: explicit rails keep their REAL names (+5V, +3V3, +12V); only a bare,
+ * voltage-less power role falls back to the generic "VCC".
+ */
+export function requiredNetsForItem(
+ role: string,
+ value: string | undefined,
+): string[] {
+ const r = role.toLowerCase();
+ const nets = new Set();
+ if (/(gnd|ground|return)/.test(r)) nets.add("GND");
+ const isPower = /(vcc|vdd|\+?\d+v|3\.3v|power|supply|rail)/.test(r);
+ if (isPower) {
+ // Pull explicit rail tokens out of the role text and the item value.
+ const haystack = `${role} ${value ?? ""}`;
+ const tokens = haystack.match(/[+]?\d+(?:\.\d+v|v\d+|v)\b/gi) ?? [];
+ let added = false;
+ for (const token of tokens) {
+ const rail = railNetName(token);
+ if (rail) {
+ nets.add(rail);
+ added = true;
+ }
+ }
+ if (!added) nets.add("VCC");
+ }
+ return [...nets];
+}
+
+
+function toIntentItem(item: {
+ role?: unknown;
+ componentId: string;
+ quantity?: number;
+ value?: unknown;
+}): BuildIntentItem {
+ const role = typeof item.role === "string" ? item.role : "";
+ const value = typeof item.value === "string" ? item.value : undefined;
+ return {
+ role: role || "part",
+ componentId: item.componentId,
+ quantity:
+ Number.isFinite(item.quantity) && (item.quantity ?? 0) > 0
+ ? Math.floor(item.quantity!)
+ : 1,
+ value,
+ requiredNets: requiredNetsForItem(role, value),
+ };
+}
+
+/** Intent from a `library_resolve_bom` result (its full `data` JSON). */
+export function intentFromBomResult(resultJson: string): CapturedIntent | null {
+ let parsed: BomResultShape;
+ try {
+ parsed = JSON.parse(resultJson) as BomResultShape;
+ } catch {
+ return null;
+ }
+ const items = (Array.isArray(parsed?.items) ? parsed.items : [])
+ .filter((item) => item.selected?.componentId)
+ .map((item) => toIntentItem({ ...item, componentId: item.selected!.componentId }));
+ if (items.length === 0) return null;
+ return { goal: typeof parsed.goal === "string" ? parsed.goal : "", items };
+}
+
+/**
+ * Intent from a `compile_circuit` result. Only a compile that actually placed
+ * parts is worth verifying — an unresolved compile placed nothing and already
+ * told the model to fix the IR.
+ */
+export function intentFromCompileResult(resultJson: string): CapturedIntent | null {
+ let parsed: CompileResultShape;
+ try {
+ parsed = JSON.parse(resultJson) as CompileResultShape;
+ } catch {
+ return null;
+ }
+ if (!parsed || (parsed.placedCount ?? 0) <= 0) return null;
+ const items = (Array.isArray(parsed.bom) ? parsed.bom : [])
+ .filter((item) => typeof item.componentId === "string" && item.componentId)
+ .map((item) => toIntentItem({ ...item, componentId: item.componentId! }));
+ if (items.length === 0) return null;
+ return { goal: "", items };
+}
diff --git a/src/modules/assistant/frontend/DesignerChatDock.tsx b/src/modules/assistant/frontend/DesignerChatDock.tsx
index 4a61d5fb..9846f109 100644
--- a/src/modules/assistant/frontend/DesignerChatDock.tsx
+++ b/src/modules/assistant/frontend/DesignerChatDock.tsx
@@ -53,6 +53,7 @@ import type {
ActiveRunState,
ActiveRunStatus,
} from "./components/AssistantRunStatusCard";
+import { useAssistantEvents } from "./hooks/useAssistantEvents";
import { useAssistantStream } from "./hooks/useAssistantStream";
import { isNearBottom, useScrollAnchor } from "./hooks/useScrollAnchor";
@@ -761,6 +762,49 @@ export function DesignerChatDock({
[assistantBase, chats, refreshDesignChats, selectedChatId],
);
+ // Live updates for changes made outside this dock's own runs — above all an
+ // MCP client (Claude Code) working on this design: its activity lands in an
+ // "MCP · …" chat bound to the design, and deletions it proposes wait there
+ // for approval. Refetch the chat list and the open chat; surface a pending
+ // approval in a chat the user is not looking at.
+ const [approvalChatId, setApprovalChatId] = useState(null);
+ useAssistantEvents({
+ backendUrl: backendURL,
+ enabled: Boolean(designId),
+ onEvents: (events) => {
+ const relevant = events.filter(
+ (event) =>
+ event.designId === designId ||
+ chats.some((chat) => chat.id === event.chatId),
+ );
+ if (relevant.length === 0) return;
+ void refreshDesignChats().catch(() => undefined);
+ const active = activeChatIdRef.current;
+ if (
+ active &&
+ !activeRunsByChat[active] &&
+ relevant.some((event) => event.chatId === active)
+ ) {
+ void refreshMessages(active).catch(() => undefined);
+ }
+ const waiting = relevant.find(
+ (event) =>
+ event.type === "proposal.updated" &&
+ event.status === "pending" &&
+ event.chatId !== active,
+ );
+ if (waiting) setApprovalChatId(waiting.chatId);
+ const decided = relevant.find(
+ (event) =>
+ event.type === "proposal.updated" && event.status !== "pending",
+ );
+ if (decided && decided.chatId === approvalChatId) setApprovalChatId(null);
+ },
+ });
+ const approvalChat = approvalChatId
+ ? (chats.find((chat) => chat.id === approvalChatId) ?? null)
+ : null;
+
if (!designId) {
return (
) : null}
+ {approvalChat && approvalChat.id !== selectedChatId ? (
+
+
+ A change is waiting for your approval in “{approvalChat.title}”.
+
+
+
+ ) : null}
{error ? (
{error}
diff --git a/src/modules/assistant/frontend/Space.tsx b/src/modules/assistant/frontend/Space.tsx
index 37176a69..7145276e 100644
--- a/src/modules/assistant/frontend/Space.tsx
+++ b/src/modules/assistant/frontend/Space.tsx
@@ -117,6 +117,7 @@ function linkedDesign(
}
return null;
}
+import { useAssistantEvents } from "./hooks/useAssistantEvents";
import { useAssistantStream } from "./hooks/useAssistantStream";
import { useScrollAnchor, isNearBottom } from "./hooks/useScrollAnchor";
import type {
@@ -376,6 +377,24 @@ export function AssistantSpace({
[base, scroll.scrollToBottom],
);
+ // Live updates for chats changed outside this panel's own runs — MCP
+ // clients (Claude Code) record every tool call into their own chats, and
+ // proposals can be decided from the design dock.
+ useAssistantEvents({
+ backendUrl: backendURL,
+ onEvents: (events) => {
+ void refreshChats().catch(() => undefined);
+ const active = activeChatIdRef.current;
+ if (
+ active &&
+ !activeRunsByChat[active] &&
+ events.some((event) => event.chatId === active)
+ ) {
+ void refreshMessages(active).catch(() => undefined);
+ }
+ },
+ });
+
const loadOlderMessages = useCallback(async () => {
if (
!base ||
diff --git a/src/modules/assistant/frontend/components/GenericProposalCard.tsx b/src/modules/assistant/frontend/components/GenericProposalCard.tsx
index 3002746e..60b2f76a 100644
--- a/src/modules/assistant/frontend/components/GenericProposalCard.tsx
+++ b/src/modules/assistant/frontend/components/GenericProposalCard.tsx
@@ -4,6 +4,11 @@ import type { AssistantWriteProposalDto } from "../../../../sdks/assistant";
import { useNavigationStore } from "../../../../core/frontend/src/stores/navigation-store";
import { useAuth } from "../../../../core/frontend/src/cloud/AuthProvider";
import { readCloudConfig } from "../../../../core/frontend/src/cloud/config";
+import {
+ isStaleMessage,
+ NO_SESSION_ALLOW_KINDS,
+ proposalFailureNote,
+} from "./proposal-status";
type GenericRiskLevel = "low" | "medium" | "high" | "destructive" | string;
@@ -97,6 +102,11 @@ export function GenericProposalCard({
const toolName =
proposal.toolName ?? proposal.envelope?.toolName ?? proposal.kind;
const isActionable = localStatus === "pending" && Boolean(assistantBaseUrl);
+ const canAllowForSession = !NO_SESSION_ALLOW_KINDS.has(String(proposal.kind));
+ const failureNote =
+ localStatus === proposal.status
+ ? proposalFailureNote(localStatus, proposal.applyResult)
+ : null;
const operationGroups = groupOperations(operations);
const operationLimit = compact ? 5 : 8;
const hiddenOperationCount = operationGroups.reduce(
@@ -123,8 +133,17 @@ export function GenericProposalCard({
title?: string;
};
const message = problem.detail ?? problem.title ?? "Apply failed";
- if (/confirm partial/i.test(message)) setConfirmPartial(true);
- throw new Error(message);
+ if (/confirm partial/i.test(message)) {
+ setConfirmPartial(true);
+ throw new Error(message);
+ }
+ // Anything else was persisted as failed by the backend.
+ setLocalStatus("failed");
+ throw new Error(
+ isStaleMessage(message)
+ ? "Not applied: the design changed after this was proposed. Ask the agent to propose it again."
+ : message,
+ );
}
const result = (await response.json()) as {
designId?: string;
@@ -263,6 +282,9 @@ export function GenericProposalCard({
{actionMessage}
diff --git a/src/modules/assistant/frontend/components/proposal-status.test.ts b/src/modules/assistant/frontend/components/proposal-status.test.ts
new file mode 100644
index 00000000..e3b67193
--- /dev/null
+++ b/src/modules/assistant/frontend/components/proposal-status.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { isStaleMessage, NO_SESSION_ALLOW_KINDS, proposalFailureNote } from "./proposal-status";
+
+describe("proposalFailureNote", () => {
+ it("says nothing for proposals that did not fail", () => {
+ expect(proposalFailureNote("pending", null)).toBeNull();
+ expect(proposalFailureNote("applied", { message: "ok" })).toBeNull();
+ });
+
+ it("explains a stale proposal with both revisions and no apply-anyway", () => {
+ const note = proposalFailureNote("failed", {
+ code: "STALE_PROPOSAL",
+ expectedRevision: 40,
+ currentRevision: 42,
+ });
+ expect(note).toContain("revision 40 → 42");
+ expect(note).toContain("propose it again");
+ });
+
+ it("falls back to the persisted message", () => {
+ expect(proposalFailureNote("failed", { message: "Design not found" })).toBe(
+ "Not applied: Design not found",
+ );
+ expect(proposalFailureNote("failed", null)).toBe("Not applied.");
+ });
+
+ it("recognises the backend's stale refusal", () => {
+ expect(
+ isStaleMessage(
+ "Design changed since proposal was created (expected revision 3, current 4). Regenerate the proposal.",
+ ),
+ ).toBe(true);
+ expect(isStaleMessage("Apply failed")).toBe(false);
+ });
+
+ it("never offers a session allowance for irreversible or verification-suppressing kinds", () => {
+ expect(NO_SESSION_ALLOW_KINDS.has("designer_design_delete")).toBe(true);
+ expect(NO_SESSION_ALLOW_KINDS.has("designer_pcb_drc_rule_ignores")).toBe(true);
+ expect(NO_SESSION_ALLOW_KINDS.has("designer_schematic_deletions")).toBe(false);
+ });
+});
diff --git a/src/modules/assistant/frontend/components/proposal-status.ts b/src/modules/assistant/frontend/components/proposal-status.ts
new file mode 100644
index 00000000..2b2e7588
--- /dev/null
+++ b/src/modules/assistant/frontend/components/proposal-status.ts
@@ -0,0 +1,43 @@
+/**
+ * What a proposal card says about a proposal that did not apply, from the
+ * persisted apply result. Pure, so it is unit-tested without React.
+ *
+ * `STALE_PROPOSAL` (see ProposalStaleError on the backend) means the design
+ * moved on after the proposal was made. That is never retried or "applied
+ * anyway": the agent has to propose again against the current design.
+ */
+
+export interface ProposalApplyResultLike {
+ code?: string;
+ message?: string;
+ expectedRevision?: number;
+ currentRevision?: number;
+}
+
+export function proposalFailureNote(
+ status: string,
+ applyResult: unknown,
+): string | null {
+ if (status !== "failed") return null;
+ const result = (applyResult ?? null) as ProposalApplyResultLike | null;
+ if (result?.code === "STALE_PROPOSAL") {
+ const from = result.expectedRevision ?? "?";
+ const to = result.currentRevision ?? "?";
+ return `Not applied: the design changed after this was proposed (revision ${from} → ${to}). Ask the agent to propose it again.`;
+ }
+ return result?.message ? `Not applied: ${result.message}` : "Not applied.";
+}
+
+/** A backend refusal message that means the proposal is stale. */
+export function isStaleMessage(message: string): boolean {
+ return /design changed since proposal was created/i.test(message);
+}
+
+/**
+ * Proposal kinds that always need a fresh approval — the backend never
+ * honours a session allowance for them, so the card does not offer one.
+ */
+export const NO_SESSION_ALLOW_KINDS: ReadonlySet = new Set([
+ "designer_design_delete",
+ "designer_pcb_drc_rule_ignores",
+]);
diff --git a/src/modules/assistant/frontend/hooks/useAssistantEvents.ts b/src/modules/assistant/frontend/hooks/useAssistantEvents.ts
new file mode 100644
index 00000000..571748dc
--- /dev/null
+++ b/src/modules/assistant/frontend/hooks/useAssistantEvents.ts
@@ -0,0 +1,55 @@
+import { useEffect, useRef } from "react";
+import { createLiveEventController } from "../../../../shared/frontend/live-events/live-events";
+
+/**
+ * Live change notifications from `GET /api/modules/assistant/events`.
+ *
+ * The panel streams its own runs over the tasks SSE, but chats also change
+ * from outside a run — above all when Claude Code (or another MCP client)
+ * drives OpenPCB: each tool call lands in an MCP chat, and a deletion it
+ * proposes waits there for the user's approval. This hook lets the panel
+ * refetch when that happens instead of showing a stale transcript.
+ *
+ * Events carry only ids; callers refetch through the normal routes. Bursts
+ * are coalesced (an MCP call produces a begin and an end event) so one tool
+ * call costs one refetch.
+ */
+
+export type AssistantLiveEvent =
+ | { type: "chat.activity"; chatId: string; designId: string | null }
+ | {
+ type: "proposal.updated";
+ chatId: string;
+ proposalId: string;
+ status: string;
+ designId: string | null;
+ };
+
+export interface UseAssistantEventsOptions {
+ backendUrl: string | null | undefined;
+ /** Called once per coalesced burst with every event in it. */
+ onEvents: (events: AssistantLiveEvent[]) => void;
+ /** Coalescing window. */
+ debounceMs?: number;
+ enabled?: boolean;
+}
+
+export function useAssistantEvents({
+ backendUrl,
+ onEvents,
+ debounceMs = 250,
+ enabled = true,
+}: UseAssistantEventsOptions): void {
+ const onEventsRef = useRef(onEvents);
+ onEventsRef.current = onEvents;
+
+ useEffect(() => {
+ if (!enabled || !backendUrl || typeof EventSource === "undefined") return;
+ return createLiveEventController({
+ url: `${backendUrl}/api/modules/assistant/events`,
+ eventTypes: ["chat.activity", "proposal.updated"],
+ debounceMs,
+ onEvents: (events) => onEventsRef.current(events),
+ });
+ }, [backendUrl, debounceMs, enabled]);
+}
diff --git a/src/modules/designer/backend/design-events.ts b/src/modules/designer/backend/design-events.ts
new file mode 100644
index 00000000..73157be1
--- /dev/null
+++ b/src/modules/designer/backend/design-events.ts
@@ -0,0 +1,129 @@
+/**
+ * Design change notifications.
+ *
+ * The designer frontend reloads its projection and history after its own
+ * commands, and after an in-app assistant run tells it to. Nothing told it
+ * about edits from anywhere else — an MCP client (Claude Code) driving the
+ * design through the assistant module, an undo in another window, an
+ * auto-layout apply — so the canvas silently went stale and the user's next
+ * command raced a revision they had never seen.
+ *
+ * The store publishes here after every committed change; `GET /events` (SSE)
+ * relays it; the frontend refetches the open design when the event revision
+ * is newer than what it shows. Events carry ids and revisions only.
+ *
+ * One bus per database (keyed by the module's db client, like the undo
+ * histories), because the designer builds two store instances — routes and
+ * SDK — that must feed the same stream.
+ */
+
+export type DesignEvent =
+ | {
+ type: "design.changed";
+ designId: string;
+ revision: number;
+ /** Undo session the change was made in (`designer-ui-session` for UI + agents). */
+ sessionId: string | null;
+ /** Who made it: "user", "assistant", "autolayout_apply", "import", or null. */
+ actor: string | null;
+ source: "command" | "undo" | "redo";
+ commandType: string | null;
+ }
+ | { type: "design.created"; designId: string; name: string }
+ | { type: "design.updated"; designId: string; name: string }
+ | { type: "design.deleted"; designId: string }
+ /** Ask the UI to open and focus a design (an agent switched what it works on). */
+ | { type: "design.focus"; designId: string };
+
+type Listener = (event: DesignEvent) => void;
+
+export class DesignEventBus {
+ private readonly listeners = new Set();
+
+ publish(event: DesignEvent): void {
+ for (const listener of [...this.listeners]) {
+ try {
+ listener(event);
+ } catch {
+ // One broken subscriber must not starve the others.
+ }
+ }
+ }
+
+ subscribe(listener: Listener): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ get listenerCount(): number {
+ return this.listeners.size;
+ }
+}
+
+const buses = new WeakMap