Skip to content

Agent MCP server, tools and skill - #107

Merged
Gherkin merged 20 commits into
mainfrom
agent-mcp
Oct 11, 2026
Merged

Gherkin merged 20 commits into
mainfrom
agent-mcp

Conversation

@Gherkin

@Gherkin Gherkin commented Oct 11, 2026

Copy link
Copy Markdown
Owner

The agent MCP epic (#73), merged child by child into agent-mcp. Each child PR had its own /prr review.

What it adds:

  • An MCP server for agents (mcp/). It runs in Docker as a second service behind /mcp, or locally over stdio against a static site.
  • Tools: list_boards, locate, net_places, nearest, nearest_vias, link, snapshot.
  • build_boards.py writes pcbs/index.json; the board list page reads it.
  • Pad names in the KiCad export, pin in the tools, and pin names in the viewer.
  • A mark= link key that draws a marker.
  • The viewer and MCP images are built before either is pushed.
  • README sections for the MCP server, static hosting and the agent skill.
  • An agent skill in skills/pcb-viewer/.

Changes for running servers:

  • docker-compose.yml has a second service, pcb-mcp. Set VIEWER_URL to the address users open.
  • A new image, ghcr.io/gherkin/htmlpcbviewer-mcp, about 592 MB (Chromium for snapshot).
  • The first arm64 build of the MCP image runs in the publish job after this merge. It was only built for amd64 before.

Closes #73
Closes #74
Closes #75
Closes #76
Closes #77
Closes #78
Closes #79
Closes #80
Closes #81
Closes #82
Closes #83
Closes #84
Closes #87
Closes #90
Closes #96
Closes #14

🤖 Generated with Claude Code

https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

Gherkin and others added 18 commits October 10, 2026 20:34
* Write a board index file, and read it on the board list page (#74)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fall back to the directory listing when index.json is missing

Data built before the board index has no pcbs/index.json. On a 404 the
board list page now reads the nginx JSON directory listing, as before,
instead of showing an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add an MCP server in Docker with a list_boards tool (#75)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix review findings on the MCP server

- http.js: read the path without new URL(), which threw on a target
  such as "//[" and took the process down. Test added.
- server.js: log tool errors that are not BoardError; the SDK turns
  them into tool errors and logs nothing.
- nginx.conf: give the URI in proxy_pass, so //mcp reaches the server
  as /mcp.
- main.js: let requests in progress finish on shutdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* MCP tool locate: where parts are on the board, by refdes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* locate: say which way angle turns the rectangle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* MCP tool net_places: every pad on a net, with refdes, side and position

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* net_places: trim the net name, explain a net with no pads

Trim the net name like locate trims refdes. Say in the tool
description that a net with no pads gives found: true and no pads.
Note in pads.js that Allegro writes a through-hole pin as two pads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* pads.js: note that Allegro through-hole pins are listed twice

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* pads.js: merge an Allegro through-hole pin pair into one pad

Allegro writes a through-hole pin as an F "th" pad and a B "smd" pad
at the same position. boardPads now gives one "th" pad on both sides,
and keeps pin1 if either pad has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* MCP tool nearest: reachable pads on a net near a part or point

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* nearest: sort by distance only, mark own and covered_by pads instead of dropping

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* nearest: review fixes (own refdes never covers, ambiguous refdes error, covers after limit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Pad names in the KiCad export, pin in net_places and nearest (#90)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* Review fixes: empty pad name in merges, stock-ibom wording

A merged pad pair now takes the other half's name when one half has an
empty name. The tool descriptions say that KiCad boards from stock ibom
have no pin either. The fixture README warns that regenerating ciaa-acc
adds names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* nearest: the near part's own pads do not count toward limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* nearest: plain loop for the limit cut, no side effect in filter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…resort (#97)

Same input as nearest. Vias are zero-length tracks on two or more layers;
copies at one point are one via. Reachable only from the outer layers a via
is on. Vias under a part are kept and marked covered_by, as nearest does for
pads. The description and every answer say vias are a last resort and are
often under soldermask, which the export cannot tell.

The near/side/cover logic shared with nearest moves to mcp/src/near.js.

Closes #79


Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#98)

* nearest_vias: vias covered by the near part do not count toward limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* nearest_vias: vias inside the near part's box do not count toward limit

A through via under a front BGA is free on the back and gets no
covered_by, so keying on covered_by still let fan-out vias fill the list.
Now any via inside the near part's footprint rectangle, on either side,
does not count. Covers are looked up for the returned vias only again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* MCP tool link: build a viewer link from structured fields (#81)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* link: review round 1 fixes

- Side: count the focused part's side whenever the viewer turns to it
  (no view, no zoom, and a marker or a given side), not with zoom.
- A view that rounds to no width or height is an error.
- A net or copper layer that matches two names when case is ignored
  says so, as refs do. One matchName in pads.js for all of them.
- Net names and pad nets are built once, only when a net is asked for.
- The same error line is not listed twice.
- base_url and VIEWER_URL text: the board list page's address, not
  the viewer page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* MCP tool snapshot: PNG of what a viewer link shows (#82)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* snapshot: a late browser disconnect no longer drops a newer browser; apt in its own Docker layer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* MCP stdio mode: boards from a static site over HTTP (#83)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* Keep Chromium's sandbox outside Docker; share concurrent board fetches

Chromium now keeps its sandbox unless CHROMIUM_NO_SANDBOX=1, which only
the MCP Docker image sets. A sandbox that cannot start is a tool error
that says so. httpStore shares one fetch between calls for the same
file, and a 304 re-counts the entry's bytes and takes new validators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* README: host boards on a static site (#84)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* README static site: copy for local test, soften .nojekyll note

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Show pad names in the viewer (#96)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* Pad name test matches the MCP tools

padName() takes a pad name only when it is a string, as mcp/src/pads.js
does, so the viewer and the tools agree on which pads have a pin name.
Tooltip and pad table both use it. Fix the pad table comment: it does not
match the tooltip for an unnamed pin-1 pad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Agent skill for the viewer MCP tools (#14)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* Skill: fixes from review round 1

- Any snapshot error: give the link and say it was not checked.
- Focus the pad's own part, not the near part; frame a via with a view.
- A net with no pads: leave it out of nets and keep the via marker.
- Through-hole side: trust the side field.
- Look for the tools by name, not by server name.
- A generic rail-name example instead of one board.
- Vias are "often" covered, as the tool says; fix the list formatting.
- README: the link is an alternative to the copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Pad table: head the first column "Pin", not "#"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

* Pad table test: check the Pin header on an unnamed board too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-11 19:17 UTC

@Gherkin

Gherkin commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

/prr round 1 (code-review run on the whole epic). 10 findings, each checked against the code.

To fix on agent-mcp:

  1. mcp/src/pads.js:56 - boardPads merges any two pads of one footprint at the same pos when either is "th", so an SMD pad on another net at a TH pad's center is lost from net_places/nearest. Fix: merge only pads on the same net (the Allegro pair always is).
  2. mcp/src/boards.js:38 - diskStore turns only ENOENT into a tool error; a folder path (EISDIR) or an unreadable file (EACCES) becomes an internal error with no hint. Fix: map those to BoardError too.
  3. mcp/src/tools/locate.js:53 - own exact/lower-case refdes maps instead of footprintsByRef from footprints.js. Fix: use footprintsByRef (same results).
  4. web/render.js:1065 - padLabel's comment sits on padName; padLabel has none. Fix: move it.

Need a decision (not fixed):
5. docker/index.html:121 - once pcbs/index.json exists, the board list (and list_boards) ignores the directory listing. A board copied into pcbs/ by hand, as worked before, does not show until build_boards.py runs again. Matches #74 ("instead of the nginx listing") and README:59 says so, but it changes things for existing Docker users.
6. build_boards.py:76 - write_index json.loads every board payload on every run, also when all were skipped, only for metadata.title. An incremental run over many large boards is no longer near-instant.

FYI (not fixing):
7. web/app.js:995 - "Pin" header over list places on unnamed boards. User decision on #96; no change.
8. mcp/src/browser.js:143 - any uncaught page error ends a snapshot. Deliberate (fast fail on a board that does not load); no known harmless error in the viewer.
9. mcp/src/boards.js:28 - diskStore re-reads and parses the board on every call; no cache. Perf only, the cost is a cache design choice.
10. mcp/src/tools/snapshot.js:73 - url mode parses the board only to check it exists. Same cause as 9.

- pads.js: merge a TH pad pair only when both pads are on the same net,
  so an SMD pad on another net at the same point is no longer lost.
- boards.js: a folder or an unreadable file in diskStore is a tool error,
  not an internal one.
- locate.js: match refdes with footprintsByRef, as the other tools do.
- render.js: put padLabel's comment back on padLabel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x
@Gherkin

Gherkin commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

Decisions on the round 1 findings (user):

  • Finding 5, boards copied in by hand: keep as is. The README says to run build_boards.py.
  • Finding 6, build time: build_boards.py reuses the title from the existing index.json for boards whose output file did not change, and loads only boards that were built or are missing from the old index.
  • Merge into main: squash.

A board skipped as up to date keeps the title the old index.json gives
it, so its payload is not read again. Built boards, boards missing from
the old index and files not from the source are still read. A missing
or unreadable old index falls back to reading every payload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzHC7LspikUyimHw5ptN8x
@Gherkin

Gherkin commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

/prr round 2 (code-review run on e1bf597 and 1448b4b, the commits after the round 1 comment).

Fixed: finding 6 in 1448b4b, as decided. build_boards.py reuses the title from the old index.json for boards skipped as up to date. Built boards, boards missing from the old index, and files not from the source are still read. A missing or unreadable old index falls back to reading every payload. Tests in tests/browser/board-index.spec.js: skipped board keeps its title with a payload that is no JSON (fails on the old code), rebuilt board gets its new title, corrupt old index falls back.

Review findings, all checked against the code. None fixed:

  1. build_boards.py:127 - a payload rewritten outside build_boards.py (newer than its source) is skipped, so the index keeps the old title, and a broken payload stays listed. This follows from the approved rule (reuse for skipped boards). A guard "reuse only if the payload is older than the old index.json" would close it; that is a new choice, so not made here.
  2. mcp/src/pads.js:56 - a same-net SMD pad and TH pad at one center in one KiCad footprint (e.g. EP plus a thermal pin named differently) still merge, and the SMD pad name is lost. Edge case; tightening to the exact Allegro shape is a new choice.
  3. mcp/src/pads.js:56 - a pair with net "" on one pad and no net on the other is not merged. generate.py writes both the same way; speculative.
  4. mcp/src/boards.js:38 - ENAMETOOLONG and ELOOP still come out as internal errors, not tool errors. Minor.
  5. mcp/src/tools/locate.js:58 - footprintsByRef scans all footprints per ref. Cost is small for real boards; this was the round 1 fix as asked.
  6. build_boards.py:121 - path normalization for the skipped set is done in main and in write_index. Minor duplication.
  7. tests/browser/board-index.spec.js:120 - future mtimes in the rebuilt-title test. The finally block restores and backdates the source, so later tests are not affected.

CI green on 1448b4b. Merging by squash, as decided.

@Gherkin
Gherkin merged commit 2fe55d9 into main Oct 11, 2026
10 checks passed
@Gherkin
Gherkin deleted the agent-mcp branch October 11, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant