ec-sim-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +190 -2
- package/package.json +33 -4
- package/src/bridge.mjs +190 -0
- package/src/server.mjs +183 -0
- package/src/tools.mjs +446 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Copyright (c) EC-Sim (https://ec-sim.com). All rights reserved.
|
|
2
|
+
|
|
3
|
+
This package (the Ec-Sim MCP server) is proprietary software.
|
|
4
|
+
|
|
5
|
+
You may install and run unmodified copies of this package, free of charge, for the sole purpose
|
|
6
|
+
of connecting an MCP client to the Ec-Sim editor at https://ec-sim.com.
|
|
7
|
+
|
|
8
|
+
No other licence is granted. In particular you may not modify, redistribute, sublicense or
|
|
9
|
+
embed this software in another product or service without prior written permission from the
|
|
10
|
+
copyright holder.
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
|
13
|
+
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
14
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
15
|
+
OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,191 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ec-Sim MCP server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lets an AI agent (Claude Code, Claude Desktop, any MCP client) design and simulate circuits in
|
|
4
|
+
Ec-Sim **while the editor is open in your browser**. The agent's changes appear on the schematic
|
|
5
|
+
as it makes them, simulations run in the tab, and the waveforms it measures are the ones you see.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
agent ⇄ stdio ⇄ this server ⇄ WebSocket on 127.0.0.1 ⇄ editor tab
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The server is a relay. The circuit, the engine and the results stay in the tab, so the engine's
|
|
12
|
+
origin lock and the web app's own code paths apply unchanged. Nothing is uploaded.
|
|
13
|
+
|
|
14
|
+
## Use
|
|
15
|
+
|
|
16
|
+
Needs Node.js 20 or newer. Register the server with your MCP client. In Claude Code:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
claude mcp add ec-sim -- npx -y ec-sim-mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
In Claude Desktop and other clients that use a JSON configuration:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{ "mcpServers": { "ec-sim": { "command": "npx", "args": ["-y", "ec-sim-mcp"] } } }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
From a checkout of the repository (development):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cd mcp && npm ci
|
|
32
|
+
claude mcp add ec-sim -- node /path/to/Ec-Sim/mcp/src/server.mjs
|
|
33
|
+
# against a local dev server (cd web && npm run dev):
|
|
34
|
+
claude mcp add ec-sim -e ECSIM_EDITOR_URL=http://localhost:5173/editor -- node /path/to/Ec-Sim/mcp/src/server.mjs
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then ask the agent for something ("build an RC low-pass with a 1 kHz corner and show me the step
|
|
38
|
+
response"). Its first call, `open_editor`, opens a pairing link in your browser. Press **Connect**
|
|
39
|
+
in the dialog. A badge in the menu bar shows the link and has a Disconnect button.
|
|
40
|
+
|
|
41
|
+
| Variable | Meaning |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `ECSIM_EDITOR_URL` | Editor to pair with. Default `https://ec-sim.com/editor`. Only this origin may link. |
|
|
44
|
+
| `ECSIM_MCP_PORT` | Loopback port for the tab link. Default: any free port. |
|
|
45
|
+
|
|
46
|
+
## Tools
|
|
47
|
+
|
|
48
|
+
| Tool | Purpose |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `open_editor` | Link to a tab (opens the pairing link, waits for Connect) |
|
|
51
|
+
| `list_component_types` | Part types, pins, parameters with SI units and defaults |
|
|
52
|
+
| `list_parts` | The editor's parts library (diodes, BJTs, MOSFETs, JFETs, op-amps): categories, or one category's table in SI, with limits on any column (`where`), sorting, word search and paging |
|
|
53
|
+
| `list_examples`, `load_example` | Built-in circuits |
|
|
54
|
+
| `get_circuit` | The circuit as a netlist (works for hand-drawn circuits), ERC result, revision |
|
|
55
|
+
| `place_part` | Place one part without wires, at a cell, rotated or mirrored; the reply gives its pin positions |
|
|
56
|
+
| `draw_wire`, `remove_wire` | Draw one wire along a stated path (pins, points, corners); remove a wire or one of its segments |
|
|
57
|
+
| `place_symbol`, `remove_symbol` | Ground symbols and net labels at a stated point |
|
|
58
|
+
| `move_label` | Move a part's designator and value text, a probe's badge, or a net label / ground symbol |
|
|
59
|
+
| `get_drawing` | The drawing with positions: parts, pin coordinates and nets, symbols, wires with ids |
|
|
60
|
+
| `set_circuit` | Replace the circuit from a structured netlist or a SPICE deck; drawn and wired automatically |
|
|
61
|
+
| `edit_circuit` | Change values and parameters, swap in a library part, add and remove parts, move, turn or mirror a part, move a pin to another net (all or nothing) |
|
|
62
|
+
| `set_probes` | Choose the signals in the waveform viewer |
|
|
63
|
+
| `set_design_notes` | Write the project's design notes (Edit > Design Notes in the editor); `get_circuit` returns them |
|
|
64
|
+
| `set_math_waveforms` | Named expressions drawn as traces in the viewer (`avg`, `rms`, `d/dt`, `integ`, arithmetic); the tool description carries the syntax |
|
|
65
|
+
| `run_simulation` | Transient, AC, DC operating point, DC sweep; time accuracy normal/high/very high; returns a summary, not waveforms |
|
|
66
|
+
| `measure` | Scalars from the last result: levels, timing, settling, −3 dB points, math expressions |
|
|
67
|
+
| `get_waveform` | The plotted data: resampled points, capped at 2000 per signal; `from`/`to` return one part of the run |
|
|
68
|
+
| `export_waveforms` | Write the full-resolution data of the last run to a CSV file on this computer and return its path |
|
|
69
|
+
| `screenshot` | PNG of the schematic (the whole drawing, or a close-up around named parts) or of the waveform viewer as shown, returned as an image |
|
|
70
|
+
| `export` | SPICE netlist, BOM, share link |
|
|
71
|
+
|
|
72
|
+
Definitions are in `src/tools.mjs`; the handlers are in the web app
|
|
73
|
+
(`web/src/agent/agentHandlers.ts` in the repository). A web test fails if the two lists differ.
|
|
74
|
+
|
|
75
|
+
## How it stays safe and honest
|
|
76
|
+
|
|
77
|
+
- **Loopback only.** The server listens on 127.0.0.1. The tab connects to the literal address.
|
|
78
|
+
- **Three checks before a tab links:** the page's `Origin` must be the editor, the `Host` header
|
|
79
|
+
must be a loopback name, and the first message must carry the random token from the pairing link.
|
|
80
|
+
The token is removed from the address bar as soon as the page has read it.
|
|
81
|
+
- **You confirm in the tab.** Nothing links until you press Connect, and the dialog warns about
|
|
82
|
+
unsaved changes.
|
|
83
|
+
- **Every agent change is one undo step**, applied through the editor's normal state and validated
|
|
84
|
+
like an opened file.
|
|
85
|
+
- **Revisions.** Every change to the document, yours or the agent's, increments a revision.
|
|
86
|
+
`edit_circuit` must name the revision it is based on and fails if you edited in between.
|
|
87
|
+
An agent change waits while you are dragging something.
|
|
88
|
+
- **Netlist = drawing.** The agent states parts and net names; the tab draws real parts, wires,
|
|
89
|
+
net labels and ground symbols, then reads the drawing back through the editor's connectivity
|
|
90
|
+
and refuses the change if any pin is not on the net that was asked for.
|
|
91
|
+
- **Library parts take the editor's path.** A part's `model` applies the same catalog entry, the
|
|
92
|
+
same way, as picking it in the Property Inspector. `list_parts` shows that catalog converted to
|
|
93
|
+
SI with `deviceDefaults.ts`.
|
|
94
|
+
- **SI at the boundary.** Conversion to the datasheet units the Property Inspector shows uses the
|
|
95
|
+
same table (`deviceDefaults.ts`) as the path to the engine. Defaults reported to the agent are
|
|
96
|
+
computed from that table, not copied.
|
|
97
|
+
- **One file, where you say.** `export_waveforms` is the only tool that writes to disk: a `.csv`
|
|
98
|
+
at an absolute path (or a new file in the system temp folder), never over an existing file
|
|
99
|
+
unless asked, and complete or absent.
|
|
100
|
+
- **Bounded output.** No tool returns a raw waveform; lists and text are capped, and a screenshot
|
|
101
|
+
is at most 2400 px wide.
|
|
102
|
+
|
|
103
|
+
## How the agent draws
|
|
104
|
+
|
|
105
|
+
**First everything at once, then tidying by hand.** The agent is told to plan the placement, draw
|
|
106
|
+
the whole circuit with one `set_circuit` call (the editor places and wires it, as described in the
|
|
107
|
+
next section), look at a screenshot, and then fix what does not read cleanly one change at a
|
|
108
|
+
time: `move_part` for a part in the wrong place or out of line, `remove_wire` and `draw_wire` for
|
|
109
|
+
a wire with a detour or a needless crossing, `move_label` for text in the way. You see the
|
|
110
|
+
circuit appear and then being cleaned up.
|
|
111
|
+
|
|
112
|
+
The hand tools work like this:
|
|
113
|
+
|
|
114
|
+
- Positions are in grid steps of 20 px. A layout cell is 16 steps wide and 12 high; the part in
|
|
115
|
+
cell (col, row) is centred at (10 + 16 col, 10 + 12 row).
|
|
116
|
+
- A wire goes exactly where the agent says. Nothing is snapped: an off-grid point or a wire
|
|
117
|
+
through a part is refused. Two points that are not in line get one corner between them.
|
|
118
|
+
- Nothing is assumed about what a wire connects. The reply to `draw_wire` reports what the
|
|
119
|
+
editor's connectivity finds on it: the pins it joined and their net, and warnings when it
|
|
120
|
+
bridged a part, joined two named nets, crossed other wires or left an end open. After hand
|
|
121
|
+
changes the agent compares `get_circuit` with the circuit it meant.
|
|
122
|
+
- Each step is one undo step. `revision` is optional on these tools.
|
|
123
|
+
|
|
124
|
+
A circuit can also be built entirely by hand (`place_part`, then `draw_wire`), which the agent is
|
|
125
|
+
told is fine for a handful of parts or for adding to an existing circuit.
|
|
126
|
+
|
|
127
|
+
The wording that steers the agent (workflow, layout and wire style) is `INSTRUCTIONS` in
|
|
128
|
+
`src/tools.mjs`.
|
|
129
|
+
|
|
130
|
+
## How the automatic drawing works
|
|
131
|
+
|
|
132
|
+
Parts go on a layout grid (320 × 240 px cells): where the agent says with `at: {col, row}` and
|
|
133
|
+
`rotation`, otherwise row by row in the order listed. Every connected pin is led out of its part
|
|
134
|
+
on a short stub. Then, per net (`web/src/agent/wireRouter.ts`):
|
|
135
|
+
|
|
136
|
+
- Pins are joined by orthogonal wires on the 20 px grid, nearest first, as short as the placement
|
|
137
|
+
allows. A pin is led two grid steps out of its part before a wire turns or joins, and a wire
|
|
138
|
+
keeps two straight grid steps on each side of a crossing where there is room (a preference: it
|
|
139
|
+
gives way when other connections need the space).
|
|
140
|
+
- A wire may cross another
|
|
141
|
+
net's wire but never ends on it, runs along it or passes over one of its corners, so a crossing
|
|
142
|
+
is never a connection.
|
|
143
|
+
- A pin gets a **net label instead of a wire** only when no clear path exists within about
|
|
144
|
+
1800 px of wire (bends and crossings count extra). The reply to `set_circuit` / `edit_circuit`
|
|
145
|
+
then carries a `drawing` note naming those nets, so the agent can move parts closer.
|
|
146
|
+
- Each net gets one label that carries its name (what `V(name)` refers to). Ground pins get a
|
|
147
|
+
ground symbol each.
|
|
148
|
+
- `add_part` puts the new part in the free cell nearest to the pins it connects to and wires it to
|
|
149
|
+
the existing net, including nets of a hand-drawn circuit that have no label.
|
|
150
|
+
- `remove_part` also removes the wires that were left leading nowhere.
|
|
151
|
+
- `move_part` and `connect` take the part out with the wires drawn for it and draw it again at its
|
|
152
|
+
(new) place on its (new) nets. Every other connection must come out exactly as it was, or the
|
|
153
|
+
change is refused. Probes follow their parts and nets.
|
|
154
|
+
|
|
155
|
+
A circuit given as a SPICE deck goes through the editor's SPICE import instead, which puts a net
|
|
156
|
+
label on every pin.
|
|
157
|
+
|
|
158
|
+
After the agent draws or extends a circuit, the editor zooms your view to the whole drawing, and
|
|
159
|
+
the server's instructions tell the agent to look at a screenshot of the layout before going on.
|
|
160
|
+
|
|
161
|
+
If the wired drawing ever fails the read-back, the circuit is drawn with a label on every pin
|
|
162
|
+
instead and the reply says so.
|
|
163
|
+
|
|
164
|
+
## Limits
|
|
165
|
+
|
|
166
|
+
- Placement is the agent's job: there is no automatic placement. The server's instructions
|
|
167
|
+
(`LAYOUT` in `src/tools.mjs`) tell it how to choose cells: signal flow left to right, series
|
|
168
|
+
parts in one row, shunt parts upright in the row below their node, tidy rows and columns, then
|
|
169
|
+
check the screenshot and fix with `move_part`.
|
|
170
|
+
- Parts sit in the middle of a cell unless the agent says otherwise. Pins of three-pin parts are
|
|
171
|
+
not on the centre line, so `list_component_types` gives every pin's offset from the centre for
|
|
172
|
+
each rotation, and `line_up` (in `place_part`, `add_part` and `move_part`) shifts a part so one
|
|
173
|
+
of its pins is exactly in line with a pin of another part.
|
|
174
|
+
- `move_part` on a hand-wired part redraws that part's wires automatically.
|
|
175
|
+
- `list_parts` covers the built-in library only. Passives have no library entries.
|
|
176
|
+
- Design notes are not part of undo, and writing them does not change the circuit revision.
|
|
177
|
+
- No parameter sweeps, Monte Carlo or thermal tools yet.
|
|
178
|
+
- One tab at a time; a second tab that links takes over.
|
|
179
|
+
- A page on `https://ec-sim.com` connecting to `ws://127.0.0.1` has only been tested from a local
|
|
180
|
+
dev server (`http://localhost`). Browsers treat loopback as trustworthy, but Chrome may show a
|
|
181
|
+
local-network permission prompt and Safari may refuse; this needs checking on the live site.
|
|
182
|
+
|
|
183
|
+
## Tests
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
cd mcp && npm test # server: MCP over stdio, origin/host/token checks, relay
|
|
187
|
+
cd web && npx vitest run src/test/agent_bridge.test.ts src/test/agent_bridge_editor.test.tsx
|
|
188
|
+
# Real browser, real engine (needs chromium and a running dev server on port 5199):
|
|
189
|
+
cd web && npx vite --port 5199 --strictPort &
|
|
190
|
+
node mcp/scripts/e2e-browser.mjs /tmp/ecsim-e2e.png
|
|
191
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ec-sim-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that lets an AI agent drive the Ec-Sim circuit simulator in an open browser tab",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"circuit-simulator",
|
|
9
|
+
"spice",
|
|
10
|
+
"electronics",
|
|
11
|
+
"ec-sim"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://ec-sim.com/docs/files-and-reference#ai-agents",
|
|
14
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bin": {
|
|
22
|
+
"ec-sim-mcp": "src/server.mjs"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "node src/server.mjs",
|
|
26
|
+
"test": "node --test"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
33
|
+
"ws": "^8.23.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/bridge.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link between the MCP server and one Ec-Sim editor tab.
|
|
3
|
+
*
|
|
4
|
+
* A WebSocket server on the loopback interface. A tab may link only if all of these hold:
|
|
5
|
+
* - the connection comes to 127.0.0.1 (the server does not listen anywhere else),
|
|
6
|
+
* - the page's Origin is the Ec-Sim editor (any other website is refused),
|
|
7
|
+
* - the Host header is a loopback name (defeats DNS rebinding),
|
|
8
|
+
* - its first message carries the pairing token from the link the user opened.
|
|
9
|
+
* The user must also press Connect in the tab before it links (that part lives in the web app).
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { WebSocketServer } from 'ws';
|
|
13
|
+
|
|
14
|
+
export const BRIDGE_PROTOCOL = 1;
|
|
15
|
+
const HELLO_TIMEOUT_MS = 5000;
|
|
16
|
+
const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
export class NoEditorError extends Error {}
|
|
19
|
+
|
|
20
|
+
export class EditorBridge {
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ editorUrl: string, port?: number, log?: (msg: string) => void }} opts
|
|
23
|
+
*/
|
|
24
|
+
constructor(opts) {
|
|
25
|
+
this.editorUrl = new URL(opts.editorUrl);
|
|
26
|
+
this.allowedOrigin = this.editorUrl.origin;
|
|
27
|
+
this.requestedPort = opts.port ?? 0;
|
|
28
|
+
this.log = opts.log ?? (() => {});
|
|
29
|
+
this.token = randomBytes(24).toString('hex');
|
|
30
|
+
this.tab = null;
|
|
31
|
+
this.nextId = 1;
|
|
32
|
+
/** @type {Map<number, { resolve: Function, reject: Function, timer: NodeJS.Timeout, socket: object }>} */
|
|
33
|
+
this.pending = new Map();
|
|
34
|
+
this.tabWaiters = [];
|
|
35
|
+
this.wss = null;
|
|
36
|
+
this.port = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
start() {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const wss = new WebSocketServer({
|
|
42
|
+
host: '127.0.0.1',
|
|
43
|
+
port: this.requestedPort,
|
|
44
|
+
path: '/bridge',
|
|
45
|
+
maxPayload: MAX_MESSAGE_BYTES,
|
|
46
|
+
verifyClient: (info, done) => {
|
|
47
|
+
const origin = info.origin || '';
|
|
48
|
+
const host = String(info.req.headers.host || '').replace(/:\d+$/, '');
|
|
49
|
+
if (origin !== this.allowedOrigin) {
|
|
50
|
+
this.log(`Refused a connection from origin "${origin}" (only ${this.allowedOrigin} may link).`);
|
|
51
|
+
done(false, 403, 'Origin not allowed');
|
|
52
|
+
} else if (host !== '127.0.0.1' && host !== 'localhost') {
|
|
53
|
+
done(false, 403, 'Host not allowed');
|
|
54
|
+
} else {
|
|
55
|
+
done(true);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
wss.once('error', reject);
|
|
60
|
+
wss.once('listening', () => {
|
|
61
|
+
this.port = wss.address().port;
|
|
62
|
+
this.wss = wss;
|
|
63
|
+
wss.on('error', (err) => this.log(`Bridge error: ${err.message}`));
|
|
64
|
+
resolve();
|
|
65
|
+
});
|
|
66
|
+
wss.on('connection', (socket) => this.onConnection(socket));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The link the user opens to pair a tab with this server. */
|
|
71
|
+
get pairingUrl() {
|
|
72
|
+
const url = new URL(this.editorUrl);
|
|
73
|
+
url.hash = `agent=${this.port}.${this.token}`;
|
|
74
|
+
return url.toString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get connected() {
|
|
78
|
+
return this.tab !== null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
onConnection(socket) {
|
|
82
|
+
let authed = false;
|
|
83
|
+
const helloTimer = setTimeout(() => {
|
|
84
|
+
if (!authed) socket.close(4401, 'no pairing token received');
|
|
85
|
+
}, HELLO_TIMEOUT_MS);
|
|
86
|
+
|
|
87
|
+
socket.on('message', (data) => {
|
|
88
|
+
let msg;
|
|
89
|
+
try {
|
|
90
|
+
msg = JSON.parse(data.toString());
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!authed) {
|
|
95
|
+
if (msg?.type !== 'hello' || !this.tokenMatches(msg.token)) {
|
|
96
|
+
socket.close(4401, 'wrong pairing token');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (msg.protocol !== BRIDGE_PROTOCOL) {
|
|
100
|
+
socket.close(4426, 'the editor and the MCP server versions do not match');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
authed = true;
|
|
104
|
+
clearTimeout(helloTimer);
|
|
105
|
+
if (this.tab && this.tab !== socket) {
|
|
106
|
+
const old = this.tab;
|
|
107
|
+
this.dropTab(old, 'another tab took over the link');
|
|
108
|
+
old.close(4000, 'another tab took over the link');
|
|
109
|
+
}
|
|
110
|
+
this.tab = socket;
|
|
111
|
+
socket.send(JSON.stringify({ type: 'welcome', protocol: BRIDGE_PROTOCOL }));
|
|
112
|
+
this.log('Editor tab linked.');
|
|
113
|
+
for (const w of this.tabWaiters.splice(0)) w(true);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (msg?.type === 'response' && this.pending.has(msg.id)) {
|
|
117
|
+
const p = this.pending.get(msg.id);
|
|
118
|
+
this.pending.delete(msg.id);
|
|
119
|
+
clearTimeout(p.timer);
|
|
120
|
+
if (msg.ok) p.resolve(msg.result);
|
|
121
|
+
else p.reject(new Error(String(msg.error?.message || 'The editor reported an error.')));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
socket.on('close', () => {
|
|
125
|
+
clearTimeout(helloTimer);
|
|
126
|
+
if (this.tab === socket) {
|
|
127
|
+
this.log('Editor tab unlinked.');
|
|
128
|
+
this.dropTab(socket, 'the editor tab was closed or unlinked');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
socket.on('error', () => {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
tokenMatches(candidate) {
|
|
135
|
+
if (typeof candidate !== 'string') return false;
|
|
136
|
+
const a = Buffer.from(candidate);
|
|
137
|
+
const b = Buffer.from(this.token);
|
|
138
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
dropTab(socket, why) {
|
|
142
|
+
if (this.tab === socket) this.tab = null;
|
|
143
|
+
for (const [id, p] of this.pending) {
|
|
144
|
+
if (p.socket !== socket) continue;
|
|
145
|
+
this.pending.delete(id);
|
|
146
|
+
clearTimeout(p.timer);
|
|
147
|
+
p.reject(new NoEditorError(`The request was not completed: ${why}.`));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Resolves true when a tab is linked, false after `timeoutMs`. */
|
|
152
|
+
waitForTab(timeoutMs) {
|
|
153
|
+
if (this.tab) return Promise.resolve(true);
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
const done = (ok) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
const i = this.tabWaiters.indexOf(done);
|
|
158
|
+
if (i >= 0) this.tabWaiters.splice(i, 1);
|
|
159
|
+
resolve(ok);
|
|
160
|
+
};
|
|
161
|
+
const timer = setTimeout(() => done(false), timeoutMs);
|
|
162
|
+
this.tabWaiters.push(done);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Sends one request to the linked tab and resolves with its result. */
|
|
167
|
+
request(method, params, timeoutMs) {
|
|
168
|
+
const socket = this.tab;
|
|
169
|
+
if (!socket) return Promise.reject(new NoEditorError('No editor tab is linked.'));
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const id = this.nextId++;
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
this.pending.delete(id);
|
|
174
|
+
reject(new Error(`The editor did not answer ${method} within ${Math.round(timeoutMs / 1000)} s.`));
|
|
175
|
+
}, timeoutMs);
|
|
176
|
+
this.pending.set(id, { resolve, reject, timer, socket });
|
|
177
|
+
socket.send(JSON.stringify({ type: 'request', id, method, params: params ?? {} }));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
close() {
|
|
182
|
+
for (const w of this.tabWaiters.splice(0)) w(false);
|
|
183
|
+
if (this.tab) this.dropTab(this.tab, 'the MCP server is shutting down');
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
if (!this.wss) return resolve();
|
|
186
|
+
for (const client of this.wss.clients) client.terminate();
|
|
187
|
+
this.wss.close(() => resolve());
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Ec-Sim MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Speaks MCP over stdio to an AI agent and relays its tool calls to an Ec-Sim editor tab in the
|
|
6
|
+
* user's browser (see bridge.mjs). The circuit and the simulation stay in the tab, so the user
|
|
7
|
+
* watches the agent work.
|
|
8
|
+
*
|
|
9
|
+
* Environment:
|
|
10
|
+
* ECSIM_EDITOR_URL editor to pair with (default https://ec-sim.com/editor). For development
|
|
11
|
+
* use the Vite server, e.g. http://localhost:5173/editor
|
|
12
|
+
* ECSIM_MCP_PORT loopback port for the tab link (default: any free port)
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { rename, rm, stat } from 'node:fs/promises';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
19
|
+
import { once } from 'node:events';
|
|
20
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
21
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
22
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
23
|
+
import { EditorBridge, NoEditorError } from './bridge.mjs';
|
|
24
|
+
import { EDITOR_TOOLS, INSTRUCTIONS, LOCAL_TOOLS } from './tools.mjs';
|
|
25
|
+
|
|
26
|
+
const log = (msg) => process.stderr.write(`[ec-sim-mcp] ${msg}\n`);
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
29
|
+
const MAX_EXPORT_ROWS = 5_000_000;
|
|
30
|
+
const SIM_DEFAULT_S = 120;
|
|
31
|
+
const SIM_MAX_S = 600;
|
|
32
|
+
|
|
33
|
+
function openInBrowser(url) {
|
|
34
|
+
const [cmd, args] =
|
|
35
|
+
process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]] : ['xdg-open', [url]];
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
try {
|
|
38
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
39
|
+
child.once('error', () => resolve(false));
|
|
40
|
+
child.once('spawn', () => {
|
|
41
|
+
child.unref();
|
|
42
|
+
resolve(true);
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
resolve(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] });
|
|
51
|
+
/** A reply that carries a picture (`image: {mimeType, data}`) becomes an image block plus the rest as text. */
|
|
52
|
+
function reply(value) {
|
|
53
|
+
const image = value && typeof value === 'object' ? value.image : undefined;
|
|
54
|
+
if (!image || typeof image.data !== 'string' || typeof image.mimeType !== 'string') return text(value);
|
|
55
|
+
const { image: _picture, ...rest } = value;
|
|
56
|
+
return { content: [{ type: 'image', data: image.data, mimeType: image.mimeType }, { type: 'text', text: JSON.stringify(rest) }] };
|
|
57
|
+
}
|
|
58
|
+
const failure = (message) => ({ content: [{ type: 'text', text: message }], isError: true });
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
const port = process.env.ECSIM_MCP_PORT ? Number(process.env.ECSIM_MCP_PORT) : 0;
|
|
62
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('ECSIM_MCP_PORT must be a port number.');
|
|
63
|
+
const bridge = new EditorBridge({ editorUrl: process.env.ECSIM_EDITOR_URL || 'https://ec-sim.com/editor', port, log });
|
|
64
|
+
await bridge.start();
|
|
65
|
+
log(`Waiting for an editor tab. Pairing link: ${bridge.pairingUrl}`);
|
|
66
|
+
|
|
67
|
+
const notLinked = () =>
|
|
68
|
+
failure(`No Ec-Sim editor tab is linked. Call open_editor, or ask the user to open this link and press Connect: ${bridge.pairingUrl}`);
|
|
69
|
+
|
|
70
|
+
async function openEditor(args) {
|
|
71
|
+
const launch = args.launch !== false;
|
|
72
|
+
const waitS = Math.max(0, Math.min(300, typeof args.wait_s === 'number' ? args.wait_s : 60));
|
|
73
|
+
if (bridge.connected) return text({ linked: true });
|
|
74
|
+
let launched = false;
|
|
75
|
+
if (launch) launched = await openInBrowser(bridge.pairingUrl);
|
|
76
|
+
const linked = await bridge.waitForTab(waitS * 1000);
|
|
77
|
+
if (linked) return text({ linked: true });
|
|
78
|
+
return text({
|
|
79
|
+
linked: false,
|
|
80
|
+
pairing_link: bridge.pairingUrl,
|
|
81
|
+
next: launched
|
|
82
|
+
? 'The link was opened in the browser, but the user has not pressed Connect yet. Ask them to, then call open_editor again.'
|
|
83
|
+
: 'Ask the user to open the pairing link in their browser and press Connect, then call open_editor again.',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Writes the recorded data to a CSV file. The tab hands over the rows piece by piece (a whole
|
|
89
|
+
* run does not fit in one message); the file is complete or absent, never half written.
|
|
90
|
+
*/
|
|
91
|
+
async function exportWaveforms(args) {
|
|
92
|
+
let target;
|
|
93
|
+
if (args.path !== undefined) {
|
|
94
|
+
if (typeof args.path !== 'string' || !isAbsolute(args.path) || !/\.csv$/i.test(args.path)) return failure('path must be an absolute path that ends in .csv.');
|
|
95
|
+
target = resolve(args.path);
|
|
96
|
+
if (existsSync(target) && args.overwrite !== true) return failure(`${target} already exists. Give another path, or "overwrite": true.`);
|
|
97
|
+
} else {
|
|
98
|
+
const dir = join(tmpdir(), 'ec-sim-mcp');
|
|
99
|
+
mkdirSync(dir, { recursive: true });
|
|
100
|
+
target = join(dir, `waveforms-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`);
|
|
101
|
+
}
|
|
102
|
+
const partial = `${target}.part`;
|
|
103
|
+
const out = createWriteStream(partial);
|
|
104
|
+
let failed = null;
|
|
105
|
+
out.on('error', (err) => {
|
|
106
|
+
failed = err;
|
|
107
|
+
});
|
|
108
|
+
let info = null;
|
|
109
|
+
let rows = 0;
|
|
110
|
+
try {
|
|
111
|
+
for (let offset = 0; offset !== null; ) {
|
|
112
|
+
const piece = await bridge.request('waveform_rows', { signals: args.signals, from: args.from, to: args.to, offset }, DEFAULT_TIMEOUT_MS);
|
|
113
|
+
if (failed) throw failed;
|
|
114
|
+
if (offset === 0) {
|
|
115
|
+
if (piece.total > MAX_EXPORT_ROWS) throw new Error(`The result has ${piece.total} rows, more than the ${MAX_EXPORT_ROWS} one file may hold. Limit it with from/to.`);
|
|
116
|
+
info = piece;
|
|
117
|
+
out.write(`${piece.header}\n`);
|
|
118
|
+
}
|
|
119
|
+
if (piece.rows && !out.write(`${piece.rows}\n`)) await once(out, 'drain');
|
|
120
|
+
rows = piece.next ?? piece.total;
|
|
121
|
+
offset = piece.next;
|
|
122
|
+
}
|
|
123
|
+
out.end();
|
|
124
|
+
await once(out, 'finish');
|
|
125
|
+
if (failed) throw failed;
|
|
126
|
+
await rename(partial, target);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
out.destroy();
|
|
129
|
+
await rm(partial, { force: true });
|
|
130
|
+
if (err instanceof NoEditorError && !bridge.connected) return notLinked();
|
|
131
|
+
return failure(`The file was not written: ${err?.message || String(err)}`);
|
|
132
|
+
}
|
|
133
|
+
return text({
|
|
134
|
+
path: target,
|
|
135
|
+
rows,
|
|
136
|
+
columns: info.header.split(',').length,
|
|
137
|
+
header: info.header,
|
|
138
|
+
bytes: (await stat(target)).size,
|
|
139
|
+
range: { axis: info.axis, from: info.from, to: info.to },
|
|
140
|
+
stale: info.stale,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const server = new Server({ name: 'ec-sim', version: '0.1.0' }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
145
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...LOCAL_TOOLS, ...EDITOR_TOOLS] }));
|
|
146
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
147
|
+
const name = req.params.name;
|
|
148
|
+
const args = req.params.arguments && typeof req.params.arguments === 'object' ? req.params.arguments : {};
|
|
149
|
+
if (name === 'open_editor') return openEditor(args);
|
|
150
|
+
if (name !== 'export_waveforms' && !EDITOR_TOOLS.some((t) => t.name === name)) return failure(`Unknown tool "${name}".`);
|
|
151
|
+
if (!bridge.connected) return notLinked();
|
|
152
|
+
if (name === 'export_waveforms') return exportWaveforms(args);
|
|
153
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
154
|
+
if (name === 'run_simulation') {
|
|
155
|
+
const s = typeof args.timeout_s === 'number' && args.timeout_s > 0 ? Math.min(args.timeout_s, SIM_MAX_S) : SIM_DEFAULT_S;
|
|
156
|
+
// The tab stops the run itself at timeout_s; allow it time to report what it had
|
|
157
|
+
timeoutMs = (s + 20) * 1000;
|
|
158
|
+
}
|
|
159
|
+
// A long list of operations redraws many wires: allow it more time than a single change
|
|
160
|
+
if (name === 'edit_circuit' && Array.isArray(args.operations)) timeoutMs = Math.min(DEFAULT_TIMEOUT_MS + args.operations.length * 500, 180_000);
|
|
161
|
+
try {
|
|
162
|
+
return reply(await bridge.request(name, args, timeoutMs));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof NoEditorError && !bridge.connected) return notLinked();
|
|
165
|
+
return failure(err?.message || String(err));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const shutdown = async () => {
|
|
170
|
+
await bridge.close();
|
|
171
|
+
process.exit(0);
|
|
172
|
+
};
|
|
173
|
+
process.on('SIGINT', shutdown);
|
|
174
|
+
process.on('SIGTERM', shutdown);
|
|
175
|
+
process.stdin.on('close', shutdown);
|
|
176
|
+
|
|
177
|
+
await server.connect(new StdioServerTransport());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
main().catch((err) => {
|
|
181
|
+
log(`Fatal: ${err?.stack || err}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
});
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool definitions of the Ec-Sim MCP server.
|
|
3
|
+
*
|
|
4
|
+
* `open_editor` is answered by the server itself. Every other tool is forwarded to the editor
|
|
5
|
+
* tab, which holds the circuit and runs the simulation (web/src/agent/agentHandlers.ts has one
|
|
6
|
+
* handler per tool; a web test checks the two lists match).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PART = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
description: 'One part of the circuit.',
|
|
12
|
+
properties: {
|
|
13
|
+
id: { type: 'string', description: 'Designator, unique in the circuit: R1, C2, M1, V1 ...' },
|
|
14
|
+
type: { type: 'string', description: 'Part type from list_component_types, e.g. resistor, capacitor, nmos, voltage_source.' },
|
|
15
|
+
value: {
|
|
16
|
+
type: ['number', 'string'],
|
|
17
|
+
description: 'Main value in SI (ohm, farad, henry, volt, ampere), as a number or text with a suffix ("4.7k", "100n"). Only for types that list a value.',
|
|
18
|
+
},
|
|
19
|
+
nets: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
description: 'Pin name -> net name, e.g. {"p1": "in", "p2": "out"}. Use "0" for ground. Pins sharing a net name are connected.',
|
|
22
|
+
additionalProperties: { type: 'string' },
|
|
23
|
+
},
|
|
24
|
+
params: { type: 'object', description: 'Parameters in SI (see list_component_types). Omitted parameters use the simulator defaults.' },
|
|
25
|
+
model: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Part number from the library (see list_parts), e.g. "IRF540N" for an nmos. The library entry sets the parameters; "params" override single values.',
|
|
28
|
+
},
|
|
29
|
+
at: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
description:
|
|
32
|
+
'Where to draw the part, in cells of the layout grid: col grows to the right, row downwards. Every part sits in the middle of its cell, so parts in one row or one column line up exactly. Left out: the next free cell, in the order the parts are listed. See LAYOUT in the server instructions for how to choose cells.',
|
|
33
|
+
properties: { col: { type: 'integer', minimum: 0 }, row: { type: 'integer', minimum: 0 } },
|
|
34
|
+
required: ['col', 'row'],
|
|
35
|
+
},
|
|
36
|
+
rotation: { type: 'number', enum: [0, 90, 180, 270], description: 'Rotation in degrees (default 0). Two-pin passives lie horizontal at 0; sources stand vertical.' },
|
|
37
|
+
line_up: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
description:
|
|
40
|
+
'Shift the part a few grid steps so that one of its pins is exactly in line with a pin of a part already placed: {"pin": "p1", "with": "M1.drain"}. The shift is along one axis, the one that needs the smaller move: a part beside the other lines up in height, a part above or below lines up sideways (at most 4 steps). Use it whenever two connected pins are not on the same line, e.g. a resistor above a transistor\'s drain, so the wire between them is straight. Not available in set_circuit.',
|
|
41
|
+
properties: { pin: { type: 'string' }, with: { type: 'string' } },
|
|
42
|
+
required: ['pin', 'with'],
|
|
43
|
+
},
|
|
44
|
+
centre: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
description: 'Exact centre of the part in grid steps, instead of a cell ("at"), for the rare case where a cell plus line_up does not fit.',
|
|
47
|
+
properties: { x: { type: 'integer' }, y: { type: 'integer' } },
|
|
48
|
+
required: ['x', 'y'],
|
|
49
|
+
},
|
|
50
|
+
mirrorX: { type: 'boolean', description: 'Flip the part horizontally (left and right swap), applied before the rotation. Use it to turn a transistor so its gate or base faces the other way without swapping drain and source in height.' },
|
|
51
|
+
mirrorY: { type: 'boolean', description: 'Flip the part vertically (top and bottom swap), applied before the rotation, e.g. to put a transistor\'s source or emitter at the top.' },
|
|
52
|
+
},
|
|
53
|
+
required: ['id', 'type', 'nets'],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A part for place_part: as PART, placed without wires */
|
|
57
|
+
const PART_BARE = {
|
|
58
|
+
type: 'object',
|
|
59
|
+
description: 'The part to place: id, type, value or model, params, and where ("at", "rotation"). No "nets".',
|
|
60
|
+
properties: Object.fromEntries(Object.entries(PART.properties).filter(([key]) => key !== 'nets')),
|
|
61
|
+
required: ['id', 'type'],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const REVISION_OPTIONAL = {
|
|
65
|
+
type: 'number',
|
|
66
|
+
description: 'Optional: the revision this call is based on; the call then fails if the user changed the circuit since.',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const SIMULATION = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
description:
|
|
72
|
+
'Simulation settings in SI. transient: tStop, tStepMax (default tStop/500), outputStart, zeroStart, accuracy. ac: fStart, fStop, pointsPerDecade (a source needs acMag). dc_op: none. dc_sweep: sweepSource, sweepStart, sweepStop, sweepPoints.',
|
|
73
|
+
properties: {
|
|
74
|
+
analysis: { type: 'string', enum: ['transient', 'ac', 'dc_op', 'dc_sweep'] },
|
|
75
|
+
tStop: { type: 'number' },
|
|
76
|
+
tStepMax: { type: 'number' },
|
|
77
|
+
outputStart: { type: 'number' },
|
|
78
|
+
zeroStart: { type: 'boolean', description: 'Start a transient from zero state instead of the DC operating point.' },
|
|
79
|
+
fStart: { type: 'number' },
|
|
80
|
+
fStop: { type: 'number' },
|
|
81
|
+
pointsPerDecade: { type: 'number' },
|
|
82
|
+
sweepSource: { type: 'string' },
|
|
83
|
+
sweepStart: { type: 'number' },
|
|
84
|
+
sweepStop: { type: 'number' },
|
|
85
|
+
sweepPoints: { type: 'number' },
|
|
86
|
+
accuracy: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
enum: ['normal', 'high', 'very_high'],
|
|
89
|
+
description: 'Time accuracy of a transient run (truncation-error tolerance per step: 1e-3, 1e-4, 1e-5). Run again at a higher setting to confirm that a result does not depend on the step size; higher is slower.',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const PROBES = {
|
|
95
|
+
type: 'array',
|
|
96
|
+
items: { type: 'string' },
|
|
97
|
+
description: 'Signals to show in the waveform viewer: V(net), I(part), P(part), Ploss(part).',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const REVISION = {
|
|
101
|
+
type: 'number',
|
|
102
|
+
description: 'Revision of the circuit this call is based on (from get_circuit or the previous reply). The call fails if the circuit changed since.',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const LOCAL_TOOLS = [
|
|
106
|
+
{
|
|
107
|
+
name: 'open_editor',
|
|
108
|
+
description:
|
|
109
|
+
'Connect to the Ec-Sim editor in the user\'s browser. Call this first. Returns whether a tab is linked and, if not, the pairing link; by default it also opens the link in the default browser and waits for the user to press Connect. The user watches every change you make in that tab.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
launch: { type: 'boolean', description: 'Open the pairing link in the default browser when no tab is linked (default true).' },
|
|
114
|
+
wait_s: { type: 'number', description: 'Seconds to wait for the tab to link (default 60, max 300).' },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'export_waveforms',
|
|
120
|
+
description:
|
|
121
|
+
'Write the data of the last simulation to a CSV file on this computer and return its path: one row per solver time point (no resampling), first column time in seconds (or frequency in Hz), one column per signal. Use it when you need the full data, for a script or a spreadsheet; read the file with your own tools. signals may be recorded signals or math expressions; left out, every recorded V, I, P and Ploss signal is written (at most 64). from/to limit the rows to a part of the run. Without "path" the file goes to a new file in the system temp folder.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
signals: { type: 'array', items: { type: 'string' } },
|
|
126
|
+
from: { type: 'number' },
|
|
127
|
+
to: { type: 'number' },
|
|
128
|
+
path: { type: 'string', description: 'Absolute path of the file to write; must end in .csv.' },
|
|
129
|
+
overwrite: { type: 'boolean', description: 'Replace the file at "path" if it exists (default false).' },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/** Calls the server makes to the tab on its own behalf; they are not offered as tools. */
|
|
136
|
+
export const INTERNAL_METHODS = ['waveform_rows'];
|
|
137
|
+
|
|
138
|
+
export const EDITOR_TOOLS = [
|
|
139
|
+
{
|
|
140
|
+
name: 'list_component_types',
|
|
141
|
+
description: 'Part types the simulator has, with their pin names, where each pin is relative to the part\'s centre for every rotation (pin_offsets, in grid steps), the unit of their main value, and their parameters (SI units and defaults).',
|
|
142
|
+
inputSchema: { type: 'object', properties: {} },
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'list_parts',
|
|
146
|
+
description:
|
|
147
|
+
'The parts library of the editor (real diodes, zeners, LEDs, BJTs, MOSFETs, JFETs, op-amps) as a table in SI units. Without "type": the categories and how many parts each has. With "type": the table of that category. Narrow it with "where" (limits on any column, in SI: {"bv": {"min": 60}, "rdsOn": {"max": 0.02}}), order it with "sort" (a column name; "-rdsOn" for high to low), and filter by words with "search" (must appear in the part number, description or technology, e.g. "schottky" or "logic-level"). Example: the lowest-resistance N-channel MOSFETs for at least 60 V and 20 A: {"type": "nmos", "where": {"bv": {"min": 60}, "idMax": {"min": 20}}, "sort": "rdsOn", "limit": 10}. Place a part from the table with "model" in a part.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
type: { type: 'string', description: 'Part type, e.g. nmos, diode, opamp.' },
|
|
152
|
+
search: { type: 'string' },
|
|
153
|
+
where: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
description: 'Column name -> {"min": number, "max": number} (either or both), in the SI unit of the column. A part without a value for a limited column is left out.',
|
|
156
|
+
additionalProperties: { type: 'object', properties: { min: { type: 'number' }, max: { type: 'number' } } },
|
|
157
|
+
},
|
|
158
|
+
sort: { type: 'string', description: 'Column to sort by, low to high; put "-" in front for high to low.' },
|
|
159
|
+
limit: { type: 'integer', description: 'Rows to return (default 50, max 200).' },
|
|
160
|
+
offset: { type: 'integer', description: 'Rows to skip (for the next page).' },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'list_examples',
|
|
166
|
+
description: 'Built-in example circuits (keys for load_example).',
|
|
167
|
+
inputSchema: { type: 'object', properties: {} },
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'load_example',
|
|
171
|
+
description: 'Replace the circuit in the editor with a built-in example and return it.',
|
|
172
|
+
inputSchema: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'get_circuit',
|
|
176
|
+
description:
|
|
177
|
+
'The circuit currently in the editor as a netlist: parts with values, parameters and the net on each pin, the simulation settings, the probes, the math waveforms, the design notes, the result of the electrical rule check, and the revision. Works for circuits the user drew by hand too.',
|
|
178
|
+
inputSchema: { type: 'object', properties: {} },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'set_circuit',
|
|
182
|
+
description:
|
|
183
|
+
'Replace the circuit in the editor. Give either "parts" (structured netlist) or "spice" (a SPICE deck). The editor draws it as a schematic: you choose where each part goes ("at", "rotation") and the editor draws the wires, falling back to a net label only where parts are far apart or the way is blocked. Plan the placement before you call this (LAYOUT in the server instructions): a good placement gives short straight wires, a careless one gives detours and crossings. This is the first step of every new circuit; afterwards look at a screenshot and tidy single parts and wires by hand (move_part, remove_wire, draw_wire, move_label). Returns the electrical rule check, the new revision, and "drawing" notes naming any nets that needed labels. The user can undo it.',
|
|
184
|
+
inputSchema: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
name: { type: 'string' },
|
|
188
|
+
parts: { type: 'array', items: PART },
|
|
189
|
+
spice: { type: 'string', description: 'SPICE deck text (R, C, L, V, I, D, Q, M, J, E, G, F, H, K, .model, .subckt, .tran/.ac/.op).' },
|
|
190
|
+
simulation: SIMULATION,
|
|
191
|
+
probes: PROBES,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'edit_circuit',
|
|
197
|
+
description:
|
|
198
|
+
'Change the circuit in place; all operations succeed or none is applied. Operations: {"op":"set_value","part":"R1","value":"2.2k"}, {"op":"set_params","part":"M1","params":{"rdsOn":0.02}} (null removes a parameter), {"op":"set_model","part":"M1","model":"IRLZ44N"} (a library part from list_parts), {"op":"add_part","part":{...}} (wired to its nets where a clear path exists), {"op":"remove_part","part":"C3"}, {"op":"move_part","part":"R2","at":{"col":3,"row":1},"rotation":90} (any of at, centre, line_up, rotation, mirrorX, mirrorY; its wires are drawn again. move_part and connect operations that follow each other are made together: places are checked as they end up, so parts can swap places or take a cell another part of the same call leaves, with no need to park anything first), {"op":"connect","part":"R2","nets":{"p2":"out"}} (moves the listed pins to other nets; null disconnects a pin; other pins keep their nets). Use move_part to tidy a layout after looking at the screenshot.',
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {
|
|
202
|
+
revision: REVISION,
|
|
203
|
+
operations: {
|
|
204
|
+
type: 'array',
|
|
205
|
+
items: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
properties: {
|
|
208
|
+
op: { type: 'string', enum: ['set_value', 'set_params', 'set_model', 'add_part', 'remove_part', 'move_part', 'connect'] },
|
|
209
|
+
part: { description: 'Part id (set_value, set_params, remove_part) or a part object (add_part).' },
|
|
210
|
+
value: { type: ['number', 'string'] },
|
|
211
|
+
params: { type: 'object' },
|
|
212
|
+
model: { type: 'string' },
|
|
213
|
+
at: { type: 'object', properties: { col: { type: 'integer' }, row: { type: 'integer' } } },
|
|
214
|
+
rotation: { type: 'number', enum: [0, 90, 180, 270] },
|
|
215
|
+
mirrorX: { type: 'boolean' },
|
|
216
|
+
mirrorY: { type: 'boolean' },
|
|
217
|
+
line_up: { type: 'object', properties: { pin: { type: 'string' }, with: { type: 'string' } } },
|
|
218
|
+
centre: { type: 'object', properties: { x: { type: 'integer' }, y: { type: 'integer' } } },
|
|
219
|
+
nets: { type: 'object', description: 'connect: pin name -> net name, or null to disconnect the pin.' },
|
|
220
|
+
},
|
|
221
|
+
required: ['op', 'part'],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
required: ['revision', 'operations'],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'set_probes',
|
|
230
|
+
description: 'Choose the signals shown in the editor\'s waveform viewer (replaces the current probes). run_simulation reports numbers for these.',
|
|
231
|
+
inputSchema: { type: 'object', properties: { signals: PROBES }, required: ['signals'] },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'get_drawing',
|
|
235
|
+
description:
|
|
236
|
+
'The drawing with positions, for wiring by hand: every part with its cell, rotation, and each pin\'s position and net (null = nothing drawn on the pin yet); every ground symbol and net label; every probe; every wire with its id, its corner points and its net. Positions are in grid steps of 20 px: a cell is 16 steps wide and 12 high, and the part in cell (col, row) is centred at (10 + 16 col, 10 + 12 row); x grows to the right, y downwards. Use get_circuit for the netlist itself.',
|
|
237
|
+
inputSchema: { type: 'object', properties: {} },
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'place_part',
|
|
241
|
+
description:
|
|
242
|
+
'Place one part on the schematic without wires, to wire it yourself with draw_wire (for adding to a circuit; a new circuit starts with set_circuit). Give "at" (cell) and, where needed, "rotation", "mirrorX" or "mirrorY"; the reply lists where its pins are, in grid steps, ready for draw_wire. "part" is as in set_circuit but without "nets". Left-out "at": the next free cell.',
|
|
243
|
+
inputSchema: { type: 'object', properties: { part: PART_BARE, revision: REVISION_OPTIONAL }, required: ['part'] },
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: 'draw_wire',
|
|
247
|
+
description:
|
|
248
|
+
'Draw one wire, exactly where you say: to replace an automatic wire you removed, or to connect a part you placed. "from" and "to" are pins ("R1.p2") or points in grid steps; "via" adds corner points in between. Two consecutive points that are not in line get one corner between them: the horizontal part first, or the vertical part with "first": "vertical". A wire connects at its two ends (to a pin, to another wire\'s end, or to the middle of another wire, which makes a junction dot) and where a pin lies on it; wires that only cross are not connected. Nothing is snapped: a wire through a part is refused. The reply says which pins the wire now connects and the net they are on, and warns if it bridged a part, joined two named nets, crossed other wires or left an end open. Read it: it is your check that the wire did what you meant.',
|
|
249
|
+
inputSchema: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
from: { description: 'A pin, written "R1.p2", or a point {"x": ..., "y": ...} in grid steps.' },
|
|
253
|
+
to: { description: 'A pin, written "R1.p2", or a point {"x": ..., "y": ...} in grid steps.' },
|
|
254
|
+
via: { type: 'array', items: { description: 'A pin, written "R1.p2", or a point {"x": ..., "y": ...} in grid steps.' }, description: 'Corner points between from and to, in order.' },
|
|
255
|
+
first: { type: 'string', enum: ['horizontal', 'vertical'], description: 'Which way to leave a point when the next one is not in line (default horizontal).' },
|
|
256
|
+
revision: REVISION_OPTIONAL,
|
|
257
|
+
},
|
|
258
|
+
required: ['from', 'to'],
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: 'remove_wire',
|
|
263
|
+
description:
|
|
264
|
+
'Remove a wire by its id (from draw_wire or get_drawing), or only one of its segments: "segment" 0 is the one that starts at its first point. Removing a middle segment leaves two shorter wires. The reply lists what is left and says if a net fell apart.',
|
|
265
|
+
inputSchema: {
|
|
266
|
+
type: 'object',
|
|
267
|
+
properties: { wire: { type: 'string' }, segment: { type: 'integer', minimum: 0 }, revision: REVISION_OPTIONAL },
|
|
268
|
+
required: ['wire'],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: 'place_symbol',
|
|
273
|
+
description:
|
|
274
|
+
'Place a ground symbol or a net label with its connection point exactly at "at" (a pin, or a point in grid steps; usually the end of a wire). A ground symbol hangs down from the point and makes the net ground ("0"). A net label names the net ("name"); labels with the same name are one net without a wire between them, and V(name) refers to it. "side" puts a label\'s text left or right of the point.',
|
|
275
|
+
inputSchema: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
properties: {
|
|
278
|
+
type: { type: 'string', enum: ['ground', 'net_label'] },
|
|
279
|
+
name: { type: 'string', description: 'Net name (net_label only).' },
|
|
280
|
+
at: { description: 'A pin, written "R1.p2", or a point {"x": ..., "y": ...} in grid steps.' },
|
|
281
|
+
side: { type: 'string', enum: ['left', 'right'] },
|
|
282
|
+
revision: REVISION_OPTIONAL,
|
|
283
|
+
},
|
|
284
|
+
required: ['type', 'at'],
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'move_label',
|
|
289
|
+
description:
|
|
290
|
+
'Move a piece of text so the drawing reads cleanly. Name exactly one of: "part" (the designator and value text of a part: give "offset" in grid steps from where the editor puts it, x to the right and y downwards, fractions allowed; {"x": 0, "y": 0} puts it back), "probe" (the badge of a probe such as "V(out)": give "offset" the same way), or "symbol" (a net label or ground symbol, by its id: give "at", the pin or point its connection point moves to, and for a label optionally "side" to put the text left or right). Moving text never changes the circuit. Moving a net label or ground symbol can, because it names the net it touches: the reply says if any net changed. Use it after a screenshot shows text over a wire, a part or other text.',
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
properties: {
|
|
294
|
+
part: { type: 'string' },
|
|
295
|
+
probe: { type: 'string' },
|
|
296
|
+
symbol: { type: 'string' },
|
|
297
|
+
offset: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' } }, required: ['x', 'y'] },
|
|
298
|
+
at: { description: 'A pin, written "R1.p2", or a point {"x": ..., "y": ...} in grid steps.' },
|
|
299
|
+
side: { type: 'string', enum: ['left', 'right'] },
|
|
300
|
+
revision: REVISION_OPTIONAL,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: 'remove_symbol',
|
|
306
|
+
description: 'Remove a ground symbol or net label by its id (from place_symbol or get_drawing).',
|
|
307
|
+
inputSchema: { type: 'object', properties: { symbol: { type: 'string' }, revision: REVISION_OPTIONAL }, required: ['symbol'] },
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: 'set_design_notes',
|
|
311
|
+
description:
|
|
312
|
+
'Write the design notes of the project: free text the user reads under Edit > Design Notes and that is saved with the project (get_circuit returns it as "notes"). Use it to record the requirements, the decisions you took and why, and the measured results, so the reasoning stays with the design. "append": true adds a paragraph to what is there, which keeps what the user wrote; without it the text is replaced. At most 20000 characters.',
|
|
313
|
+
inputSchema: {
|
|
314
|
+
type: 'object',
|
|
315
|
+
properties: { text: { type: 'string' }, append: { type: 'boolean' } },
|
|
316
|
+
required: ['text'],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'set_math_waveforms',
|
|
321
|
+
description: [
|
|
322
|
+
'Choose the math waveforms shown in the editor\'s waveform viewer (replaces the current ones; an empty list removes them). Each is a named expression over the signals of a transient result, drawn as its own trace. Returns min/max/average/rms/final of each when a result exists. The same expressions work as "signal" in measure and get_waveform.',
|
|
323
|
+
'Syntax: signals V(net), I(part), P(part), Ploss(part), used with or without a probe; t is time in seconds.',
|
|
324
|
+
'Operators: + - * / ^ %, comparisons < > == !=, logic && || !, and cond ? a : b.',
|
|
325
|
+
'Over time: avg(x) and rms(x) are running values from the start, avg(x, T) and rms(x, T) a sliding window of T seconds (one switching period), d/dt(x) the derivative, integ(x) the integral, smooth(x, N) a moving average over N samples.',
|
|
326
|
+
'Functions: abs sqrt cbrt pow hypot exp ln log10 log2 min max clamp sign floor ceil round trunc sin cos tan asin acos atan atan2 sinh cosh tanh. Constants: pi, e. Numbers take SI suffixes p n u m k M meg (e.g. 1.25u).',
|
|
327
|
+
'A later waveform may use an earlier one by its name. Examples: "V(in) - V(out)"; "integ(Ploss(M1))" (energy in J); "avg(P(R1), 10u) / avg(-P(V1), 10u) * 100" (efficiency in %).',
|
|
328
|
+
].join(' '),
|
|
329
|
+
inputSchema: {
|
|
330
|
+
type: 'object',
|
|
331
|
+
properties: {
|
|
332
|
+
waveforms: {
|
|
333
|
+
type: 'array',
|
|
334
|
+
items: {
|
|
335
|
+
type: 'object',
|
|
336
|
+
properties: {
|
|
337
|
+
name: { type: 'string', description: 'Trace name: a letter, then letters, digits or _ (e.g. "eff", "v_drop").' },
|
|
338
|
+
expression: { type: 'string' },
|
|
339
|
+
unit: { type: 'string', description: 'Unit shown on the axis; inferred from the expression when left out.' },
|
|
340
|
+
},
|
|
341
|
+
required: ['name', 'expression'],
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
required: ['waveforms'],
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
name: 'run_simulation',
|
|
350
|
+
description:
|
|
351
|
+
'Run the simulation in the editor (the user sees the waveforms) and return its status, warnings, errors with the node or device at fault, the list of recorded signals, and min/max/average/rms/final for each probed signal. Optionally change the simulation settings first. Waveforms are not returned; use measure or get_waveform.',
|
|
352
|
+
inputSchema: {
|
|
353
|
+
type: 'object',
|
|
354
|
+
properties: {
|
|
355
|
+
simulation: SIMULATION,
|
|
356
|
+
timeout_s: { type: 'number', description: 'Stop the run after this many seconds (default 120, max 600).' },
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: 'measure',
|
|
362
|
+
description:
|
|
363
|
+
'Numbers from the last simulation result. signal is a recorded signal such as V(out) or I(L1), or (transient) a math expression such as "V(out)*I(R1)". Measurements: min, max, peak_to_peak, average, rms, initial, final, value_at (needs at), rise_time, fall_time (10-90%), frequency, period, duty_cycle, overshoot, settling_time (band, default 0.02), and for AC results cutoff_3db and phase_at (needs at, in Hz). AC magnitudes are in dB. from/to limit the window (seconds, or Hz for AC).',
|
|
364
|
+
inputSchema: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
signal: { type: 'string' },
|
|
368
|
+
measurements: { type: 'array', items: { type: 'string' } },
|
|
369
|
+
from: { type: 'number' },
|
|
370
|
+
to: { type: 'number' },
|
|
371
|
+
at: { type: 'number' },
|
|
372
|
+
band: { type: 'number' },
|
|
373
|
+
},
|
|
374
|
+
required: ['signal'],
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
name: 'get_waveform',
|
|
379
|
+
description: 'The plotted data: points of up to 8 signals from the last result, resampled to at most max_points (default 200, max 2000). from/to return only that part of the run (seconds, or Hz for AC), at full detail within the window. Prefer measure when a number answers the question.',
|
|
380
|
+
inputSchema: {
|
|
381
|
+
type: 'object',
|
|
382
|
+
properties: {
|
|
383
|
+
signals: { type: 'array', items: { type: 'string' } },
|
|
384
|
+
max_points: { type: 'number' },
|
|
385
|
+
from: { type: 'number' },
|
|
386
|
+
to: { type: 'number' },
|
|
387
|
+
},
|
|
388
|
+
required: ['signals'],
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
name: 'screenshot',
|
|
393
|
+
description:
|
|
394
|
+
'A PNG picture of what the user sees. view "schematic" (default): the whole drawing with its probes, always fitted to the picture whatever the user\'s zoom; give "parts" to get a close-up of the area around those parts instead (useful when a large circuit is too small to read). view "waveforms": the plot panes of the waveform viewer as shown now, with their zoom and cursors (needs a simulation result). Look at the schematic after every set_circuit and after adding parts: check that parts and labels do not overlap, that wires are short and readable, and that the signal flows left to right; if not, place the parts again with "at" and "rotation". For numbers use measure or get_waveform.',
|
|
395
|
+
inputSchema: {
|
|
396
|
+
type: 'object',
|
|
397
|
+
properties: {
|
|
398
|
+
view: { type: 'string', enum: ['schematic', 'waveforms'] },
|
|
399
|
+
parts: { type: 'array', items: { type: 'string' }, description: 'Schematic only: part ids to zoom in on, e.g. ["M1", "D1"].' },
|
|
400
|
+
max_width: { type: 'integer', description: 'Largest width of the picture in pixels (default 1400, max 2400).' },
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: 'export',
|
|
406
|
+
description: 'Export the circuit: "spice" (netlist text), "bom" (CSV), or "share_link" (a URL that opens a copy of the circuit in the editor).',
|
|
407
|
+
inputSchema: { type: 'object', properties: { format: { type: 'string', enum: ['spice', 'bom', 'share_link'] } }, required: ['format'] },
|
|
408
|
+
},
|
|
409
|
+
];
|
|
410
|
+
|
|
411
|
+
export const INSTRUCTIONS = [
|
|
412
|
+
'Ec-Sim is a circuit simulator that runs in the user\'s browser. This server links you to an open editor tab: the circuit lives there, the simulation runs there, and the user watches every part and wire appear as you work.',
|
|
413
|
+
'Start with open_editor, then list_component_types (once). All numbers are SI. Ground is net "0". The user can edit the circuit too: if a call reports that the revision changed, call get_circuit and continue from what is there.',
|
|
414
|
+
'',
|
|
415
|
+
'HOW TO BUILD A CIRCUIT. Draw the whole circuit in one go, then tidy it by hand like an engineer cleaning up a sketch:',
|
|
416
|
+
'1. Plan the placement first (LAYOUT below): decide the cell and rotation of every part.',
|
|
417
|
+
'2. Draw it with one set_circuit call: every part with its "at", "rotation" and nets. The editor places the parts and wires them. Check "check" for errors.',
|
|
418
|
+
'3. Call screenshot and look at the result critically, as the user will.',
|
|
419
|
+
'4. Fix what does not read cleanly, one change at a time, so the user can follow: move_part for a part in the wrong place or out of line ("line_up" puts a pin exactly in line with the pin it connects to); remove_wire and draw_wire for a wire with a detour, a jog or a needless crossing; move_label for text or a net label that sits on something. After a few fixes take another screenshot. Stop when the drawing reads cleanly from left to right; do not redraw what is already good. To rearrange a whole block, put all its move_part operations in one edit_circuit call: they are applied together and the wires are drawn once.',
|
|
420
|
+
'5. After changing wires by hand, call get_circuit and compare the net on every pin with the circuit you intended: read every draw_wire reply too, it lists the pins the wire connected.',
|
|
421
|
+
'6. Then set_probes -> run_simulation -> measure. Change values with edit_circuit and run again.',
|
|
422
|
+
'To extend a circuit later, add parts one at a time: edit_circuit add_part wires the part for you, place_part places it bare for wiring with draw_wire. Building a whole circuit with place_part and draw_wire is possible and fine for a handful of parts, but slower; a SPICE deck or a large circuit always starts with set_circuit.',
|
|
423
|
+
'',
|
|
424
|
+
'LAYOUT. You place the parts, in cells: col grows to the right, row downwards, and a part sits in the middle of its cell, so a row or a column lines up exactly.',
|
|
425
|
+
'- Signal flow runs left to right: sources and inputs in column 0, each following stage one column to the right, the load or output last.',
|
|
426
|
+
'- One main row carries the signal path. Parts in series with the signal (series resistor, inductor, switch, coupling capacitor) lie in that row at rotation 0, one per column, in the order the signal meets them.',
|
|
427
|
+
'- Parts from a node to ground or to a rail (shunt capacitor, load resistor, divider leg, freewheel diode) stand upright (rotation 90 or 270) in the row directly below the node they hang from, in the same column or the next, so the wire to them is one short line. Parts up to a supply rail go in the row directly above.',
|
|
428
|
+
'- Feedback parts go in the row above the stage they bridge, in the column of that stage.',
|
|
429
|
+
'- Keep rows and columns tidy: use consecutive cells, do not leave gaps or stagger parts. Two parts that share a net belong in neighbouring cells; if a net joins many parts, line them up along one row or column.',
|
|
430
|
+
'- Rotate or mirror a part so its pins face what they connect to ("rotation"; "mirrorX" swaps left and right, "mirrorY" top and bottom). list_component_types gives the pins; place_part and get_drawing give their positions.',
|
|
431
|
+
'- A source stands in column 0 with its negative pin to ground; a second supply gets its own cell in column 0, above or below.',
|
|
432
|
+
'- Pins are not all on the centre line of a part: a resistor\'s pins are, a transistor\'s drain and source are one grid step to the side (list_component_types gives pin_offsets for every rotation). Two parts in the same row or column can therefore have connected pins one or two steps out of line, which forces an ugly jog in the wire. Prevent it when placing: give "line_up": {"pin": "p2", "with": "M1.drain"} and the part is shifted so those two pins are exactly in line. Place the part with the most pins first (the transistor, the op-amp), then line the two-pin parts up with it.',
|
|
433
|
+
'',
|
|
434
|
+
'WIRES. Positions are in grid steps of 20 px; a cell is 16 steps wide and 12 high, and the part in cell (col, row) is centred at (10 + 16 col, 10 + 12 row).',
|
|
435
|
+
'- Wires are horizontal and vertical only, as short as the placement allows, with as few corners as possible. Two pins on one line are joined by one straight wire. If a wire between neighbouring parts needs a jog of one or two steps, the parts are out of line: fix the part with move_part and "line_up" instead of drawing the jog.',
|
|
436
|
+
'- Lead a wire two grid steps straight out of a pin before it turns or meets another wire. Extend that only when other connections need the room.',
|
|
437
|
+
'- Avoid crossings. Where one cannot be avoided, both wires run straight for at least two grid steps on each side of it.',
|
|
438
|
+
'- Join a third pin to a net by ending its wire on the existing wire (a junction), two grid steps or more from the nearest pin or corner, not by a second wire into the same pin.',
|
|
439
|
+
'- Never run a wire over a part or along another wire. Go around, with "via" points.',
|
|
440
|
+
'- A ground symbol goes two grid steps below a pin that points down (draw the short wire, then place the symbol at its end). Prefer one ground symbol per ground pin over long ground wires.',
|
|
441
|
+
'- Name the nets you will measure (in, out, sw ...) with one net label each, at a wire end or on a short stub, where its text is clear of parts.',
|
|
442
|
+
'These are the rules to judge the automatic wiring by and to follow when you redraw a wire. In a screenshot: parts and text must not overlap, wires must be short and straight, rows and columns must line up, and the drawing must read from left to right. Tidy what does not (remove_wire and redraw; move_part for a part; move_label for text or a net label that sits on a wire, a part or other text).',
|
|
443
|
+
'',
|
|
444
|
+
'For a real semiconductor or op-amp, select it with list_parts ("where" and "sort" pick by voltage, current, resistance and so on) and name it with "model" instead of typing datasheet values.',
|
|
445
|
+
'Keep the design notes current with set_design_notes (requirements, decisions, measured results). For data too large to read, export_waveforms writes a CSV file.',
|
|
446
|
+
].join('\n');
|