dsh-eda-mcp 0.7.0 → 0.7.2

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.
@@ -16,11 +16,15 @@ import {
16
16
  TRACK_WIDTH_MIL, VIA_HOLE_ABSOLUTE_MIN_MM, VIA_HOLE_MIN_MM, VIA_SIZES_MM,
17
17
  assertNetName, checkNetName, checkTrackWidth, checkVia, classifyNet, milToMm, suggestTrackWidthMil,
18
18
  } from './design-rules.ts'
19
+ import { WORKFLOW_GUIDE, WORKFLOW_SUMMARY } from './workflow-guide.ts'
19
20
 
20
21
  const VERSION = '0.4.0'
21
22
  const WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009)
22
23
  const WS_HOST = '127.0.0.1'
23
24
  const ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? '1') !== '0'
25
+ // Automatic layout/routing is opt-in: placing parts and routing by hand is the
26
+ // default working style (visual PNG check beats letting the app rearrange a sheet).
27
+ const ALLOW_AUTO_LAYOUT = (process.env.DSH_EDA_ALLOW_AUTO_LAYOUT ?? '0') === '1'
24
28
  const TOOL_TIMEOUT_MS = Number(process.env.DSH_EDA_MCP_TOOL_TIMEOUT_MS ?? 60000)
25
29
 
26
30
  const MCP_UNITS_PER_INCH = 100
@@ -50,6 +54,15 @@ function safeAreas(paperName) {
50
54
  }
51
55
  }
52
56
 
57
+ /** Decode a text payload that may arrive as UTF-16LE (BOM or interleaved NULs). */
58
+ function decodeTextPayload(value) {
59
+ const text = typeof value === 'string' ? value : ''
60
+ if (text === '') return text
61
+ const looksUtf16 = text.charCodeAt(0) === 0xfeff || (text.length > 3 && text.charCodeAt(1) === 0 && text.charCodeAt(3) === 0)
62
+ if (!looksUtf16) return text
63
+ return Buffer.from(text, 'latin1').toString('utf16le').replace(String.fromCharCode(0xfeff), '')
64
+ }
65
+
53
66
  function checkPointInFrame(x, y, paperName, margin = 0) {
54
67
  const paper = paperOf(paperName)
55
68
  const f = paper.frame
@@ -264,11 +277,11 @@ const TOOLS = [
264
277
  }, ['libraryUuid', 'uuid', 'x', 'y']) },
265
278
  { name: 'eda_place_net_flag', description: 'Place a power/ground net flag (VCC/GND etc.) on the active schematic page.', inputSchema: objectSchema({ identification: { type: 'string', enum: ['Power', 'Ground', 'AnalogGround', 'ProtectGround'] }, net: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, mirror: { type: 'boolean' }, paper: { type: 'string' }, allowOutsideFrame: { type: 'boolean' } }, ['identification', 'net', 'x', 'y']) },
266
279
  { name: 'eda_place_net_port', description: 'Place a net port on the active schematic page.', inputSchema: objectSchema({ direction: { type: 'string', enum: ['IN', 'OUT', 'BI'] }, net: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, mirror: { type: 'boolean' }, paper: { type: 'string' }, allowOutsideFrame: { type: 'boolean' } }, ['direction', 'net', 'x', 'y']) },
267
- { name: 'eda_get_pins', description: 'List pins of one schematic component. Use the returned primitiveId with eda_place_net_flag_at_pin, eda_place_net_label_at_pin, or eda_set_no_connect.', inputSchema: objectSchema({ componentPrimitiveId: { type: 'string' } }, ['componentPrimitiveId']) },
280
+ { name: 'eda_get_pins', description: 'List pins of one schematic component in a single round trip, including pinName and pinNumber (no per-pin sch_PrimitivePin.get needed). Use the returned primitiveId with eda_place_net_flag_at_pin, eda_place_net_label_at_pin, or eda_set_no_connect.', inputSchema: objectSchema({ componentPrimitiveId: { type: 'string' } }, ['componentPrimitiveId']) },
268
281
  { name: 'eda_place_net_flag_at_pin', description: 'Place a power/ground net flag at a pin WITHOUT covering the symbol: the pin direction is read from the pin rotation, a stub wire of stubLength MCP units (default 20) is drawn outwards from the pin with the net name, and the flag is placed at its end. Pass offsetX/offsetY to place it manually (that disables the stub) or stubLength=0 for the old on-pin behaviour.', inputSchema: objectSchema({ pinPrimitiveId: { type: 'string' }, identification: { type: 'string', enum: ['Power', 'Ground', 'AnalogGround', 'ProtectGround'] }, net: { type: 'string' }, stubLength: { type: 'number' }, offsetX: { type: 'number' }, offsetY: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, mirror: { type: 'boolean' }, color: { type: 'string' }, lineWidth: { type: 'integer' }, lineType: { type: 'integer' }, paper: { type: 'string' }, allowOutsideFrame: { type: 'boolean' } }, ['pinPrimitiveId', 'identification', 'net']) },
269
282
  { name: 'eda_place_net_label_at_pin', description: 'Place a REAL net label at a pin (sch_PrimitiveAttribute.createNetLabel; falls back to text and reports method) WITHOUT covering the symbol: a stub wire of stubLength MCP units (default 20) is drawn outwards along the pin direction and the label goes at its end. offsetX/offsetY place it manually (disables the stub); stubLength=0 keeps the old on-pin behaviour.', inputSchema: objectSchema({ pinPrimitiveId: { type: 'string' }, net: { type: 'string' }, stubLength: { type: 'number' }, offsetX: { type: 'number' }, offsetY: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, fontSize: { type: 'number' }, color: { type: 'string' }, lineWidth: { type: 'integer' }, lineType: { type: 'integer' }, paper: { type: 'string' }, allowOutsideFrame: { type: 'boolean' } }, ['pinPrimitiveId', 'net']) },
270
283
  { name: 'eda_set_no_connect', description: 'Set or clear the no-connect flag on one or more schematic pins via sch_PrimitivePin.modify.', inputSchema: objectSchema({ primitiveIds: { type: 'array', items: { type: 'string' } }, noConnected: { type: 'boolean' } }, ['primitiveIds']) },
271
- { name: 'eda_draw_wire', description: 'Draw a schematic wire (polyline). Coordinates are MCP units (0.01 inch).', inputSchema: objectSchema({ points: { type: 'array', items: { type: 'number' } }, net: { type: 'string' }, color: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] } }, ['points']) },
284
+ { name: 'eda_draw_wire', description: 'Draw a schematic wire (polyline). Coordinates are MCP units (0.01 inch). IMPORTANT: the net argument only sticks on a floating wire — as soon as an endpoint touches a pin, EDA recomputes the net from connectivity and the name you passed is overwritten (measured: TESTNET merged into LED_A). To join same-named nets across regions use eda_place_net_port or a net label; do not rely on two distantly drawn wires sharing a name.', inputSchema: objectSchema({ points: { type: 'array', items: { type: 'number' } }, net: { type: 'string' }, color: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] } }, ['points']) },
272
285
  { name: 'eda_draw_rectangle', description: 'Draw a rectangle. topLeftY is the UPPER y coordinate (max y); height extends downward. Coordinates are MCP units.', inputSchema: objectSchema({ topLeftX: { type: 'number' }, topLeftY: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' }, cornerRadius: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, color: { type: 'string' }, fillColor: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] }, fillStyle: { type: 'string' } }, ['topLeftX', 'topLeftY', 'width', 'height']) },
273
286
  { name: 'eda_draw_functional_box', description: 'Draw a dashed functional-group box around one group of components, with an optional label. Margins and coordinates are MCP units; default margin 1 unit (about 0.254 mm).', inputSchema: objectSchema({ componentIds: { type: 'array', items: { type: 'string' } }, label: { type: 'string' }, margin: { type: 'number' }, color: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] } }) },
274
287
  { name: 'eda_draw_circle', description: 'Draw a circle. Coordinates are MCP units.', inputSchema: objectSchema({ centerX: { type: 'number' }, centerY: { type: 'number' }, radius: { type: 'number' }, color: { type: 'string' }, fillColor: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] }, fillStyle: { type: 'string' } }, ['centerX', 'centerY', 'radius']) },
@@ -281,14 +294,18 @@ const TOOLS = [
281
294
  { name: 'eda_get_nets', description: 'List nets of the current schematic project. Uses sch_Net.* first; when the schematic is not the active document those APIs return [] and this tool falls back to parsing sch_Netlist.getNetlist, reporting which source was used.', inputSchema: objectSchema({ name: { type: 'string' }, parseNetlist: { type: 'boolean' } }) },
282
295
  { name: 'eda_get_active_document', description: 'Return the document/tab that is currently active (documentType, uuid, tabId, parent project). Most tab-dependent APIs (sch_Net.*, netlist export, DRC) only work when the matching document is active - use eda_open_document to switch.', inputSchema: objectSchema({}) },
283
296
  { name: 'eda_open_document', description: 'Open/activate a document tab by uuid (dmt_EditorControl.openDocument). Works for a schematic page or a PCB, so tab-dependent APIs can run.', inputSchema: objectSchema({ documentUuid: { type: 'string' }, splitScreenId: { type: 'string' } }, ['documentUuid']) },
284
- { name: 'eda_get_net_labels', description: 'List net-label / attribute primitives on the active schematic page (sch_PrimitiveAttribute.getAll), optionally for one parent primitive.', inputSchema: objectSchema({ parentPrimitiveId: { type: 'string' } }) },
285
- { name: 'eda_place_net_label', description: 'Place a real net label at x/y via sch_PrimitiveAttribute.createNetLabel (falls back to a plain text label when that API is missing, and reports which method was used). Coordinates are MCP units.', inputSchema: objectSchema({ x: { type: 'number' }, y: { type: 'number' }, net: { type: 'string' }, fontSize: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] } }, ['x', 'y', 'net']) },
286
- { name: 'eda_run_drc', description: 'Run schematic DRC and return the verbose result array.', inputSchema: objectSchema({ strict: { type: 'boolean' } }) },
287
- { name: 'eda_auto_layout', description: 'Run schematic auto-layout (sch_Document.autoLayout).', inputSchema: objectSchema({ uuids: { type: 'array', items: { type: 'string' } } }) },
288
- { name: 'eda_auto_route', description: 'Run schematic auto-routing (sch_Document.autoRouting).', inputSchema: objectSchema({ uuids: { type: 'array', items: { type: 'string' } } }) },
297
+ { name: 'eda_get_net_labels', description: 'List every label-like primitive on the active schematic page: attribute primitives (netlabel/netport/netflag) PLUS plain texts left by the createNetLabel fallback, so they can be audited and deleted. Reports kind/primitiveId/net per entry.', inputSchema: objectSchema({ parentPrimitiveId: { type: 'string' } }) },
298
+ { name: 'eda_place_net_label', description: 'Place a net label at x/y. The real API (sch_PrimitiveAttribute.createNetLabel) is probed once per session; when the build never answers, a plain TEXT label is placed instead, which carries no electrical meaning and must be paired with a stub wire. Pass probe:true to re-check.', inputSchema: objectSchema({ x: { type: 'number' }, y: { type: 'number' }, net: { type: 'string' }, fontSize: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] } }, ['x', 'y', 'net']) },
299
+ { name: 'eda_place_net_labels', description: 'Place many net labels in ONE call (per-pin or by coordinate) and report done/failed per item: a 133-label schematic used to exceed the caller timeout with no progress. items: [{ pinPrimitiveId, net, stubLength?, offsetX?, offsetY? }] or [{ x, y, net, fontSize? }].', inputSchema: objectSchema({ items: { type: 'array', items: { type: 'object', additionalProperties: true } }, probe: { type: 'boolean' } }, ['items']) },
300
+ { name: 'eda_run_drc', description: 'Run schematic DRC with includeVerboseError and return one entry per problem (type, message, primitiveId, x/y plus the raw record) instead of bare counts.', inputSchema: objectSchema({ strict: { type: 'boolean' } }) },
301
+ { name: 'eda_auto_layout', description: 'Run schematic auto-layout (sch_Document.autoLayout). DISABLED by default: automated layout is not used unless the user asks for it. Pass force:true only then.', inputSchema: objectSchema({ uuids: { type: 'array', items: { type: 'string' } }, force: { type: 'boolean' } }) },
302
+ { name: 'eda_auto_route', description: 'Run schematic auto-routing (sch_Document.autoRouting). DISABLED by default: route by hand unless the user explicitly asks. Pass force:true only then.', inputSchema: objectSchema({ uuids: { type: 'array', items: { type: 'string' } }, force: { type: 'boolean' } }) },
289
303
  { name: 'eda_export_netlist', description: 'Export the schematic netlist as text (default JLCEDA format). Returns null when no schematic is active - check eda_get_active_document / open the schematic page first.', inputSchema: objectSchema({ type: { type: 'string' } }) },
290
- { name: 'eda_export_bom', description: 'Export the schematic BOM. csv returns text; xlsx returns a base64 data URL.', inputSchema: objectSchema({ format: { type: 'string', enum: ['csv', 'xlsx'] } }) },
291
- { name: 'eda_delete_primitive', description: 'Delete a schematic primitive by kind and primitive id.', inputSchema: objectSchema({ kind: { type: 'string', enum: ['component', 'wire', 'rectangle', 'circle', 'arc', 'polygon', 'text'] }, primitiveId: { type: 'string' } }, ['kind', 'primitiveId']) },
304
+ { name: 'eda_export_bom', description: 'Export the schematic BOM. csv is decoded from UTF-16 to readable text; xlsx returns a base64 data URL.', inputSchema: objectSchema({ format: { type: 'string', enum: ['csv', 'xlsx'] } }) },
305
+ { name: 'eda_move_component', description: 'Move/rotate/mirror a schematic component (sch_PrimitiveComponent.modify) without deleting and re-placing it, so the designator and value survive.', inputSchema: objectSchema({ primitiveId: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, rotation: { type: 'number' }, mirror: { type: 'boolean' } }, ['primitiveId']) },
306
+ { name: 'eda_workflow_guide', description: 'Return the schematic working standard: grid-aligned manual placement, signal flow, when net labels/ports are required (a wire net name is overridden by connectivity), net-label fallback behaviour, batch placement and the verification order. Read it before laying out a sheet.', inputSchema: objectSchema({}) },
307
+ { name: 'eda_list_api_methods', description: 'List every API method the connected extension accepts (its allow-list). Call it when eda_api_call answers "API method not allowed".', inputSchema: objectSchema({}) },
308
+ { name: 'eda_delete_primitive', description: 'Delete a schematic primitive by kind and primitive id. Net ports, net flags and net labels are attribute primitives and need kind "netport"/"netflag"/"netlabel"; the text fallback labels are kind "text".', inputSchema: objectSchema({ kind: { type: 'string', enum: ['component', 'wire', 'rectangle', 'circle', 'arc', 'polygon', 'text', 'netport', 'netflag', 'netlabel'] }, primitiveId: { type: 'string' } }, ['kind', 'primitiveId']) },
292
309
  { name: 'eda_create_board', description: 'Create a board linking a schematic and a PCB (dmt_Board.createBoard).', inputSchema: objectSchema({ schematicUuid: { type: 'string' }, pcbUuid: { type: 'string' } }) },
293
310
  { name: 'eda_api_call', description: 'Call an allow-listed JLCEDA Pro extension API method directly.', inputSchema: objectSchema({ method: { type: 'string' }, args: { type: 'array' } }, ['method', 'args']) },
294
311
  { name: 'pcb_get_components', description: 'List PCB components (mil). includePins=true also reads pcb_PrimitiveComponent.getAllPinsByPrimitiveId per component, which is the accurate pad/net source; the fast summary pad list can be stale.', inputSchema: objectSchema({ layer: { type: ['string', 'integer'] }, locked: { type: 'boolean' }, includeBounds: { type: 'boolean' }, includePins: { type: 'boolean' } }) },
@@ -463,6 +480,11 @@ const pendingCalls = new Map()
463
480
  // EDA's own confirmation dialog, so "did the user confirm?" is answered by
464
481
  // watching whether new PCB components appear (low-frequency polling).
465
482
  let pcbComponentBaseline = null
483
+ // Net-label capability, probed once per session (null = unknown), plus the plain
484
+ // texts placed while the real net-label API was unavailable: without this registry
485
+ // those labels could not be listed or cleaned up.
486
+ let netLabelSupport = null
487
+ const placedTextLabels = []
466
488
 
467
489
  function sleep(ms) {
468
490
  return new Promise(resolve => setTimeout(resolve, ms))
@@ -507,7 +529,7 @@ function sendToClient(client, method, args, timeoutMs) {
507
529
  async function callExtension(method, args, timeoutMs) {
508
530
  const pool = pickClients()
509
531
  if (pool.length === 0) {
510
- throw new Error('JLCEDA bridge extension is not connected. Open JLCEDA Pro, import dsh-eda-bridge-extension.zip, enable "Allow interactive with external", and keep a schematic or PCB page open.')
532
+ throw new Error('JLCEDA bridge extension is not connected. Open JLCEDA Pro, import dsh-eda-bridge-extension-' + VERSION + '.zip, enable "Allow interactive with external", and keep a schematic or PCB page open.')
511
533
  }
512
534
  const deadline = Date.now() + timeoutMs
513
535
  let lastError
@@ -960,31 +982,89 @@ async function parseNetlist(previous, timeoutMs) {
960
982
  return { source: 'sch_Netlist.getNetlist', count: nets.length, nets, netlistBytes: text.length }
961
983
  }
962
984
 
963
- async function placeNetLabel(net, x, y, fontSize, timeoutMs) {
985
+ async function placeNetLabel(net, x, y, fontSize, timeoutMs, options = {}) {
964
986
  if (net === '' || net === 'undefined') throw new Error('a net name is required')
965
987
  assertNetName(String(net), 'eda_place_net_label')
966
988
  const size = fontSize === undefined ? 12 : Number(fontSize)
967
- try {
968
- // Measured on JLCEDA Pro 3.2.186: eda.sch_PrimitiveAttribute.createNetLabel exists
969
- // but never resolves, so it is probed with a short timeout and the plain text
970
- // label (plus the stub wire drawn by the caller) is used instead.
971
- const raw = await callExtension('sch_PrimitiveAttribute.createNetLabel', [x, y, net], Math.min(timeoutMs, 4000))
972
- if (raw === undefined || raw === null) throw new Error('createNetLabel returned nothing')
973
- return { method: 'netLabel', net, x, y, label: summarizePrimitive(raw) }
974
- } catch (error) {
975
- const message = error instanceof Error ? error.message : String(error)
976
- if (!/not found|not available|not allowed|timed out|timeout/i.test(message)) throw error
977
- const raw = await callExtension('sch_PrimitiveText.create', [x, y, net, 0, null, null, size, false, false, false, 1], timeoutMs)
978
- const label = summarizePrimitive(raw)
979
- return {
980
- method: 'text', net, x, y, created: label.primitiveId !== undefined,
981
- warning: 'createNetLabel could not be called (' + message + '). On JLCEDA Pro 3.2.186 the API exists but never resolves, so it is probed with a 4 s timeout. A plain text label does NOT create an electrical connection - the stub wire drawn by eda_place_net_label_at_pin carries the net, so keep it.',
982
- ...(label.primitiveId === undefined
983
- ? { hint: 'No label primitive was created: the schematic page must be the ACTIVE document (sch_PrimitiveText.create is tab-dependent). Use eda_get_active_document / eda_open_document, then retry.' }
984
- : {}),
985
- label,
989
+ if (options.probe === true) netLabelSupport = null
990
+ let failure = null
991
+ // The capability is probed ONCE per session: on JLCEDA Pro 3.2.186 createNetLabel
992
+ // exists but never resolves, and paying the 4s timeout for every one of hundreds
993
+ // of labels is what turned a 133-label run into 15 minutes.
994
+ if (netLabelSupport !== false) {
995
+ try {
996
+ const raw = await callExtension('sch_PrimitiveAttribute.createNetLabel', [x, y, net], Math.min(timeoutMs, 4000))
997
+ if (raw === undefined || raw === null) throw new Error('createNetLabel returned nothing')
998
+ netLabelSupport = true
999
+ return { method: 'netLabel', net, x, y, label: summarizePrimitive(raw) }
1000
+ } catch (error) {
1001
+ const message = error instanceof Error ? error.message : String(error)
1002
+ if (!/not found|not available|not allowed|timed out|timeout|nothing|undefined/i.test(message)) throw error
1003
+ failure = message
1004
+ netLabelSupport = false
986
1005
  }
987
1006
  }
1007
+ const raw = await callExtension('sch_PrimitiveText.create', [x, y, net, 0, null, null, size, false, false, false, 1], timeoutMs)
1008
+ const label = summarizePrimitive(raw)
1009
+ if (label.primitiveId !== undefined) {
1010
+ placedTextLabels.push({ net: String(net), x, y, primitiveId: label.primitiveId })
1011
+ if (placedTextLabels.length > 2000) placedTextLabels.shift()
1012
+ }
1013
+ return {
1014
+ method: 'text', net, x, y, created: label.primitiveId !== undefined,
1015
+ warning: 'createNetLabel is unavailable on this EDA build (' + (failure ?? 'probe already failed this session') +
1016
+ '), so a plain TEXT label was placed: it carries no electrical meaning. The stub wire drawn by the caller is what joins the net; eda_get_net_labels lists these texts by name and id.',
1017
+ ...(label.primitiveId === undefined
1018
+ ? { hint: 'No label primitive was created: the schematic page must be the ACTIVE document (sch_PrimitiveText.create is tab-dependent). Use eda_get_active_document / eda_open_document, then retry.' }
1019
+ : {}),
1020
+ label,
1021
+ }
1022
+ }
1023
+
1024
+ /** Place a net flag or net label at a pin, pushing it outwards with a stub wire. */
1025
+ async function placeAtPin(toolName, args, timeoutMs) {
1026
+ const pinPrimitiveId = String(args.pinPrimitiveId ?? '')
1027
+ if (pinPrimitiveId === '') throw new Error('pinPrimitiveId is required')
1028
+ const isFlag = toolName === 'eda_place_net_flag_at_pin'
1029
+ assertNetName(String(args.net ?? ''), toolName)
1030
+ const pin = await callExtension('sch_PrimitivePin.get', [pinPrimitiveId], timeoutMs)
1031
+ const pinRecord = asRecord(pin)
1032
+ const pinX = Number(pickValue(pinRecord, 'X', 'x'))
1033
+ const pinY = Number(pickValue(pinRecord, 'Y', 'y'))
1034
+ if (!Number.isFinite(pinX) || !Number.isFinite(pinY)) throw new Error('could not read pin position for ' + pinPrimitiveId)
1035
+ const pinRotation = Number(pickValue(pinRecord, 'Rotation', 'rotation') ?? 0)
1036
+ const direction = pinDirection(pinRotation)
1037
+ const explicitOffset = args.offsetX !== undefined || args.offsetY !== undefined
1038
+ // Placing a flag/label exactly ON the pin covers the symbol. Default: push it
1039
+ // outwards along the pin direction and keep connectivity with a short stub wire.
1040
+ const stubLength = args.stubLength === undefined
1041
+ ? (explicitOffset ? 0 : 20)
1042
+ : Math.max(0, Number(args.stubLength))
1043
+ const x = pinX + direction.dx * stubLength + Number(args.offsetX ?? 0)
1044
+ const y = pinY + direction.dy * stubLength + Number(args.offsetY ?? 0)
1045
+ if (args.allowOutsideFrame !== true) {
1046
+ const problem = checkPointInFrame(x, y, args.paper, 5) ?? checkPointAgainstTitleBlock(x, y, args.paper, 5)
1047
+ if (problem !== undefined) {
1048
+ throw new Error(toolName + ' refused: ' + problem + ' - reduce stubLength or pass offsetX/offsetY to place it elsewhere.')
1049
+ }
1050
+ }
1051
+ let stub
1052
+ if (stubLength > 0 && typeof args.net === 'string' && args.net !== '') {
1053
+ stub = summarizePrimitive(await callExtension('sch_PrimitiveWire.create', [
1054
+ [pinX, pinY, x, y], String(args.net), args.color === undefined ? undefined : String(args.color),
1055
+ args.lineWidth === undefined ? undefined : Number(args.lineWidth), args.lineType === undefined ? undefined : Number(args.lineType),
1056
+ ], timeoutMs))
1057
+ }
1058
+ const placement = { pin: { primitiveId: pinPrimitiveId, x: pinX, y: pinY, rotation: pinRotation }, direction, stubLength, x, y, stub }
1059
+ if (isFlag) {
1060
+ const flag = await callExtension('sch_PrimitiveComponent.createNetFlag', [
1061
+ String(args.identification), String(args.net), x, y,
1062
+ args.rotation === undefined ? pinRotation : Number(args.rotation), Boolean(args.mirror ?? false),
1063
+ ], timeoutMs)
1064
+ return { ...placement, flag: summarizePrimitive(flag) }
1065
+ }
1066
+ const label = await placeNetLabel(String(args.net), x, y, args.fontSize, timeoutMs)
1067
+ return { ...label, ...placement }
988
1068
  }
989
1069
 
990
1070
  /**
@@ -1055,6 +1135,45 @@ async function resolveProjectUuid(args, timeoutMs) {
1055
1135
  return { uuid: matched.uuid, matched }
1056
1136
  }
1057
1137
 
1138
+ /** Wait (bounded) until getCurrentProjectInfo reports the expected project uuid. */
1139
+ async function waitForActiveProject(uuid, timeoutMs = 4000) {
1140
+ const deadline = Date.now() + timeoutMs
1141
+ let lastActive = null
1142
+ for (;;) {
1143
+ try {
1144
+ const info = asRecord(await callExtension('dmt_Project.getCurrentProjectInfo', [], 3000))
1145
+ lastActive = String(pickValue(info, 'uuid', 'Uuid') ?? '') || null
1146
+ if (lastActive === uuid) return { active: true, project: info }
1147
+ } catch {
1148
+ // The app answers undefined while the project is switching.
1149
+ }
1150
+ if (Date.now() > deadline) return { active: false, project: null, lastActive }
1151
+ await sleep(400)
1152
+ }
1153
+ }
1154
+
1155
+ /** Collect the internal ids a project owns (schematic / page / pcb uuids). */
1156
+ function internalIdsOf(info) {
1157
+ const ids = new Set()
1158
+ const boards = Array.isArray(asRecord(info).data) ? asRecord(info).data : []
1159
+ for (const board of boards) {
1160
+ const record = asRecord(board)
1161
+ const boardUuid = String(pickValue(record, 'uuid', 'Uuid') ?? '')
1162
+ if (boardUuid !== '') ids.add(boardUuid)
1163
+ for (const docKey of ['schematic', 'pcb']) {
1164
+ const doc = asRecord(record[docKey])
1165
+ const docUuid = String(pickValue(doc, 'uuid', 'Uuid') ?? '')
1166
+ if (docUuid !== '') ids.add(docUuid)
1167
+ const pages = Array.isArray(doc.pages) ? doc.pages : []
1168
+ for (const page of pages) {
1169
+ const pageUuid = String(pickValue(asRecord(page), 'uuid', 'Uuid') ?? '')
1170
+ if (pageUuid !== '') ids.add(pageUuid)
1171
+ }
1172
+ }
1173
+ }
1174
+ return ids
1175
+ }
1176
+
1058
1177
  async function executeTool(name, rawArgs, timeoutMs) {
1059
1178
  const args = asRecord(rawArgs)
1060
1179
  const tool = PCB_TOOL_ALIASES[name] ?? name
@@ -1075,10 +1194,10 @@ async function executeTool(name, rawArgs, timeoutMs) {
1075
1194
  versionMismatch: stale.length > 0,
1076
1195
  mcpUnits: '0.01 inch', defaultPaper: DEFAULT_PAPER,
1077
1196
  hint: ready.length === 0
1078
- ? 'No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension.zip.'
1197
+ ? 'No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension-' + VERSION + '.zip.'
1079
1198
  : (stale.length > 0
1080
1199
  ? 'Connected EDA extension ' + stale.join(', ') + ' does not match bridge ' + VERSION +
1081
- '. In JLCEDA Pro remove the OLD dsh-eda-bridge extension first, import dsh-eda-bridge-extension.zip again, then restart the app; a simple reload keeps the old copy.'
1200
+ '. In JLCEDA Pro remove the OLD dsh-eda-bridge extension first, import dsh-eda-bridge-extension-' + VERSION + '.zip again, then restart the app; a simple reload keeps the old copy.'
1082
1201
  : 'ok'),
1083
1202
  }
1084
1203
  }
@@ -1136,7 +1255,35 @@ async function executeTool(name, rawArgs, timeoutMs) {
1136
1255
  const landed = raw !== null && raw !== undefined
1137
1256
  let opened = false
1138
1257
  if (args.open === true && uuid !== undefined) {
1139
- try { opened = (await callExtension('dmt_Project.openProject', [uuid], timeoutMs)) !== false } catch { opened = false }
1258
+ try {
1259
+ await callExtension('dmt_Project.openProject', [uuid], timeoutMs)
1260
+ // Same as eda_open_project: trust the observed state, not the return value.
1261
+ opened = (await waitForActiveProject(uuid, 4000)).active
1262
+ } catch {
1263
+ opened = false
1264
+ }
1265
+ }
1266
+ // Import copies the file verbatim, so schematic/page/pcb uuids inside the new
1267
+ // project can collide with the source. Detect it and say so instead of leaving a
1268
+ // silent data-integrity trap.
1269
+ let collisions = null
1270
+ if (landed && uuid !== undefined && args.checkCollisions !== false) {
1271
+ try {
1272
+ const mine = await callExtension('dmt_Project.getProjectInfo', [uuid], toolTimeout(args, timeoutMs))
1273
+ const myIds = internalIdsOf(mine)
1274
+ const others = await callExtension('dmt_Project.getAllProjectsUuid', [], toolTimeout(args, timeoutMs))
1275
+ const list = (Array.isArray(others) ? others.map(String) : []).filter(other => other !== uuid).slice(0, 20)
1276
+ collisions = []
1277
+ for (const other of list) {
1278
+ const info = await callExtension('dmt_Project.getProjectInfo', [other], timeoutMs).catch(() => undefined)
1279
+ const shared = [...internalIdsOf(info)].filter(sharedId => myIds.has(sharedId))
1280
+ if (shared.length > 0) {
1281
+ collisions.push({ projectUuid: other, friendlyName: pickValue(asRecord(info), 'friendlyName', 'FriendlyName') ?? null, sharedIds: shared })
1282
+ }
1283
+ }
1284
+ } catch {
1285
+ collisions = null
1286
+ }
1140
1287
  }
1141
1288
  return {
1142
1289
  ok: landed,
@@ -1146,6 +1293,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
1146
1293
  teamUuid,
1147
1294
  uuid: uuid ?? null,
1148
1295
  opened,
1296
+ collisions,
1149
1297
  result: raw ?? null,
1150
1298
  hint: landed ? null : 'The app returned nothing: the import did not create a project. That means this build ignores the saveTo target (same limitation as createProject), so import cannot be used to create projects here.',
1151
1299
  }
@@ -1247,9 +1395,18 @@ async function executeTool(name, rawArgs, timeoutMs) {
1247
1395
  case 'eda_open_project': {
1248
1396
  const resolvedProject = await resolveProjectUuid(args, timeoutMs)
1249
1397
  const uuid = resolvedProject.uuid
1250
- const opened = await callExtension('dmt_Project.openProject', [uuid], toolTimeout(args, timeoutMs))
1251
- const current = await callExtension('dmt_Project.getCurrentProjectInfo', [], timeoutMs).catch(() => undefined)
1252
- return { opened: opened !== false, uuid, matched: resolvedProject.matched, current: current === undefined ? null : summarizePrimitive(current) }
1398
+ const returned = await callExtension('dmt_Project.openProject', [uuid], toolTimeout(args, timeoutMs))
1399
+ // openProject returned false on the first call although the switch had already
1400
+ // happened, so report what the app actually has open instead of its boolean.
1401
+ const activation = await waitForActiveProject(uuid, args.verifyMs === undefined ? 4000 : Number(args.verifyMs))
1402
+ return {
1403
+ opened: activation.active,
1404
+ openReturned: returned !== false,
1405
+ uuid,
1406
+ matched: resolvedProject.matched,
1407
+ current: activation.active ? summarizePrimitive(activation.project) : null,
1408
+ ...(activation.active ? {} : { hint: 'The project did not become active within the verification window (last active: ' + (activation.lastActive ?? 'unknown') + '); it may still be loading, or the app refused the switch.' }),
1409
+ }
1253
1410
  }
1254
1411
 
1255
1412
  case 'eda_get_workspace': {
@@ -1337,13 +1494,30 @@ async function executeTool(name, rawArgs, timeoutMs) {
1337
1494
  const source = await resolveProjectUuid({
1338
1495
  uuid: args.sourceUuid, name: args.sourceName,
1339
1496
  }, timeoutMs)
1340
- const uuid = await callExtension('dmt_Project.copyProject', [
1341
- source.uuid,
1342
- args.targetTeamUuid === undefined ? undefined : String(args.targetTeamUuid),
1343
- args.targetFolderUuid === undefined ? undefined : String(args.targetFolderUuid),
1344
- args.newFriendlyName === undefined ? undefined : String(args.newFriendlyName),
1345
- args.newName === undefined ? undefined : String(args.newName),
1346
- ], toolTimeout(args, timeoutMs))
1497
+ // copyProject answered nothing for every argument combination on 3.2.186, and
1498
+ // returning {copied:false} with no reason left the caller guessing.
1499
+ let copyRaw
1500
+ try {
1501
+ copyRaw = await callExtension('dmt_Project.copyProject', [
1502
+ source.uuid,
1503
+ args.targetTeamUuid === undefined ? undefined : String(args.targetTeamUuid),
1504
+ args.targetFolderUuid === undefined ? undefined : String(args.targetFolderUuid),
1505
+ args.newFriendlyName === undefined ? undefined : String(args.newFriendlyName),
1506
+ args.newName === undefined ? undefined : String(args.newName),
1507
+ ], toolTimeout(args, timeoutMs))
1508
+ } catch (failure) {
1509
+ throw new Error('eda_copy_project failed: ' + (failure instanceof Error ? failure.message : String(failure)))
1510
+ }
1511
+ const copyUuid = typeof copyRaw === 'string' ? copyRaw : String(pickValue(asRecord(copyRaw), 'uuid', 'Uuid') ?? '')
1512
+ return {
1513
+ copied: copyUuid !== '',
1514
+ sourceUuid: source.uuid,
1515
+ uuid: copyUuid === '' ? null : copyUuid,
1516
+ raw: copyRaw ?? null,
1517
+ hint: copyUuid === ''
1518
+ ? 'The app returned no uuid and created nothing: copyProject does not work on this build for local projects (same family as createProject/deleteProject). Duplicate it in the app, or export with eda_export_project and import it back with eda_import_project to get an independent copy.'
1519
+ : null,
1520
+ }
1347
1521
  return { copied: typeof uuid === 'string' && uuid !== '', sourceUuid: source.uuid, uuid: uuid ?? null }
1348
1522
  }
1349
1523
 
@@ -1376,7 +1550,8 @@ async function executeTool(name, rawArgs, timeoutMs) {
1376
1550
  let opened = false
1377
1551
  if (args.open === true) {
1378
1552
  try {
1379
- opened = (await callExtension('dmt_Project.openProject', [uuid], timeoutMs)) !== false
1553
+ await callExtension('dmt_Project.openProject', [uuid], timeoutMs)
1554
+ opened = (await waitForActiveProject(uuid, 4000)).active
1380
1555
  } catch {
1381
1556
  opened = false
1382
1557
  }
@@ -1459,7 +1634,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
1459
1634
  waitedMs: waited.waitedMs,
1460
1635
  hint: waited.connected
1461
1636
  ? 'JLCEDA is connected; the eda_* tools can be used now.'
1462
- : 'The app was started but no EDA extension connected yet. In JLCEDA Pro: 扩展 → DSH MCP → 连接 (and enable “允许外部交互”); the extension must be imported once from dsh-eda-bridge-extension.zip.',
1637
+ : 'The app was started but no EDA extension connected yet. In JLCEDA Pro: 扩展 → DSH MCP → 连接 (and enable “允许外部交互”); the extension must be imported once from dsh-eda-bridge-extension-' + VERSION + '.zip.',
1463
1638
  }
1464
1639
  }
1465
1640
 
@@ -1559,70 +1734,108 @@ async function executeTool(name, rawArgs, timeoutMs) {
1559
1734
  case 'eda_get_pins': {
1560
1735
  const componentPrimitiveId = String(args.componentPrimitiveId ?? '')
1561
1736
  if (componentPrimitiveId === '') throw new Error('componentPrimitiveId is required')
1737
+ // One call already returns pinName/pinNumber (ISCH_PrimitiveComponentPin);
1738
+ // summarising it used to drop exactly those fields, forcing one sch_PrimitivePin.get
1739
+ // round trip per pin (135 of them for a mid-size schematic).
1562
1740
  const raw = await callExtension('sch_PrimitiveComponent.getAllPinsByPrimitiveId', [componentPrimitiveId], timeoutMs)
1563
- if (Array.isArray(raw)) return raw.map(summarizePrimitive)
1564
- return raw
1741
+ if (!Array.isArray(raw)) return raw
1742
+ return raw.map((entry) => {
1743
+ const record = asRecord(entry)
1744
+ return {
1745
+ primitiveId: pickValue(record, 'PrimitiveId', 'primitiveId'),
1746
+ pinNumber: pickValue(record, 'PinNumber', 'pinNumber') ?? null,
1747
+ pinName: pickValue(record, 'PinName', 'pinName') ?? null,
1748
+ x: pickValue(record, 'X', 'x'),
1749
+ y: pickValue(record, 'Y', 'y'),
1750
+ rotation: pickValue(record, 'Rotation', 'rotation') ?? 0,
1751
+ pinLength: pickValue(record, 'PinLength', 'pinLength') ?? null,
1752
+ noConnected: pickValue(record, 'NoConnected', 'noConnected') ?? null,
1753
+ }
1754
+ })
1565
1755
  }
1566
1756
 
1567
1757
  case 'eda_place_net_flag_at_pin':
1568
- case 'eda_place_net_label_at_pin': {
1569
- const pinPrimitiveId = String(args.pinPrimitiveId ?? '')
1570
- if (pinPrimitiveId === '') throw new Error('pinPrimitiveId is required')
1571
- assertNetName(String(args.net ?? ''), name === 'eda_place_net_flag_at_pin' ? 'eda_place_net_flag_at_pin' : 'eda_place_net_label_at_pin')
1572
- const pin = await callExtension('sch_PrimitivePin.get', [pinPrimitiveId], timeoutMs)
1573
- const pinRecord = asRecord(pin)
1574
- const pinX = Number(pickValue(pinRecord, 'X', 'x'))
1575
- const pinY = Number(pickValue(pinRecord, 'Y', 'y'))
1576
- if (!Number.isFinite(pinX) || !Number.isFinite(pinY)) throw new Error('could not read pin position for ' + pinPrimitiveId)
1577
- const pinRotation = Number(pickValue(pinRecord, 'Rotation', 'rotation') ?? 0)
1578
- const direction = pinDirection(pinRotation)
1579
- const explicitOffset = args.offsetX !== undefined || args.offsetY !== undefined
1580
- // Placing a flag/label exactly ON the pin covers the symbol. Default: push it
1581
- // outwards along the pin direction and keep connectivity with a short stub wire.
1582
- const stubLength = args.stubLength === undefined
1583
- ? (explicitOffset ? 0 : 20)
1584
- : Math.max(0, Number(args.stubLength))
1585
- const x = pinX + direction.dx * stubLength + Number(args.offsetX ?? 0)
1586
- const y = pinY + direction.dy * stubLength + Number(args.offsetY ?? 0)
1587
- if (args.allowOutsideFrame !== true) {
1588
- const problem = checkPointInFrame(x, y, args.paper, 5) ?? checkPointAgainstTitleBlock(x, y, args.paper, 5)
1589
- if (problem !== undefined) {
1590
- throw new Error((name === 'eda_place_net_flag_at_pin' ? 'eda_place_net_flag_at_pin' : 'eda_place_net_label_at_pin') +
1591
- ' refused: ' + problem + ' - reduce stubLength or pass offsetX/offsetY to place it elsewhere.')
1592
- }
1593
- }
1594
- let stub
1595
- if (stubLength > 0 && typeof args.net === 'string' && args.net !== '') {
1596
- stub = summarizePrimitive(await callExtension('sch_PrimitiveWire.create', [
1597
- [pinX, pinY, x, y], String(args.net), args.color === undefined ? undefined : String(args.color),
1598
- args.lineWidth === undefined ? undefined : Number(args.lineWidth), args.lineType === undefined ? undefined : Number(args.lineType),
1599
- ], timeoutMs))
1600
- }
1601
- const placement = { pin: { primitiveId: pinPrimitiveId, x: pinX, y: pinY, rotation: pinRotation }, direction, stubLength, x, y, stub }
1602
- if (name === 'eda_place_net_flag_at_pin') {
1603
- const flag = await callExtension('sch_PrimitiveComponent.createNetFlag', [
1604
- String(args.identification), String(args.net), x, y,
1605
- args.rotation === undefined ? pinRotation : Number(args.rotation), Boolean(args.mirror ?? false),
1606
- ], timeoutMs)
1607
- return { ...placement, flag: summarizePrimitive(flag) }
1608
- }
1609
- const label = await placeNetLabel(String(args.net), x, y, args.fontSize, timeoutMs)
1610
- return { ...label, ...placement }
1611
- }
1758
+ case 'eda_place_net_label_at_pin':
1759
+ return await placeAtPin(name, args, timeoutMs)
1612
1760
 
1613
1761
  case 'eda_place_net_label': {
1614
1762
  const x = Number(args.x)
1615
1763
  const y = Number(args.y)
1616
1764
  if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('eda_place_net_label requires numeric x/y')
1617
- return await placeNetLabel(String(args.net), x, y, args.fontSize, timeoutMs)
1765
+ return await placeNetLabel(String(args.net), x, y, args.fontSize, timeoutMs, { probe: args.probe === true })
1766
+ }
1767
+
1768
+ case 'eda_place_net_labels': {
1769
+ // One MCP round trip for many labels: a 133-label schematic used to exceed the
1770
+ // caller's timeout with no way to learn what had already been placed.
1771
+ const items = Array.isArray(args.items) ? args.items.map(asRecord) : []
1772
+ if (items.length === 0) throw new Error('eda_place_net_labels requires items: [{ pinPrimitiveId, net }] or [{ x, y, net }]')
1773
+ const results = []
1774
+ for (const item of items) {
1775
+ const net = item.net === undefined ? '' : String(item.net)
1776
+ try {
1777
+ const placed = item.pinPrimitiveId !== undefined
1778
+ ? await placeAtPin('eda_place_net_label_at_pin', { ...item, net }, timeoutMs)
1779
+ : await placeNetLabel(net, Number(item.x), Number(item.y), item.fontSize, timeoutMs, { probe: args.probe === true })
1780
+ results.push({ ok: true, net, ...placed })
1781
+ } catch (error) {
1782
+ results.push({ ok: false, net, error: error instanceof Error ? error.message : String(error) })
1783
+ }
1784
+ }
1785
+ const done = results.filter(entry => entry.ok).length
1786
+ return {
1787
+ total: items.length,
1788
+ done,
1789
+ failed: items.length - done,
1790
+ method: netLabelSupport === true ? 'netLabel' : 'text',
1791
+ results,
1792
+ }
1618
1793
  }
1619
1794
 
1620
1795
  case 'eda_get_net_labels': {
1621
- const raw = await callExtension('sch_PrimitiveAttribute.getAll', [
1796
+ // Enumerate every label-like primitive: net labels/flags/ports are attribute
1797
+ // primitives, while the text fallback (createNetLabel unavailable) lands in
1798
+ // sch_PrimitiveText. Listing only attributes reported count:0 right after a
1799
+ // successful placement, so the labels could not be found or cleaned up.
1800
+ const attributes = await callExtension('sch_PrimitiveAttribute.getAll', [
1622
1801
  args.parentPrimitiveId === undefined ? undefined : String(args.parentPrimitiveId),
1623
- ], timeoutMs)
1624
- if (!Array.isArray(raw)) return raw
1625
- return { count: raw.length, labels: raw.map(summarizePrimitive) }
1802
+ ], timeoutMs).catch(() => [])
1803
+ const texts = args.parentPrimitiveId === undefined
1804
+ ? await callExtension('sch_PrimitiveText.getAll', [], timeoutMs).catch(() => [])
1805
+ : []
1806
+ const entries = []
1807
+ for (const entry of Array.isArray(attributes) ? attributes : []) {
1808
+ const record = asRecord(entry)
1809
+ entries.push({
1810
+ primitiveId: pickValue(record, 'PrimitiveId', 'primitiveId'),
1811
+ kind: String(pickValue(record, 'PrimitiveType', 'primitiveType', 'Type', 'type') ?? 'attribute'),
1812
+ net: pickValue(record, 'Net', 'net') ?? null,
1813
+ userValue: pickValue(record, 'UserValue', 'userValue', 'Value', 'value') ?? null,
1814
+ x: pickValue(record, 'X', 'x'),
1815
+ y: pickValue(record, 'Y', 'y'),
1816
+ })
1817
+ }
1818
+ for (const entry of Array.isArray(texts) ? texts : []) {
1819
+ const record = asRecord(entry)
1820
+ const content = String(pickValue(record, 'Content', 'content', 'Text', 'text') ?? '')
1821
+ if (content === '') continue
1822
+ entries.push({
1823
+ primitiveId: pickValue(record, 'PrimitiveId', 'primitiveId'),
1824
+ kind: 'text',
1825
+ net: content,
1826
+ userValue: content,
1827
+ x: pickValue(record, 'X', 'x'),
1828
+ y: pickValue(record, 'Y', 'y'),
1829
+ })
1830
+ }
1831
+ return {
1832
+ count: entries.length,
1833
+ attributeCount: Array.isArray(attributes) ? attributes.length : 0,
1834
+ textCount: Array.isArray(texts) ? texts.length : 0,
1835
+ sessionTextLabels: placedTextLabels.length,
1836
+ note: 'kind "text" entries are plain texts (no electrical meaning). Delete any entry with eda_delete_primitive(kind: "netlabel"|"netport"|"netflag"|"text", primitiveId).',
1837
+ labels: entries,
1838
+ }
1626
1839
  }
1627
1840
 
1628
1841
  case 'eda_get_active_document': {
@@ -1719,8 +1932,30 @@ async function executeTool(name, rawArgs, timeoutMs) {
1719
1932
  case 'eda_save_document':
1720
1933
  return await callExtension('sch_Document.save', [], timeoutMs)
1721
1934
 
1722
- case 'eda_zoom_to_fit':
1723
- return await callExtension('dmt_EditorControl.zoomToAllPrimitives', [args.tabId], timeoutMs)
1935
+ case 'eda_zoom_to_fit': {
1936
+ // The API reported bounds outside the sheet (left:-340, bottom:-772.9 on A4),
1937
+ // which is useless for a caller that wants to know what it is looking at.
1938
+ // Clamp the region to the page frame and re-apply it.
1939
+ const raw = await callExtension('dmt_EditorControl.zoomToAllPrimitives', [args.tabId], timeoutMs)
1940
+ const record = asRecord(raw)
1941
+ const left = Number(pickValue(record, 'left', 'Left'))
1942
+ const right = Number(pickValue(record, 'right', 'Right'))
1943
+ const bottom = Number(pickValue(record, 'bottom', 'Bottom'))
1944
+ const top = Number(pickValue(record, 'top', 'Top'))
1945
+ if (![left, right, bottom, top].every(value => Number.isFinite(value))) return raw
1946
+ const frame = paperOf(args.paper).frame
1947
+ const clamped = {
1948
+ left: Math.max(frame.left, left), right: Math.min(frame.right, right),
1949
+ bottom: Math.max(frame.bottom, bottom), top: Math.min(frame.top, top),
1950
+ }
1951
+ if (clamped.left >= clamped.right || clamped.bottom >= clamped.top) {
1952
+ return { ...record, requested: { left, right, bottom, top }, clamped: null, note: 'the primitive bounds do not overlap the page frame' }
1953
+ }
1954
+ await callExtension('dmt_EditorControl.zoomToRegion', [
1955
+ clamped.left, clamped.right, clamped.top, clamped.bottom, args.tabId,
1956
+ ], timeoutMs)
1957
+ return { ...record, requested: { left, right, bottom, top }, clamped }
1958
+ }
1724
1959
 
1725
1960
  case 'eda_zoom_to_region':
1726
1961
  return await callExtension('dmt_EditorControl.zoomToRegion', [
@@ -1763,23 +1998,108 @@ async function executeTool(name, rawArgs, timeoutMs) {
1763
1998
  return parsed
1764
1999
  }
1765
2000
 
1766
- case 'eda_run_drc':
1767
- return await callExtension('sch_Drc.check', [args.strict === undefined ? true : Boolean(args.strict), false, true], timeoutMs)
2001
+ case 'eda_run_drc': {
2002
+ // includeVerboseError:true makes the API return one record per problem; the
2003
+ // boolean overload only said "something is wrong" and gave nothing to locate.
2004
+ const verbose = await callExtension('sch_Drc.check', [
2005
+ args.strict === undefined ? true : Boolean(args.strict), false, true,
2006
+ ], toolTimeout(args, timeoutMs))
2007
+ if (Array.isArray(verbose)) {
2008
+ return {
2009
+ ok: verbose.length === 0,
2010
+ count: verbose.length,
2011
+ entries: verbose.map((entry) => {
2012
+ const record = asRecord(entry)
2013
+ return {
2014
+ type: pickValue(record, 'Type', 'type', 'Level', 'level') ?? null,
2015
+ message: pickValue(record, 'Message', 'message', 'Error', 'error', 'Description', 'description') ?? null,
2016
+ primitiveId: pickValue(record, 'PrimitiveId', 'primitiveId') ?? null,
2017
+ x: pickValue(record, 'X', 'x') ?? null,
2018
+ y: pickValue(record, 'Y', 'y') ?? null,
2019
+ raw: entry,
2020
+ }
2021
+ }),
2022
+ hint: 'Field names vary by EDA build; "raw" keeps the untouched record.',
2023
+ }
2024
+ }
2025
+ return { ok: Boolean(verbose), count: null, entries: [], raw: verbose }
2026
+ }
1768
2027
 
1769
2028
  case 'eda_auto_layout':
1770
- return await callExtension('sch_Document.autoLayout', [Array.isArray(args.uuids) ? { uuids: args.uuids.map(String) } : undefined], timeoutMs)
2029
+ case 'eda_auto_route': {
2030
+ // Standing rule from the user: do not auto-lay-out or auto-route unless asked.
2031
+ if (ALLOW_AUTO_LAYOUT !== true && args.force !== true) {
2032
+ throw new Error(name + ' is disabled by policy (settings allowAutoLayoutRouting=false, env DSH_EDA_ALLOW_AUTO_LAYOUT). Do NOT retry with force:true unless the user explicitly asked for automatic layout/routing.',
2033
+ '',
2034
+ 'Do this instead, in order:',
2035
+ '1. eda_get_active_document — make sure a schematic page is active (eda_open_document if not).',
2036
+ '2. eda_get_page_info — read the A4 frame and the safe areas before placing anything.',
2037
+ '3. eda_get_components — see what is already placed and where (x/y + bounding boxes) to avoid overlap.',
2038
+ '4. Place or move parts yourself on a 10-unit grid: eda_place_component for new ones, eda_move_component (keeps designator/value) to re-grid existing ones. Signal flow left to right, power rails on top, ground at the bottom, >=20 units between symbols for wires and labels.',
2039
+ '5. Connect with eda_draw_wire for local nets; for anything that spans the sheet use eda_place_net_port or a net label (a wire net name is overridden by connectivity once an endpoint touches a pin).',
2040
+ '6. Verify: eda_export_netlist (every net should have >=2 endpoints) and eda_run_drc (per-problem entries). For a visual check export the canvas to an image and look at it, rather than letting EDA rearrange the sheet.',
2041
+ '7. Call eda_workflow_guide for the full standard.',)
2042
+ }
2043
+ const payload = Array.isArray(args.uuids) ? { uuids: args.uuids.map(String) } : undefined
2044
+ return await callExtension(name === 'eda_auto_layout' ? 'sch_Document.autoLayout' : 'sch_Document.autoRouting', [payload], toolTimeout(args, timeoutMs))
2045
+ }
2046
+
2047
+ case 'eda_export_netlist': {
2048
+ const payload = await callExtension('sch_ManufactureData.getNetlistFile', [
2049
+ undefined, typeof args.type === 'string' && args.type !== '' ? args.type : undefined,
2050
+ ], toolTimeout(args, timeoutMs))
2051
+ const text = typeof payload === 'string'
2052
+ ? payload
2053
+ : (payload !== null && typeof payload === 'object' && typeof payload.text === 'string' ? payload.text : undefined)
2054
+ if (text === undefined) return { parsed: false, netlist: payload ?? null }
2055
+ if (args.format === 'text') return { parsed: false, netlistText: text, bytes: text.length }
2056
+ // The API hands back a JSON document; returning it as an escaped string forced
2057
+ // callers to parse twice (and once more through the MCP envelope).
2058
+ try {
2059
+ return { parsed: true, bytes: text.length, netlist: JSON.parse(text) }
2060
+ } catch {
2061
+ return { parsed: false, bytes: text.length, netlistText: text }
2062
+ }
2063
+ }
2064
+
2065
+ case 'eda_export_bom': {
2066
+ const format = args.format === 'xlsx' ? 'xlsx' : 'csv'
2067
+ const payload = await callExtension('sch_ManufactureData.getBomFile', [undefined, format], toolTimeout(args, timeoutMs))
2068
+ if (format === 'xlsx') return payload
2069
+ const raw = typeof payload === 'string'
2070
+ ? payload
2071
+ : (payload !== null && typeof payload === 'object' && typeof payload.text === 'string' ? payload.text : '')
2072
+ // CSV arrives as UTF-16LE bytes; returned verbatim it read as NUL-separated junk.
2073
+ return { format, bom: decodeTextPayload(raw) }
2074
+ }
1771
2075
 
1772
- case 'eda_auto_route':
1773
- return await callExtension('sch_Document.autoRouting', [Array.isArray(args.uuids) ? { uuids: args.uuids.map(String) } : undefined], timeoutMs)
2076
+ case 'eda_delete_primitive': {
2077
+ const kind = String(args.kind)
2078
+ // Net ports / flags / labels are attribute primitives; without these kinds the
2079
+ // only way to remove one was to remember the id from the placement call.
2080
+ const className = kind === 'netport' || kind === 'netflag' || kind === 'netlabel'
2081
+ ? 'sch_PrimitiveAttribute'
2082
+ : primitiveKindToClass(kind)
2083
+ return await callExtension(className + '.delete', [String(args.primitiveId)], timeoutMs)
2084
+ }
2085
+
2086
+ case 'eda_move_component':
2087
+ // Schematic parts could only be moved through raw sch_PrimitiveComponent.modify.
2088
+ return await callExtension('sch_PrimitiveComponent.modify', [String(args.primitiveId), {
2089
+ x: args.x === undefined ? undefined : Number(args.x),
2090
+ y: args.y === undefined ? undefined : Number(args.y),
2091
+ rotation: args.rotation === undefined ? undefined : Number(args.rotation),
2092
+ mirror: args.mirror === undefined ? undefined : Boolean(args.mirror),
2093
+ }], timeoutMs)
1774
2094
 
1775
- case 'eda_export_netlist':
1776
- return await callExtension('sch_ManufactureData.getNetlistFile', [undefined, typeof args.type === 'string' && args.type !== '' ? args.type : 'JLCEDA'], timeoutMs)
2095
+ case 'eda_list_api_methods':
2096
+ return { count: ALLOWED_API.size, methods: [...ALLOWED_API].sort() }
1777
2097
 
1778
- case 'eda_export_bom':
1779
- return await callExtension('sch_ManufactureData.getBomFile', [undefined, args.format === 'xlsx' ? 'xlsx' : 'csv'], timeoutMs)
2098
+ case 'eda_workflow_guide':
2099
+ return { summary: WORKFLOW_SUMMARY, guide: WORKFLOW_GUIDE, autoLayoutAllowed: ALLOW_AUTO_LAYOUT }
1780
2100
 
1781
- case 'eda_delete_primitive':
1782
- return await callExtension(primitiveKindToClass(String(args.kind)) + '.delete', [String(args.primitiveId)], timeoutMs)
2101
+ case 'eda_workflow_guide':
2102
+ return { summary: WORKFLOW_SUMMARY, guide: WORKFLOW_GUIDE, autoLayoutAllowed: ALLOW_AUTO_LAYOUT }
1783
2103
 
1784
2104
  case 'eda_create_board':
1785
2105
  return await callExtension('dmt_Board.createBoard', [args.schematicUuid, args.pcbUuid], timeoutMs)
@@ -1787,7 +2107,11 @@ async function executeTool(name, rawArgs, timeoutMs) {
1787
2107
  case 'eda_api_call': {
1788
2108
  if (!ALLOW_RAW_API) throw new Error('eda_api_call is disabled by settings (allowRawApi=false)')
1789
2109
  const method = String(args.method ?? '')
1790
- if (!ALLOWED_API.has(method)) throw new Error('API method not allowed: ' + method)
2110
+ if (!ALLOWED_API.has(method)) {
2111
+ throw new Error('API method not allowed: ' + method + '. The extension only accepts its allow-list (' +
2112
+ ALLOWED_API.size + ' methods): call eda_list_api_methods to get it. Common schematic helpers such as ' +
2113
+ 'sch_PrimitiveComponent.getAllPinsByPrimitiveId and sch_PrimitivePin.get are already wrapped by eda_get_pins.')
2114
+ }
1791
2115
  return await callExtension(method, Array.isArray(args.args) ? args.args : [], timeoutMs)
1792
2116
  }
1793
2117
 
@@ -1982,7 +2306,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
1982
2306
  triggered: true, backgroundUnavailable: true, importResult: result,
1983
2307
  componentsBefore: pcbComponentBaseline.length, componentsAfter: after.length,
1984
2308
  confirmedYet: after.length > pcbComponentBaseline.length,
1985
- hint: 'The imported EDA extension has no background-job helper, so the sync was awaited. Re-import dsh-eda-bridge-extension.zip to use the async + polling flow.',
2309
+ hint: 'The imported EDA extension has no background-job helper, so the sync was awaited. Re-import dsh-eda-bridge-extension-' + VERSION + '.zip to use the async + polling flow.',
1986
2310
  }
1987
2311
  }
1988
2312
  return {
@@ -2284,7 +2608,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
2284
2608
  // Older EDA extension without the background-job helper: fall back to the
2285
2609
  // blocking call (which may hit the tool timeout for large boards).
2286
2610
  const blocking = asRecord(await callExtension('pcb_Document.autoRouting', [props], toolTimeout(args, timeoutMs)))
2287
- return { ...blocking, backgroundUnavailable: true, props, hint: 'The imported EDA extension has no background-job helper, so routing was awaited. Re-import dsh-eda-bridge-extension.zip to get the async + polling flow.' }
2611
+ return { ...blocking, backgroundUnavailable: true, props, hint: 'The imported EDA extension has no background-job helper, so routing was awaited. Re-import dsh-eda-bridge-extension-' + VERSION + '.zip to get the async + polling flow.' }
2288
2612
  }
2289
2613
  const running = started.started === true || started.alreadyRunning === true
2290
2614
  return {