Agent protocols

JSON-RPC 2.0, the MCP server and the script API: the same commands for every client.

Status: 0.5.0 · issues #100 (JSON-RPC), #101 (MCP), #102 (script API), #108 (parity) · companion to docs/designer-cli.md §3. Code: src/agent/rpc, src/agent/mcp, src/agent/core.ts, src/api/edsnx.ts.

All three protocols run the same registry commands (src/commands, createRegistry()) on one Session through AgentCore (src/agent/core.ts): the same edits, undo steps, validation and error codes as the edsnx CLI and the editor. Calls of one server are serialised, so edits from several clients never interleave. Units are millimetres and degrees, KiCad axes (x right, y down); every document command takes path, relative to the server’s working directory.

1. JSON-RPC 2.0 (edsnx serve)#

text
edsnx serve --stdio                         # one client on stdin/stdout (default)
edsnx serve --socket /tmp/edsnx.sock          # Unix socket (named pipe on Windows), many clients, one session
edsnx serve --open board.kicad_pcb --attach # open documents at start; --attach edits the open VS Code editor
node out/cli.js serve --stdio             # without the bin on PATH

1.1 Framing#

LSP framing: Content-Length: <bytes>\r\n\r\n<UTF-8 JSON>. A client whose first byte is { or [ is served newline-delimited JSON instead (handy from a shell: echo '{"jsonrpc":"2.0","id":1,"method":"session.documents"}' | edsnx serve). stdout carries only protocol frames; diagnostics go to stderr. The socket file is created with mode 0600; a stale socket is removed, a live one refuses the start.

1.2 Methods#

  • Every registry command id is a method; params are its args object (by name), the result is the command result: pcb.addTrack, sch.placeSymbol, query.items, check.drc, project.updatePcb, render.png, … (docs/commands.md, or rpc.discover).
  • Session methods (RPC_SESSION_METHODS): session.open, session.close, session.documents, session.undo, session.redo, session.save, session.revert, session.describe (with path it also returns query.describe of that document), session.batch ({ steps: [{ id, args }], label? }, one undo group, rolled back on the first failure) and session.transaction, which takes either raw intents for one document or steps of registry commands: all-or-nothing, one undo step per document.
  • rpc.discover → { methods, notifications, extra }.
  • $/cancel { id } (notification) cancels one of the caller’s requests: a queued request fails with −32800 without running; a running command sees its AbortSignal.
  • Edits stay in memory until session.save (unless --auto-save).

1.3 Batches and notifications#

A JSON array of requests is a JSON-RPC batch: the reply is an array of the responses (notifications get none); each element succeeds or fails on its own. For all-or-nothing use session.transaction.

Server notifications:

methodtoparams
document.changedevery clientSessionChange without the intent, plus op ({ path, reason: edit/undo/redo/save/revert/open/close, revision, hash, label, newIds, actor, at })
command.progressthe caller{ id, method, message?, percent? }
session.logevery client{ level, entry: LogEntry } after each non-query command, or { level, message, method, id } diagnostics

1.4 Errors#

Command failures keep their CommandError code in error.data.code:

CommandErrorJSON-RPC code
unknown-command−32601 (method not found)
invalid-args−32602 (invalid params; data.errors lists the schema errors)
cancelled−32800
internal−32603
ui-only / no-document / wrong-kind / stale-revision / check-failed / service-unavailable / not-found−32001 … −32007

1.5 Example#

text
→ {"jsonrpc":"2.0","id":1,"method":"pcb.addTrack","params":{"path":"blinky.kicad_pcb","layer":"F.Cu","net":"GND","width":0.25,"pts":[[110,110],[120,110]]}}
← {"jsonrpc":"2.0","method":"document.changed","params":{"path":"/…/blinky.kicad_pcb","reason":"edit","revision":1,"op":"addTrack","newIds":["…"],…}}
← {"jsonrpc":"2.0","id":1,"result":{"newIds":["…"],"revision":1,"hash":"…","label":"Add track"}}
→ {"jsonrpc":"2.0","id":2,"method":"session.transaction","params":{"label":"GND","steps":[{"id":"pcb.route","args":{…}},{"id":"pcb.addVia","args":{…}}]}}
→ {"jsonrpc":"2.0","id":3,"method":"session.save","params":{}}

2. MCP server (edsnx mcp)#

Model Context Protocol over stdio (newline-delimited JSON-RPC), implemented by hand against the 2025-06-18 specification; 2025-03-26 and 2024-11-05 clients are answered in their version.

Register it with Claude Code:

text
claude mcp add edsnx -- edsnx mcp                       # or: claude mcp add edsnx -- node /path/to/out/cli.js mcp
claude mcp add edsnx-live -- edsnx mcp --attach         # edit the documents open in VS Code

or commit a project .mcp.json like examples/mcp.json ({ "mcpServers": { "edsnx": { "command": "edsnx", "args": ["mcp"] } } }). Claude Desktop takes the same entry in claude_desktop_config.json. The electronics-designer skill (.claude/skills/electronics-designer/SKILL.md) tells Claude how to use the tools.

  • Tools: every registry command except kind: "ui" ones (dialogs, view switches) is a tool named with _ for . (pcb.addTrack → pcb_addTrack, check.drc → check_drc), with the command’s JSON schema as inputSchema, its description, kind/scope, units and up to three examples in the description, and readOnlyHint on queries. (2.1.0 AUT-6) From protocol 2025-06-18 on, every tool also has an outputSchema (tools/list leaves it out for older clients): the command’s result schema when it is an object, a schema of { value } wrapping it otherwise (arrays, unions), the generic { "type": "object" } for the few commands without one (session tools, dfm.profiles.get, pcb.autoroute.cancel/providers, sim.models.set, lib.footprint.wizard and the managed-library commands). Results: the JSON result as text plus structuredContent (the result object, or { value } as the schema says), which validates against the outputSchema; render.png returns an image. Failures are results with isError: true and "<code>: <message>" so the model can correct itself; an unknown tool name is a −32602 error. Progress: notifications/progress when the call has _meta.progressToken; notifications/cancelled aborts a call.
  • Resources (MCP_RESOURCES, <path> percent-encoded): kicad://board/<path>/items (query.items), /nets (query.nets), /drc (check.drc), /render.png (render.png, a base64 PNG blob; a resource error “render not available” when the runtime has no renderer), kicad://schematic/<path>/sheets (query.sheetTree) and /erc (check.erc). resources/list lists them for every open document, resources/templates/list gives the URI templates, resources/subscribe sends notifications/resources/updated after each change of that document, and opening/closing a document sends notifications/resources/list_changed.
  • Prompts: design-workflow (goal?, project?) — the schematic → netlist → board → place → route → zones → DRC → fab procedure with the tool names (src/agent/mcp/prompts.ts).
  • Also ping, logging/setLevel (notifications/message), completion/complete (empty).
  • (2.1.0 AUT-6) A headless edsnx mcp (and edsnx serve) appends every call to the project’s .edsnx/session.jsonl, the log of the edsnx CLI (attached sessions are logged by the editor into the same file); session_export turns it into a Markdown report or one JSON document. project_new creates a project (the registry’s project.new, built on a private session: no half-built board is ever visible), query_pairs / query_groups report differential pairs and matched-length groups; the edsnx --check flag now includes the library rules. test/agent.blinky.mcp.test.ts rebuilds the blinky sample with ~140 scripted tool calls and checks its netlist, DRC and ERC with kicad-cli.

2.1 AI chat box (edsnx mcp --attach)#

The editor’s AI chat box (docs/user-guide.md, AI chat box) talks to agents over the live editor attachment (src/agent/attachProtocol.ts). Two methods and one notification are added to it:

MessageDirectionParams → result
agent.chatSubscribeclient → editor{ listen?: boolean = true, chatId?: string } → AgentChatRequest[]: the requests no agent took yet (or the one chatId); with listen the connection also receives later ones
agent.chateditor → client (notification)AgentChatRequest { chatId, text, path?, uri?, at }: a request typed in the box, sent to the listening connections
agent.chatEventclient → editor (request or notification)AgentChatEvent { chatId, kind, text?, percent?, intents?, label?, path? } → { ok: true, revision? }

kind is text (a delta appended to the answer), progress (the progress line, percent 0…100), intents (EditIntents applied on path, default the request’s document, as one undoable change that goes to the review list, named label), done (optional final text) or error (text is the message). Bad params are −32602 errors (parseChatEvent). Every agent edit made while a request runs, through any tool, is counted on that request and listed on its proposal card.

When the MCP server is attached, it subscribes and offers:

  • agent_chat { wait?: seconds }: the pending requests as JSON ([{ chatId, text, path, at }]), waiting up to wait (max 600) for one when none is pending.
  • agent_reply { chatId, progress?, percent?, intents?, label?, path?, text?, done?, error? }: sends progress, then intents, text, and done / error, each as one agent.chatEvent.
  • Prompt editor-chat: the pending requests and how to answer them (/mcp__edsnx__editor-chat in Claude Code).

With no listening connection, the editor starts softforge.edsnx.agent.chatCommand (default claude --print --mcp-config ${mcpConfig} --allowedTools mcp__edsnx) with the request on stdin; the generated config runs edsnx mcp --attach with EDSNX_CHAT_ID set, so that server takes only this request (agent.chatSubscribe { listen: false, chatId }) and the process’s stdout becomes the answer.

3. Script API (edsnx run, @softforge/edsnx-api)#

text
edsnx run build.ts --board blinky.kicad_pcb [--schematic blinky.kicad_sch] [--no-save] [--json] [-- args]

The script is TypeScript or JavaScript (ESM, top-level await). import edsnx, { board, sch, project, lib, query } from '@softforge/edsnx-api' binds to the live API (the same names are globals); relative imports are bundled with esbuild when it is installed. (2.1.0 AUT-6) Without esbuild, compileScriptTree (src/api/edsnx.ts) resolves them itself: the script and every module it imports with a relative specifier (./x, ./x.js finding x.ts, folders finding index.ts), transitively, are stripped of their types by Node (module.stripTypeScriptTypes) and written as .mjs files to a temporary folder next to the script that mirrors the source tree, with the specifiers rewritten; each module stays its own ES module (cycles behave as in Node), bare packages resolve from the script’s folder, and the folder is removed afterwards. This was chosen over bundling esbuild-wasm (about 10 MB in the extension) because Node already strips types. A default-exported function is called with the API; the default export (or the function’s return value) is printed. The session is saved when the script ends unless it throws or --no-save is given.

APIcommand
board.place({ lib, name, at, rot?, side?, ref?, value?, padNets? })pcb.placeFootprint
board.route({ pts, layer?, net?, width?, vias? })pcb.route
board.via, board.zone({ pts, net, layers?, fill? }), board.fillZones(), board.outline({ from, to })pcb.addVia, pcb.addZone, pcb.fillZones, pcb.addGraphic
board.move/rotate/delete, board.drc(), board.items/nets/unrouted/describepcb.move, pcb.rotate, pcb.delete, check.drc, query.items, query.nets, query.unrouted, query.describe
sch.place({ lib, name, at, footprint? }), sch.wire({ pts }), sch.label({ text, at }), sch.junction({ at }), sch.noConnect({ at }), sch.annotate(), sch.erc(), sch.sheets()sch.placeSymbol, sch.wire, sch.label, sch.addJunction, sch.addNoConnect, sch.annotate, check.erc, query.sheetTree
project.new({ name, template? }), project.updatePcb(), project.plot(), project.fabricate()the edsnx project new builtin, project.updatePcb, project.plot, project.fabricate
lib.search(query, { kind? }), query.<name>(args)lib.search, every query.* command
edsnx.run(id, args), edsnx.open/save/undo, edsnx.transaction(label, fn)any command, session.open, session.save, session.undo, one undo group rolled back on a throw

path defaults to board.use(path) / sch.use(path), the --board/--schematic flags, or the only open document of that kind. Types: src/api/edsnx-api.d.ts, published as packages/edsnx-api (@softforge/edsnx-api; node scripts/build-edsnx-api.mjs refreshes the copy). In code, createEdsnxApi({ registry, session }) and runScript(path, session, opts) from src/api/edsnx.ts.

4. Embedding and parity#

src/agent/index.ts exports serveRpc(opts), serveMcp(opts), createAgentRuntime(opts), RpcServer, McpServer, AgentCore and the script API; src/agent/run.ts exports runAgentCommand(argv, io, cwd) and AGENT_SUBCOMMANDS for the CLI’s serve, mcp and run. The parity tests (test/agent.rpc.test.ts, test/agent.mcp.test.ts) check that every registry command is an RPC method and an MCP tool, except the UI-only ones, which uiOnly(registry) lists; test/agent.skill.test.ts checks that this page and the skill name only existing commands.

Documentation for Electronics Designer 2.3.60.