onshape-mcp
I wanted a way to drive Onshape from a language model the way I drive it with my hands. Click a face. Draw a sketch. Extrude. Chamfer. The existing Onshape MCP wraps the REST API and FeatureScript, which works but feels like coding, not modeling. This server aims at the other 70% of CAD work that lives in the viewport.
It uses a vision-capable LLM (Gemini web, no API key, my Plus cookies) as eyes, Playwright as hands, and an MCP surface so any MCP-aware client can talk to it. Every action is journaled, so I can undo, replay, or branch.
Status
- M0 done: scaffold, smoke tests, public repo, safety rails.
- M1 done: driver primitives, Onshape tool datasheet, 43 MCP tools across
sketching, constraints, dimensions, features and feature-tree editing, plus
the closed-loop
act(goal)agent and a deterministic fast path that skips the LLM entirely for parseable goals. - M2 next: pattern, mirror_body, assembly.mate, sketch.constrain flyout, journal-replay undo, a real perceptual-diff for the stuck detector.
A note on sizes
Sizes come from Onshape's dimension solver, not from pixel measurements.
Tools draw a rough shape at a scale derived from the live canvas, then drive
the true millimetre value in — so a rectangle or circle is exactly the size
you asked for regardless of zoom. All dimension arguments are millimetres
unless you write a unit ("10 cm", "2 in"); bare numbers are never
reinterpreted. Polygon radius is currently drawn-to-scale but not
solver-driven; see the ponytail: note in ui_actions.py.
What it looks like from the client side
Two ways to drive it:
Direct tools. I call individual MCP tools when I want fine control:
screenshot, describe_view, viewport_size, journal_tail, tool_datasheet,
open_doc, and the per-tool onshape_* ones. Useful when I want to see
each step and steer.
Closed-loop act. I just say what I want:
act(goal="draw a 50x30mm rectangle on the top plane and extrude it 10mm").
Gemini sees each screenshot, picks the next tool, calls it, repeats until
the goal is met or it bails. Bounded by max_steps (default 25) and a
stuck detector (3 identical screenshots in a row = stop).
Install
Add this to your MCP client's config. Nothing else — no account, no API key, no per-user server, no token.
{
"mcpServers": {
"onshape": {
"command": "uvx",
"args": ["--from", "git+https://github.com/WilliamSamson/onshape-mcp", "onshape-mcp"]
}
}
}
| Client | File |
|---|---|
| Claude Desktop | claude_desktop_config.json |
| Claude Code | claude mcp add onshape -- uvx --from git+https://github.com/WilliamSamson/onshape-mcp onshape-mcp |
| Cursor | ~/.cursor/mcp.json |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
| VS Code (Cline / Roo) | cline_mcp_settings.json |
Or let it find and write those for you:
uvx --from git+https://github.com/WilliamSamson/onshape-mcp onshape-mcp setup -y
Restart the client and ask for something:
Draw a 50 x 30 mm rectangle on the Top plane
Why there is nothing to configure
Your Onshape session is read from the browser you already use. If you are
signed in to Onshape in Chrome, Brave, Edge, Firefox or Vivaldi, the server
picks that session up. Nothing to export, no keys to paste, and no per-user
server to stand up. Signed out? Run onshape-mcp login once.
Chromium is downloaded on first use (~150 MB). setup does it up front so the
first request is not waiting on a download.
Optional .env settings: ONSHAPE_DEFAULT_DOC to pin a document,
ONSHAPE_HEADLESS=false to watch it work, ONSHAPE_CDP_URL to drive a Chrome
you already have open.
Remote clients (ChatGPT web)
The config above covers every client that speaks MCP over stdio. ChatGPT on the
web needs an HTTPS endpoint instead, which is what up provides.
One command: up
uv run onshape-mcp up
That is the whole setup. It stops stale servers and orphaned tunnels (an abandoned tunnel keeps a public hostname alive with nothing behind it, which is what a 502 in your client actually means), frees the port, checks the browser engine, opens Onshape and confirms you are signed in, then serves and prints the connector URL.
It refuses to print a URL it has not proven works. Counting cookies is not a check: Onshape's session cookies carry no expiry and are revoked server-side, so a dead session looks identical to a live one on disk.
Paste the printed URL into ChatGPT under Settings → Connectors → Add. It drives the browser on your machine with your Onshape session.
Make the URL permanent
By default the hostname is random each run, so you must re-paste it after every restart. To stop that, reserve an ngrok domain (free tier includes one) and set:
ONSHAPE_NGROK_DOMAIN=your-name.ngrok-free.app
up then uses it and the URL is identical every run — paste it into ChatGPT
once. ONSHAPE_TUNNEL_SUBDOMAIN asks localtunnel for a name instead, but that
is best-effort: it silently hands back a random hostname when the name is
taken, so the banner tells you whether you actually got what you asked for.
With a permanent URL the server can update itself in place:
onshape_mcp_version— the running commit, and whetherorigin/mainmoved.onshape_mcp_update(restart=True)— pull and re-exec. The URL survives, so the connector just reconnects.
The URL is a credential. Anyone holding it can edit your Onshape documents.
Pin MCP_TOKEN in .env to keep the token stable across restarts.
The Space (deploy_hf/) installs this package from git rather than vendoring a
copy of src/, serves the same /mcp endpoint, and requires its own
MCP_TOKEN secret before it will start.
A note on Google + automated browsers
Google blocks automated browser logins ("This browser or app may not be
secure") when you launch Playwright's bundled Chromium against a Google
login page. I hit this. The fix in bootstrap.py is to try reading the
Gemini cookies straight out of my real Chrome session first, via
browser-cookie3. No browser launch, no automation block. If that
somehow fails (Chrome locked, no Chrome installed), it falls back to
launching real Chrome via Playwright (channel="chrome"), and only as
a last resort launches bundled Chromium.
The same Chrome-first choice applies to the main driver. Set
ONSHAPE_BROWSER_CHANNEL=auto (the default) and the server uses real
Chrome when available, falling back to bundled Chromium. Set it to
chromium to skip the Chrome attempt (e.g. on a Pi with no Chrome
installed).
Architecture in one screen
┌────────────────────────────────────────┐
│ MCP client (Claude Code, Cursor, …) │
└──────────────────┬─────────────────────┘
│ mcp__onshape__* tool calls
┌──────────────────▼─────────────────────┐
│ onshape-mcp server (this repo) │
│ ┌──────────┐ ┌──────────┐ ┌──────┐ │
│ │ tools │ │ journal │ │ loop │ │
│ └────┬─────┘ └────┬─────┘ └───┬──┘ │
└───────┼─────────────┼────────────┼─────┘
│ │ │
┌───────────────▼─────┐ ┌────▼─────┐ ┌───▼────────────┐
│ Playwright driver │ │ JSONL │ │ Gemini web │
│ (headless Chromium)│ │ state/ │ │ (vision + LLM)│
└───────────────┬─────┘ └──────────┘ └────────────────┘
│
┌───────▼────────┐
│ Onshape web │
└────────────────┘
Five layers, bottom up:
- Driver primitives in
driver.py. Click, type, press chord, drag, find by text, screenshot. Knows nothing about Onshape. - UI bindings in
shortcuts.py. Maps each semantic tool to its keyboard chord or toolbar button, with aconfidenceflag so I know what to retest. - UI actions in
ui_actions.py. Compose primitives + bindings into one logical op (sketch_rectangle(d, c1, c2)= activate tool, click c1, click c2, Esc, screenshot). Every op journals itself. - Dispatch table in
server.py. Maps tool names back to ui_actions functions, flattens LLM-friendly args to tuples. - Agent loop
act(goal)inserver.py. Screenshot, ask Gemini what's next, dispatch, repeat. Bounded bymax_stepsand a stuck detector.
License
MIT. See LICENSE.
Interactive sketch editing
A new one-step controller supports shared browser sessions, inline frames, confirmed target previews, detail edits, manual handoff, request retry protection, and checkpoint-checked sketch undo. See the interactive guide for setup, tool calls, verification semantics and remaining live acceptance checks.
Metadata
Release files for onshape-mcp 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| onshape_mcp-0.1.0.tar.gz | 101.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| onshape_mcp-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 193.5 kB
Release files / onshape_mcp-0.1.0.tar.gz
| Download URL | onshape_mcp-0.1.0.tar.gz |
|---|---|
| Size | 101.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5c788fd488aa6f0e84f89dcc2585ba51a223c987c65e0d2e54a8552bf863f239
|
|
BLAKE2b-256 checksum How to use checksums |
9193786fe586c882b31df336b6dcee4add605329814e533215be0f6ebe3a200f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / onshape_mcp-0.1.0-py3-none-any.whl
| Download URL | onshape_mcp-0.1.0-py3-none-any.whl |
|---|---|
| Size | 92.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c8f6d1d955177c52c43f63581dce974f600baf88f2cc7913858b85093e0c5004
|
|
BLAKE2b-256 checksum How to use checksums |
c64663096900ed46aee6d07741624387263347c65d5219029c3777304c1514e1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|