X-R-1-8 commited on
Commit
19a94ff
·
verified ·
1 Parent(s): 2930a63

Deploy Onshape MCP to Hugging Face Spaces

Browse files
Files changed (43) hide show
  1. LICENSE +21 -0
  2. README.md +20 -7
  3. __pycache__/app.cpython-314.pyc +0 -0
  4. app.py +176 -0
  5. packages.txt +16 -0
  6. pyproject.toml +51 -0
  7. requirements.txt +14 -0
  8. src/onshape_mcp.egg-info/PKG-INFO +156 -0
  9. src/onshape_mcp.egg-info/SOURCES.txt +27 -0
  10. src/onshape_mcp.egg-info/dependency_links.txt +1 -0
  11. src/onshape_mcp.egg-info/entry_points.txt +3 -0
  12. src/onshape_mcp.egg-info/requires.txt +14 -0
  13. src/onshape_mcp.egg-info/top_level.txt +1 -0
  14. src/onshape_mcp/__init__.py +3 -0
  15. src/onshape_mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  16. src/onshape_mcp/__pycache__/config.cpython-314.pyc +0 -0
  17. src/onshape_mcp/__pycache__/dispatch.cpython-314.pyc +0 -0
  18. src/onshape_mcp/__pycache__/driver.cpython-314.pyc +0 -0
  19. src/onshape_mcp/__pycache__/fast_exec.cpython-314.pyc +0 -0
  20. src/onshape_mcp/__pycache__/intent.cpython-314.pyc +0 -0
  21. src/onshape_mcp/__pycache__/journal.cpython-314.pyc +0 -0
  22. src/onshape_mcp/__pycache__/loop.cpython-314.pyc +0 -0
  23. src/onshape_mcp/__pycache__/server.cpython-314.pyc +0 -0
  24. src/onshape_mcp/__pycache__/setup.cpython-314.pyc +0 -0
  25. src/onshape_mcp/__pycache__/shortcuts.cpython-314.pyc +0 -0
  26. src/onshape_mcp/__pycache__/tools.cpython-314.pyc +0 -0
  27. src/onshape_mcp/__pycache__/tunnel.cpython-314.pyc +0 -0
  28. src/onshape_mcp/__pycache__/ui_actions.cpython-314.pyc +0 -0
  29. src/onshape_mcp/__pycache__/vision.cpython-314.pyc +0 -0
  30. src/onshape_mcp/config.py +96 -0
  31. src/onshape_mcp/dispatch.py +360 -0
  32. src/onshape_mcp/driver.py +423 -0
  33. src/onshape_mcp/fast_exec.py +77 -0
  34. src/onshape_mcp/intent.py +243 -0
  35. src/onshape_mcp/journal.py +54 -0
  36. src/onshape_mcp/loop.py +201 -0
  37. src/onshape_mcp/server.py +697 -0
  38. src/onshape_mcp/setup.py +373 -0
  39. src/onshape_mcp/shortcuts.py +498 -0
  40. src/onshape_mcp/tools.py +539 -0
  41. src/onshape_mcp/tunnel.py +131 -0
  42. src/onshape_mcp/ui_actions.py +1216 -0
  43. src/onshape_mcp/vision.py +240 -0
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kayode Williams Olalere
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,13 +1,26 @@
1
  ---
2
- title: Onshape Mcp
3
- emoji: 🦀
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Onshape CAD MCP
3
+ emoji: 📐
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 5.20.0
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # 📐 Onshape MCP Server
13
+
14
+ A natural-language agent that models 3D CAD directly in Onshape. Drives the Onshape web canvas using Playwright hands and Gemini vision.
15
+
16
+ ### 🔗 Connect to ChatGPT / Claude / Cursor
17
+
18
+ **MCP SSE Endpoint:**
19
+ ```text
20
+ https://x-r-1-8-onshape-mcp.hf.space/sse
21
+ ```
22
+
23
+ ### Features
24
+ * **Zero Installation for Testers**: Connect directly over SSE without running local code.
25
+ * **Full Sketch Suite**: 35+ tools (lines, rectangles, splines, polygons, fillets, chamfers, offset, trim, mirror).
26
+ * **Deterministic Fast Engine**: 0-token instant CAD operations without LLM overhead.
__pycache__/app.cpython-314.pyc ADDED
Binary file (8.26 kB). View file
 
app.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Space Application for Onshape MCP.
2
+ Provides both:
3
+ 1. An interactive Gradio web playground at `/`
4
+ 2. A full Model Context Protocol (MCP) SSE endpoint at `/sse` for ChatGPT, Claude, and Cursor.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ # Add src to sys.path
17
+ sys.path.insert(0, str(Path(__file__).parent / "src"))
18
+
19
+ import gradio as gr
20
+ from fastapi import FastAPI
21
+ from starlette.middleware.cors import CORSMiddleware
22
+
23
+ # Auto-ensure Playwright chromium is installed
24
+ try:
25
+ subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=False)
26
+ except Exception:
27
+ pass
28
+
29
+ # ZeroGPU compatibility (no-op decorator so ZeroGPU hardware remains active without burning quota)
30
+ try:
31
+ import spaces
32
+
33
+ @spaces.GPU
34
+ def _gpu_keepalive() -> str:
35
+ return "GPU active"
36
+ except Exception:
37
+ def _gpu_keepalive() -> str:
38
+ return "CPU fallback"
39
+
40
+ # Import Onshape MCP server
41
+ from onshape_mcp.server import mcp
42
+ from onshape_mcp.intent import parse as parse_intent
43
+ from onshape_mcp.tools import ALL_TOOLS
44
+
45
+ # 1. Build Gradio Interface
46
+ def test_prompt(user_prompt: str) -> str:
47
+ """Preview how Onshape MCP parses a prompt into CAD tools."""
48
+ if not user_prompt.strip():
49
+ return "Please enter a CAD prompt (e.g., 'Draw a 10cm by 5cm box on the Top plane')."
50
+ try:
51
+ plan = parse_intent(user_prompt)
52
+ if plan:
53
+ return json.dumps(
54
+ {
55
+ "status": "Recognized Deterministic CAD Actions",
56
+ "action_count": len(plan),
57
+ "actions": [
58
+ {"tool": tool, "args": args} for tool, args in plan
59
+ ],
60
+ },
61
+ indent=2,
62
+ )
63
+ else:
64
+ return json.dumps(
65
+ {
66
+ "status": "Complex Multi-Step Goal",
67
+ "handler": "Autonomous Vision Loop (act)",
68
+ "note": "Will be executed closed-loop in Onshape.",
69
+ },
70
+ indent=2,
71
+ )
72
+ except Exception as e:
73
+ return f"Error parsing prompt: {e}"
74
+
75
+
76
+ def list_tools() -> str:
77
+ """Return available sketch tools catalog."""
78
+ tools_summary = []
79
+ for tool in ALL_TOOLS:
80
+ tools_summary.append({
81
+ "name": tool.name,
82
+ "purpose": tool.purpose,
83
+ "requires": tool.requires,
84
+ "produces": tool.produces,
85
+ "status": tool.status,
86
+ })
87
+ return json.dumps(tools_summary, indent=2)
88
+
89
+
90
+ with gr.Blocks(title="Onshape MCP — Autonomous CAD AI", theme=gr.themes.Soft()) as demo:
91
+ gr.Markdown(
92
+ """
93
+ # 📐 Onshape MCP — Autonomous CAD Agent
94
+ ### Control Onshape directly from ChatGPT, Claude, Cursor, and any MCP-compatible AI.
95
+ """
96
+ )
97
+
98
+ with gr.Row():
99
+ with gr.Column(scale=2):
100
+ gr.Markdown(
101
+ """
102
+ ### 🔗 Connect This Space to Your AI Client
103
+
104
+ **MCP SSE Endpoint:**
105
+ ```text
106
+ https://x-r-1-8-onshape-mcp.hf.space/sse
107
+ ```
108
+
109
+ * **ChatGPT Web**: In Custom GPTs or MCP Settings, add the SSE URL above.
110
+ * **Claude Desktop**: Add this block to `claude_desktop_config.json`:
111
+ ```json
112
+ {
113
+ "mcpServers": {
114
+ "onshape": {
115
+ "url": "https://x-r-1-8-onshape-mcp.hf.space/sse"
116
+ }
117
+ }
118
+ }
119
+ ```
120
+ * **Cursor / LibreChat**: Add as a remote SSE MCP server.
121
+ """
122
+ )
123
+
124
+ with gr.Column(scale=1):
125
+ gr.Markdown(
126
+ """
127
+ ### ⚙️ Space Status
128
+ * **Hardware**: ZeroGPU (RTX PRO 6000)
129
+ * **Status**: 🟢 Server Live
130
+ * **Sketch Tools**: 35+ Tools & 13 Constraints
131
+ * **Protocol**: Model Context Protocol (2024-11-05)
132
+ """
133
+ )
134
+
135
+ gr.Markdown("---")
136
+ gr.Markdown("### 🧪 Test CAD Natural Language Parser")
137
+
138
+ with gr.Row():
139
+ with gr.Column():
140
+ prompt_input = gr.Textbox(
141
+ label="CAD Prompt",
142
+ placeholder="e.g. Draw a 10cm by 5cm box on the Top plane and extrude 20mm",
143
+ lines=2,
144
+ value="Draw a 10cm by 5cm box on the Top plane",
145
+ )
146
+ btn_run = gr.Button("Parse Intent Plan", variant="primary")
147
+ with gr.Column():
148
+ output_json = gr.Code(label="Parsed Deterministic Plan", language="json")
149
+
150
+ btn_run.click(fn=test_prompt, inputs=[prompt_input], outputs=[output_json])
151
+
152
+ with gr.Accordion("📚 View Full CAD Tools Catalog (35+ Tools)", open=False):
153
+ tools_viewer = gr.Code(value=list_tools(), language="json", lines=15)
154
+
155
+ # 2. Create Parent FastAPI App
156
+ app = FastAPI()
157
+
158
+ # Enable CORS for web clients
159
+ app.add_middleware(
160
+ CORSMiddleware,
161
+ allow_origins=["*"],
162
+ allow_credentials=True,
163
+ allow_methods=["*"],
164
+ allow_headers=["*"],
165
+ )
166
+
167
+ # 3. Mount FastMCP SSE App at /
168
+ sse_app = mcp.sse_app()
169
+ app.mount("/mcp", sse_app)
170
+
171
+ # Also expose direct /sse and /messages routes at root
172
+ for route in sse_app.routes:
173
+ app.routes.append(route)
174
+
175
+ # 4. Mount Gradio interface at root
176
+ app = gr.mount_gradio_app(app, demo, path="/")
packages.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ libnss3
2
+ libnspr4
3
+ libatk1.0-0
4
+ libatk-bridge2.0-0
5
+ libcups2
6
+ libdrm2
7
+ libxkbcommon0
8
+ libxcomposite1
9
+ libxdamage1
10
+ libxfixes3
11
+ libxrandr2
12
+ libgbm1
13
+ libpango-1.0-0
14
+ libcairo2
15
+ libasound2
16
+ xvfb
pyproject.toml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "onshape-mcp"
3
+ version = "0.0.1"
4
+ description = "Visual agentic MCP server for Onshape, driven by Gemini web vision."
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ requires-python = ">=3.11"
8
+ authors = [
9
+ { name = "Kayode Williams Olalere" },
10
+ ]
11
+ dependencies = [
12
+ "mcp>=1.2.0,<2.0.0", # TODO: migrate to MCPServer in M2
13
+ "playwright>=1.48.0",
14
+ "pydantic>=2.8.0",
15
+ "python-dotenv>=1.0.1",
16
+ "Pillow>=10.4.0",
17
+ "httpx>=0.27.0",
18
+ "browser-cookie3>=0.20.1",
19
+ "flask>=3.0.0",
20
+ "gemini-webapi>=2.0.0", # verify GeminiClient API on first install
21
+ ]
22
+
23
+ [project.scripts]
24
+ onshape-mcp = "onshape_mcp.server:main"
25
+ onshape-mcp-setup = "onshape_mcp.setup:main"
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.3.0",
30
+ "ruff>=0.6.0",
31
+ "mypy>=1.11.0",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=68.0", "wheel"]
36
+ build-backend = "setuptools.build_meta"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py311"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "W", "I"]
47
+ ignore = ["E501"]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+ addopts = "-ra -q -p no:launch_testing -p no:launch_ros"
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=5.0.0
2
+ spaces
3
+ fastapi
4
+ starlette
5
+ uvicorn
6
+ mcp>=1.2.0,<2.0.0
7
+ playwright>=1.48.0
8
+ pydantic>=2.8.0
9
+ python-dotenv>=1.0.1
10
+ Pillow>=10.4.0
11
+ httpx>=0.27.0
12
+ browser-cookie3>=0.20.1
13
+ flask>=3.0.0
14
+ gemini-webapi>=2.0.0
src/onshape_mcp.egg-info/PKG-INFO ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: onshape-mcp
3
+ Version: 0.0.1
4
+ Summary: Visual agentic MCP server for Onshape, driven by Gemini web vision.
5
+ Author: Kayode Williams Olalere
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Kayode Williams Olalere
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.11
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: mcp<2.0.0,>=1.2.0
32
+ Requires-Dist: playwright>=1.48.0
33
+ Requires-Dist: pydantic>=2.8.0
34
+ Requires-Dist: python-dotenv>=1.0.1
35
+ Requires-Dist: Pillow>=10.4.0
36
+ Requires-Dist: httpx>=0.27.0
37
+ Requires-Dist: browser-cookie3>=0.20.1
38
+ Requires-Dist: flask>=3.0.0
39
+ Requires-Dist: gemini-webapi>=2.0.0
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=8.3.0; extra == "dev"
42
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
43
+ Requires-Dist: mypy>=1.11.0; extra == "dev"
44
+ Dynamic: license-file
45
+
46
+ # onshape-mcp
47
+
48
+ I wanted a way to drive Onshape from a language model the way I drive it with
49
+ my hands. Click a face. Draw a sketch. Extrude. Chamfer. The existing Onshape
50
+ MCP wraps the REST API and FeatureScript, which works but feels like coding,
51
+ not modeling. This server aims at the other 70% of CAD work that lives in
52
+ the viewport.
53
+
54
+ It uses a vision-capable LLM (Gemini web, no API key, my Plus cookies) as
55
+ eyes, Playwright as hands, and an MCP surface so any MCP-aware client can
56
+ talk to it. Every action is journaled, so I can undo, replay, or branch.
57
+
58
+ ## Status
59
+
60
+ - **M0** done: scaffold, smoke tests, public repo, safety rails.
61
+ - **M1** done: driver primitives, Onshape tool datasheet, 10 wired tools
62
+ (`view.fit`, `sketch.start/rectangle/circle/line/exit`, `feature.extrude/fillet/chamfer`,
63
+ `select.face/edge`, `ui.undo/redo`), closed-loop `act(goal)` agent.
64
+ - **M2** next: pattern, mirror_body, assembly.mate, sketch.constrain flyout,
65
+ journal-replay undo, a real perceptual-diff for the stuck detector.
66
+
67
+ ## What it looks like from the client side
68
+
69
+ Two ways to drive it:
70
+
71
+ **Direct tools.** I call individual MCP tools when I want fine control:
72
+ `screenshot`, `describe_view`, `viewport_size`, `journal_tail`, `tool_datasheet`,
73
+ `open_doc`, and the per-tool `onshape_*` ones. Useful when I want to see
74
+ each step and steer.
75
+
76
+ **Closed-loop `act`.** I just say what I want:
77
+ `act(goal="draw a 50x30mm rectangle on the top plane and extrude it 10mm")`.
78
+ Gemini sees each screenshot, picks the next tool, calls it, repeats until
79
+ the goal is met or it bails. Bounded by `max_steps` (default 25) and a
80
+ stuck detector (3 identical screenshots in a row = stop).
81
+
82
+ ## How I run it
83
+
84
+ ```bash
85
+ # one-shot bootstrap (use python3 explicitly, "python" is not always aliased)
86
+ python3 scripts/bootstrap.py
87
+
88
+ # then start the server
89
+ source .venv/bin/activate
90
+ python3 -m onshape_mcp.server
91
+ ```
92
+
93
+ `bootstrap.py` does the venv, the pip install, the Chromium download, the
94
+ cookie extraction, the Onshape login, and the M0 sanity checks. Re-run it
95
+ any time; it's idempotent.
96
+
97
+ ## A note on Google + automated browsers
98
+
99
+ Google blocks automated browser logins ("This browser or app may not be
100
+ secure") when you launch Playwright's bundled Chromium against a Google
101
+ login page. I hit this. The fix in `bootstrap.py` is to try reading the
102
+ Gemini cookies straight out of my real Chrome session first, via
103
+ `browser-cookie3`. No browser launch, no automation block. If that
104
+ somehow fails (Chrome locked, no Chrome installed), it falls back to
105
+ launching real Chrome via Playwright (`channel="chrome"`), and only as
106
+ a last resort launches bundled Chromium.
107
+
108
+ The same Chrome-first choice applies to the main driver. Set
109
+ `ONSHAPE_BROWSER_CHANNEL=auto` (the default) and the server uses real
110
+ Chrome when available, falling back to bundled Chromium. Set it to
111
+ `chromium` to skip the Chrome attempt (e.g. on a Pi with no Chrome
112
+ installed).
113
+
114
+ ## Architecture in one screen
115
+
116
+ ```
117
+ ┌───────────────���────────────────────────┐
118
+ │ MCP client (Claude Code, Cursor, …) │
119
+ └──────────────────┬─────────────────────┘
120
+ │ mcp__onshape__* tool calls
121
+ ┌──────────────────▼─────────────────────┐
122
+ │ onshape-mcp server (this repo) │
123
+ │ ┌──────────┐ ┌──────────┐ ┌──────┐ │
124
+ │ │ tools │ │ journal │ │ loop │ │
125
+ │ └────┬─────┘ └────┬─────┘ └───┬──┘ │
126
+ └───────┼─────────────┼────────────┼─────┘
127
+ │ │ │
128
+ ┌───────────────▼─────┐ ┌────▼─────┐ ┌───▼────────────┐
129
+ │ Playwright driver │ │ JSONL │ │ Gemini web │
130
+ │ (headless Chromium)│ │ state/ │ │ (vision + LLM)│
131
+ └───────────────┬─────┘ └──────────┘ └────────────────┘
132
+ │
133
+ ┌───────▼────────┐
134
+ │ Onshape web │
135
+ └────────────────┘
136
+ ```
137
+
138
+ Five layers, bottom up:
139
+
140
+ 1. **Driver primitives** in `driver.py`. Click, type, press chord, drag,
141
+ find by text, screenshot. Knows nothing about Onshape.
142
+ 2. **UI bindings** in `shortcuts.py`. Maps each semantic tool to its
143
+ keyboard chord or toolbar button, with a `confidence` flag so I know
144
+ what to retest.
145
+ 3. **UI actions** in `ui_actions.py`. Compose primitives + bindings into
146
+ one logical op (`sketch_rectangle(d, c1, c2)` = activate tool, click
147
+ c1, click c2, Esc, screenshot). Every op journals itself.
148
+ 4. **Dispatch table** in `server.py`. Maps tool names back to ui_actions
149
+ functions, flattens LLM-friendly args to tuples.
150
+ 5. **Agent loop** `act(goal)` in `server.py`. Screenshot, ask Gemini
151
+ what's next, dispatch, repeat. Bounded by `max_steps` and a stuck
152
+ detector.
153
+
154
+ ## License
155
+
156
+ MIT. See [LICENSE](LICENSE).
src/onshape_mcp.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/onshape_mcp/__init__.py
5
+ src/onshape_mcp/config.py
6
+ src/onshape_mcp/dispatch.py
7
+ src/onshape_mcp/driver.py
8
+ src/onshape_mcp/fast_exec.py
9
+ src/onshape_mcp/intent.py
10
+ src/onshape_mcp/journal.py
11
+ src/onshape_mcp/loop.py
12
+ src/onshape_mcp/server.py
13
+ src/onshape_mcp/setup.py
14
+ src/onshape_mcp/shortcuts.py
15
+ src/onshape_mcp/tools.py
16
+ src/onshape_mcp/ui_actions.py
17
+ src/onshape_mcp/vision.py
18
+ src/onshape_mcp.egg-info/PKG-INFO
19
+ src/onshape_mcp.egg-info/SOURCES.txt
20
+ src/onshape_mcp.egg-info/dependency_links.txt
21
+ src/onshape_mcp.egg-info/entry_points.txt
22
+ src/onshape_mcp.egg-info/requires.txt
23
+ src/onshape_mcp.egg-info/top_level.txt
24
+ tests/test_intent.py
25
+ tests/test_planes_and_quadrants.py
26
+ tests/test_sketch_tools_spec.py
27
+ tests/test_smoke.py
src/onshape_mcp.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/onshape_mcp.egg-info/entry_points.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [console_scripts]
2
+ onshape-mcp = onshape_mcp.server:main
3
+ onshape-mcp-setup = onshape_mcp.setup:main
src/onshape_mcp.egg-info/requires.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mcp<2.0.0,>=1.2.0
2
+ playwright>=1.48.0
3
+ pydantic>=2.8.0
4
+ python-dotenv>=1.0.1
5
+ Pillow>=10.4.0
6
+ httpx>=0.27.0
7
+ browser-cookie3>=0.20.1
8
+ flask>=3.0.0
9
+ gemini-webapi>=2.0.0
10
+
11
+ [dev]
12
+ pytest>=8.3.0
13
+ ruff>=0.6.0
14
+ mypy>=1.11.0
src/onshape_mcp.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ onshape_mcp
src/onshape_mcp/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """onshape-mcp. Visual agentic MCP server for Onshape."""
2
+
3
+ __version__ = "0.0.1"
src/onshape_mcp/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (270 Bytes). View file
 
src/onshape_mcp/__pycache__/config.cpython-314.pyc ADDED
Binary file (6.01 kB). View file
 
src/onshape_mcp/__pycache__/dispatch.cpython-314.pyc ADDED
Binary file (31.3 kB). View file
 
src/onshape_mcp/__pycache__/driver.cpython-314.pyc ADDED
Binary file (26.8 kB). View file
 
src/onshape_mcp/__pycache__/fast_exec.cpython-314.pyc ADDED
Binary file (5.13 kB). View file
 
src/onshape_mcp/__pycache__/intent.cpython-314.pyc ADDED
Binary file (12.1 kB). View file
 
src/onshape_mcp/__pycache__/journal.cpython-314.pyc ADDED
Binary file (4.14 kB). View file
 
src/onshape_mcp/__pycache__/loop.cpython-314.pyc ADDED
Binary file (11.6 kB). View file
 
src/onshape_mcp/__pycache__/server.cpython-314.pyc ADDED
Binary file (42.4 kB). View file
 
src/onshape_mcp/__pycache__/setup.cpython-314.pyc ADDED
Binary file (18.7 kB). View file
 
src/onshape_mcp/__pycache__/shortcuts.cpython-314.pyc ADDED
Binary file (12.6 kB). View file
 
src/onshape_mcp/__pycache__/tools.cpython-314.pyc ADDED
Binary file (14.1 kB). View file
 
src/onshape_mcp/__pycache__/tunnel.cpython-314.pyc ADDED
Binary file (6.44 kB). View file
 
src/onshape_mcp/__pycache__/ui_actions.cpython-314.pyc ADDED
Binary file (83.3 kB). View file
 
src/onshape_mcp/__pycache__/vision.cpython-314.pyc ADDED
Binary file (14.7 kB). View file
 
src/onshape_mcp/config.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration loaded from .env. The .env file is gitignored (see SECURITY.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from dotenv import load_dotenv
9
+
10
+
11
+ def _find_base_dir() -> Path:
12
+ """Determine the base directory for onshape-mcp configuration, cookies, and state.
13
+
14
+ Priority:
15
+ 1. ONSHAPE_DIR or ONSHAPE_HOME environment variable if explicitly set.
16
+ 2. Local source repository if pyproject.toml and src/onshape_mcp exist in cwd or parents.
17
+ 3. User home directory fallback: ~/.onshape-mcp (safe for uvx, pip, Claude Desktop).
18
+ """
19
+ if "ONSHAPE_DIR" in os.environ:
20
+ return Path(os.environ["ONSHAPE_DIR"]).expanduser().resolve()
21
+ if "ONSHAPE_HOME" in os.environ:
22
+ return Path(os.environ["ONSHAPE_HOME"]).expanduser().resolve()
23
+
24
+ cwd = Path.cwd()
25
+ for p in [cwd, *cwd.parents]:
26
+ if (p / "pyproject.toml").is_file() and (p / "src" / "onshape_mcp").is_dir():
27
+ return p
28
+
29
+ user_base = Path.home() / ".onshape-mcp"
30
+ user_base.mkdir(parents=True, exist_ok=True)
31
+ return user_base
32
+
33
+
34
+ _BASE_DIR = _find_base_dir()
35
+
36
+ # Load env from base directory (.env in repo or ~/.onshape-mcp/.env)
37
+ if (_BASE_DIR / ".env").is_file():
38
+ load_dotenv(_BASE_DIR / ".env")
39
+ elif (Path.home() / ".onshape-mcp" / ".env").is_file():
40
+ load_dotenv(Path.home() / ".onshape-mcp" / ".env")
41
+
42
+
43
+ def _path(key: str, default: str) -> Path:
44
+ raw = os.getenv(key, default)
45
+ p = Path(raw).expanduser()
46
+ if not p.is_absolute():
47
+ p = (_BASE_DIR / p).resolve()
48
+ return p
49
+
50
+
51
+ def _bool(key: str, default: bool) -> bool:
52
+ val = os.getenv(key)
53
+ if val is None:
54
+ return default
55
+ return val.strip().lower() in ("1", "true", "yes", "on")
56
+
57
+
58
+ class Settings:
59
+ # Onshape cookies live as plain JSON, not in Chrome's encrypted DB.
60
+ # Chrome's DB on Linux uses a keyring key that's only available to
61
+ # graphical sessions; headless Playwright can't decrypt, so cookies
62
+ # look empty. Storing as JSON dodges the whole keyring mess.
63
+ onshape_cookie_file: Path = _path("ONSHAPE_COOKIE_FILE", "./cookies/onshape.cookies.json")
64
+ onshape_browser_profile: Path = _path("ONSHAPE_BROWSER_PROFILE", "./playwright-profile")
65
+ gemini_cookie_file: Path = _path("GEMINI_COOKIE_FILE", "./cookies/gemini.cookies.json")
66
+ journal_dir: Path = _path("JOURNAL_DIR", "./state")
67
+ log_dir: Path = _path("LOG_DIR", "./logs")
68
+ gemini_model: str = os.getenv("GEMINI_MODEL", "gemini-flash")
69
+ gemini_temporary_chat: bool = _bool("GEMINI_TEMPORARY_CHAT", True)
70
+ gemini_auto_cleanup: bool = _bool("GEMINI_AUTO_CLEANUP", True)
71
+ onshape_default_doc: str = os.getenv("ONSHAPE_DEFAULT_DOC", "")
72
+ # "auto" tries real Chrome first then bundled Chromium,
73
+ # "chrome" requires real Chrome, "chromium" uses bundled Chromium only.
74
+ browser_channel: str = os.getenv("ONSHAPE_BROWSER_CHANNEL", "auto")
75
+
76
+ def __repr__(self) -> str: # never leak cookie/profile paths into logs
77
+ return (
78
+ f"Settings(gemini_model={self.gemini_model!r}, "
79
+ f"gemini_temporary_chat={self.gemini_temporary_chat}, "
80
+ f"gemini_auto_cleanup={self.gemini_auto_cleanup}, "
81
+ f"browser_channel={self.browser_channel!r}, "
82
+ f"onshape_default_doc={self.onshape_default_doc!r})"
83
+ )
84
+
85
+
86
+ settings = Settings()
87
+
88
+ # Make sure local-only directories exist.
89
+ for d in (
90
+ settings.onshape_cookie_file.parent,
91
+ settings.onshape_browser_profile,
92
+ settings.gemini_cookie_file.parent,
93
+ settings.journal_dir,
94
+ settings.log_dir,
95
+ ):
96
+ d.mkdir(parents=True, exist_ok=True)
src/onshape_mcp/dispatch.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared dispatch logic. The LLM calls a tool by name with flat kwargs;
2
+ we reshape them to the function signatures in ui_actions.
3
+
4
+ Used by both the MCP server (act tool) and the run_task CLI so they
5
+ can't drift apart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from typing import Any
13
+
14
+ from . import ui_actions
15
+ from .driver import OnshapeDriver
16
+
17
+
18
+ def parse_decision(text: str) -> dict[str, Any]:
19
+ """Pull a JSON object out of a Gemini reply. Tolerant of markdown fences."""
20
+ fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
21
+ if fenced:
22
+ candidate = fenced.group(1)
23
+ else:
24
+ brace = re.search(r"\{.*\}", text, re.DOTALL)
25
+ candidate = brace.group(0) if brace else text
26
+ try:
27
+ return json.loads(candidate)
28
+ except json.JSONDecodeError:
29
+ return {"done": True, "summary": f"Could not parse decision: {text[:200]}"}
30
+
31
+
32
+ def _xy(
33
+ args: dict[str, Any], x_key: str, y_key: str, default: tuple[float, float] = (720.0, 450.0)
34
+ ) -> tuple[float, float]:
35
+ if x_key in args and y_key in args:
36
+ try:
37
+ return (float(args[x_key]), float(args[y_key]))
38
+ except (ValueError, TypeError):
39
+ pass
40
+ if "_" in x_key:
41
+ prefix = x_key.rsplit("_", 1)[0]
42
+ if prefix in args:
43
+ val = args[prefix]
44
+ if isinstance(val, (list, tuple)) and len(val) >= 2:
45
+ try:
46
+ return (float(val[0]), float(val[1]))
47
+ except (ValueError, TypeError):
48
+ pass
49
+ if isinstance(val, dict):
50
+ try:
51
+ return (
52
+ float(val.get("x", val.get("0", default[0]))),
53
+ float(val.get("y", val.get("1", default[1]))),
54
+ )
55
+ except (ValueError, TypeError):
56
+ pass
57
+ # Fallback to generic x, y
58
+ if "x" in args and "y" in args:
59
+ try:
60
+ return (float(args["x"]), float(args["y"]))
61
+ except (ValueError, TypeError):
62
+ pass
63
+ # Fallback to point / pt / coords
64
+ for key in ("point", "pt", "coords", "corner"):
65
+ if key in args:
66
+ val = args[key]
67
+ if isinstance(val, (list, tuple)) and len(val) >= 2:
68
+ try:
69
+ return (float(val[0]), float(val[1]))
70
+ except (ValueError, TypeError):
71
+ pass
72
+ return default
73
+
74
+
75
+ def _dim_val(args: dict[str, Any], key: str) -> Any:
76
+ for k in (f"{key}_mm", f"{key}_cm", key, f"{key}_val", f"{key}_size"):
77
+ if k in args:
78
+ val = args[k]
79
+ if k.endswith("_cm") and isinstance(val, (int, float)):
80
+ return f"{val} cm"
81
+ return val
82
+ return None
83
+
84
+
85
+ # Tool name -> async fn(d, args) that calls the right ui_action.
86
+ TOOL_DISPATCH: dict[str, Any] = {
87
+ "view.fit": lambda d, a: ui_actions.view_fit(d),
88
+ "view.top": lambda d, a: ui_actions.view_top(d),
89
+ "view.front": lambda d, a: ui_actions.view_front(d),
90
+ "view.iso": lambda d, a: ui_actions.view_iso(d),
91
+ "view.isometric": lambda d, a: ui_actions.view_isometric(d),
92
+ "sketch.start": lambda d, a: ui_actions.sketch_start(
93
+ d,
94
+ (a["plane_x"], a["plane_y"]) if "plane_x" in a and "plane_y" in a else None,
95
+ plane_name=a.get("plane"),
96
+ ),
97
+ "sketch.rectangle": lambda d, a: ui_actions.sketch_rectangle(
98
+ d,
99
+ _xy(a, "corner1_x", "corner1_y", default=(0.0, 0.0))
100
+ if ("corner1_x" in a or "corner1" in a or "corner" in a or "x1" in a)
101
+ else None,
102
+ _xy(a, "corner2_x", "corner2_y", default=(100.0, 100.0))
103
+ if ("corner2_x" in a or "corner2" in a or "x2" in a)
104
+ else None,
105
+ width=_dim_val(a, "width"),
106
+ height=_dim_val(a, "height"),
107
+ quadrant=a.get("quadrant"),
108
+ centered=a.get("centered") or a.get("center") or a.get("at_origin"),
109
+ ),
110
+ "sketch.circle": lambda d, a: ui_actions.sketch_circle(
111
+ d,
112
+ _xy(a, "center_x", "center_y", default=(0.0, 0.0))
113
+ if ("center_x" in a or "center" in a or "x" in a)
114
+ else None,
115
+ float(a.get("radius_px") or a.get("radius_mm") or a.get("radius", 50)),
116
+ centered=a.get("centered") or a.get("center") or a.get("at_origin"),
117
+ ),
118
+ "sketch.line": lambda d, a: ui_actions.sketch_line(
119
+ d, _xy(a, "p1_x", "p1_y"), _xy(a, "p2_x", "p2_y")
120
+ ),
121
+ "sketch.line_midpoint": lambda d, a: ui_actions.sketch_line(
122
+ d, _xy(a, "p1_x", "p1_y"), _xy(a, "p2_x", "p2_y")
123
+ ),
124
+ "sketch.rectangle_center": lambda d, a: ui_actions.sketch_rectangle(
125
+ d,
126
+ centered=True,
127
+ width=_dim_val(a, "width"),
128
+ height=_dim_val(a, "height"),
129
+ ),
130
+ "sketch.rectangle_aligned": lambda d, a: ui_actions.sketch_rectangle(
131
+ d,
132
+ _xy(a, "corner1_x", "corner1_y", default=(0.0, 0.0)),
133
+ _xy(a, "corner2_x", "corner2_y", default=(100.0, 100.0)),
134
+ ),
135
+ "sketch.circle_3point": lambda d, a: ui_actions.sketch_circle(
136
+ d, _xy(a, "p1_x", "p1_y", default=(0.0, 0.0))
137
+ ),
138
+ "sketch.ellipse": lambda d, a: ui_actions.sketch_circle(
139
+ d,
140
+ _xy(a, "center_x", "center_y", default=(0.0, 0.0)),
141
+ float(a.get("radius", 50.0)),
142
+ ),
143
+ "sketch.arc": lambda d, a: ui_actions.sketch_arc(
144
+ d,
145
+ _xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
146
+ _xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
147
+ _xy(a, "radius_x", "radius_y") if ("radius_x" in a or "radius" in a) else None,
148
+ ),
149
+ "sketch.arc_3point": lambda d, a: ui_actions.sketch_arc(
150
+ d,
151
+ _xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
152
+ _xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
153
+ _xy(a, "radius_x", "radius_y") if ("radius_x" in a or "radius" in a) else None,
154
+ ),
155
+ "sketch.arc_tangent": lambda d, a: ui_actions.sketch_arc(
156
+ d,
157
+ _xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
158
+ _xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
159
+ ),
160
+ "sketch.arc_center": lambda d, a: ui_actions.sketch_arc(
161
+ d,
162
+ _xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
163
+ _xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
164
+ ),
165
+ "sketch.polygon": lambda d, a: ui_actions.sketch_polygon(
166
+ d,
167
+ _xy(a, "center_x", "center_y", default=(0.0, 0.0))
168
+ if ("center_x" in a or "center" in a or "x" in a)
169
+ else None,
170
+ radius=float(a.get("radius_px") or a.get("radius_mm") or a.get("radius", 60.0)),
171
+ sides=int(a.get("sides", 6)),
172
+ circumscribed=bool(a.get("circumscribed", False)),
173
+ ),
174
+ "sketch.spline": lambda d, a: ui_actions.sketch_spline(
175
+ d,
176
+ a.get("points")
177
+ or [_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 3)))]
178
+ ),
179
+ "sketch.point": lambda d, a: ui_actions.sketch_point(d, _xy(a, "x", "y")),
180
+ "sketch.text": lambda d, a: ui_actions.sketch_text(
181
+ d,
182
+ _xy(a, "corner1_x", "corner1_y", default=(0.0, 0.0)),
183
+ _xy(a, "corner2_x", "corner2_y", default=(200.0, 100.0)),
184
+ str(a.get("text", "")),
185
+ ),
186
+ "sketch.use": lambda d, a: ui_actions.sketch_use(d, _xy(a, "x", "y")),
187
+ "sketch.construction": lambda d, a: ui_actions.sketch_construction(
188
+ d, _xy(a, "x", "y") if ("x" in a or "entity_x" in a or "entity" in a) else None
189
+ ),
190
+ "sketch.fillet": lambda d, a: ui_actions.sketch_fillet(
191
+ d,
192
+ _xy(a, "vertex_x", "vertex_y") if ("vertex_x" in a or "vertex" in a) else _xy(a, "x", "y"),
193
+ _dim_val(a, "radius") or 5.0,
194
+ ),
195
+ "sketch.chamfer": lambda d, a: ui_actions.sketch_chamfer(
196
+ d,
197
+ _xy(a, "vertex_x", "vertex_y") if ("vertex_x" in a or "vertex" in a) else _xy(a, "x", "y"),
198
+ _dim_val(a, "distance") or 5.0,
199
+ ),
200
+ "sketch.trim": lambda d, a: ui_actions.sketch_trim(d, _xy(a, "x", "y")),
201
+ "sketch.extend": lambda d, a: ui_actions.sketch_extend(d, _xy(a, "x", "y")),
202
+ "sketch.split": lambda d, a: ui_actions.sketch_split(d, _xy(a, "x", "y")),
203
+ "sketch.offset": lambda d, a: ui_actions.sketch_offset(
204
+ d, _xy(a, "x", "y"), _dim_val(a, "distance") or 5.0
205
+ ),
206
+ "sketch.slot": lambda d, a: ui_actions.sketch_slot(
207
+ d,
208
+ _xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
209
+ _xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
210
+ float(a.get("radius", 20.0)),
211
+ ),
212
+ "sketch.mirror": lambda d, a: ui_actions.sketch_mirror(
213
+ d,
214
+ _xy(a, "centerline_x", "centerline_y", default=(0.0, 0.0)),
215
+ *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 1)))],
216
+ ),
217
+ "sketch.pattern_linear": lambda d, a: ui_actions.wait(d, 0.5),
218
+ "sketch.pattern_circular": lambda d, a: ui_actions.wait(d, 0.5),
219
+ "sketch.transform": lambda d, a: ui_actions.wait(d, 0.5),
220
+ "sketch.dimension": lambda d, a: ui_actions.sketch_dimension(
221
+ d,
222
+ _xy(a, "entity_x", "entity_y"),
223
+ _xy(a, "label_x", "label_y")
224
+ if ("label_x" in a or "label" in a)
225
+ else (_xy(a, "entity_x", "entity_y")[0] + 30.0, _xy(a, "entity_x", "entity_y")[1] + 30.0),
226
+ _dim_val(a, "value") or 5,
227
+ ),
228
+ "sketch.equal": lambda d, a: ui_actions.sketch_equal(
229
+ d, *[(a[f"x{i}"], a[f"y{i}"]) for i in range(int(a.get("n", 2)))]
230
+ ),
231
+ "sketch.constrain": lambda d, a: ui_actions.sketch_constrain(
232
+ d,
233
+ a.get("type", "coincident"),
234
+ *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))],
235
+ ),
236
+ "constraint.coincident": lambda d, a: ui_actions.sketch_constrain(
237
+ d, "coincident", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
238
+ ),
239
+ "constraint.concentric": lambda d, a: ui_actions.sketch_constrain(
240
+ d, "concentric", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
241
+ ),
242
+ "constraint.parallel": lambda d, a: ui_actions.sketch_constrain(
243
+ d, "parallel", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
244
+ ),
245
+ "constraint.tangent": lambda d, a: ui_actions.sketch_constrain(
246
+ d, "tangent", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
247
+ ),
248
+ "constraint.horizontal": lambda d, a: ui_actions.sketch_constrain(
249
+ d, "horizontal", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 1)))]
250
+ ),
251
+ "constraint.vertical": lambda d, a: ui_actions.sketch_constrain(
252
+ d, "vertical", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 1)))]
253
+ ),
254
+ "constraint.perpendicular": lambda d, a: ui_actions.sketch_constrain(
255
+ d, "perpendicular", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
256
+ ),
257
+ "constraint.equal": lambda d, a: ui_actions.sketch_constrain(
258
+ d, "equal", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
259
+ ),
260
+ "constraint.midpoint": lambda d, a: ui_actions.sketch_constrain(
261
+ d, "midpoint", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
262
+ ),
263
+ "constraint.normal": lambda d, a: ui_actions.sketch_constrain(
264
+ d, "normal", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
265
+ ),
266
+ "constraint.pierce": lambda d, a: ui_actions.sketch_constrain(
267
+ d, "pierce", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
268
+ ),
269
+ "constraint.symmetric": lambda d, a: ui_actions.sketch_constrain(
270
+ d, "symmetric", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
271
+ ),
272
+ "constraint.fix": lambda d, a: ui_actions.sketch_constrain(
273
+ d, "fix", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 1)))]
274
+ ),
275
+ "sketch.exit": lambda d, a: ui_actions.sketch_exit(d),
276
+ "feature.extrude": lambda d, a: ui_actions.feature_extrude(
277
+ d, a.get("depth_mm") if "depth_mm" in a else a.get("depth")
278
+ ),
279
+ "feature.fillet": lambda d, a: ui_actions.feature_fillet(
280
+ d, a.get("radius_mm") if "radius_mm" in a else a.get("radius")
281
+ ),
282
+ "feature.chamfer": lambda d, a: ui_actions.feature_chamfer(
283
+ d, a.get("distance_mm") if "distance_mm" in a else a.get("distance")
284
+ ),
285
+ "select.face": lambda d, a: ui_actions.select_face(d, *_xy(a, "x", "y")),
286
+ "select.edge": lambda d, a: ui_actions.select_edge(d, *_xy(a, "x", "y")),
287
+ "click": lambda d, a: ui_actions.select_face(d, *_xy(a, "x", "y")),
288
+ "ui.undo": lambda d, a: ui_actions.undo(d),
289
+ "ui.redo": lambda d, a: ui_actions.redo(d),
290
+ "wait": lambda d, a: ui_actions.wait(d, float(a.get("seconds", 1.0))),
291
+ "sleep": lambda d, a: ui_actions.wait(d, float(a.get("seconds", 1.0))),
292
+ "ui.wait": lambda d, a: ui_actions.wait(d, float(a.get("seconds", 1.0))),
293
+ "screenshot": lambda d, a: ui_actions.screenshot_only(d, a.get("name", "agent.png")),
294
+ "doc.open": lambda d, a: ui_actions.doc_open(d, a.get("url", "")),
295
+ "doc.new": lambda d, a: ui_actions.doc_new(d),
296
+ }
297
+
298
+
299
+ async def dispatch(d: OnshapeDriver, tool: str | None, args: dict[str, Any]) -> None:
300
+ """Route a tool call. Unknown tools raise a clear error so the agent
301
+ loop records it as a failure and the LLM can recover on the next
302
+ step instead of crashing the whole run.
303
+ """
304
+ if not tool:
305
+ return
306
+ fn = TOOL_DISPATCH.get(tool)
307
+ if fn is None:
308
+ raise KeyError(f"unknown tool: {tool!r}. Available: {sorted(TOOL_DISPATCH.keys())}")
309
+ return await fn(d, args or {})
310
+
311
+
312
+ def build_agent_system_prompt() -> str:
313
+ """The system prompt for the closed-loop agent. This is where I
314
+ teach the model the workflow that actually works.
315
+ """
316
+ from . import tools as datasheet
317
+
318
+ return (
319
+ "You are an agent that drives the Onshape web CAD application visually, "
320
+ "the way a human would. You see screenshots and call tools.\n\n"
321
+ "On each turn:\n"
322
+ " 1. Read the user's goal.\n"
323
+ " 2. Look at the current screenshot.\n"
324
+ " 3. Decide the single next tool to call. Pick ONE tool. Provide its "
325
+ "arguments as a JSON object matching its signature.\n"
326
+ ' 4. If you have completed the goal, return {"done": true, "summary": "..."}.\n\n'
327
+ "Coordinate space is viewport pixels (0,0 = top-left). Use viewport_size "
328
+ "to get the bounds. The viewport center is typically the world origin in "
329
+ "the default top view.\n\n"
330
+ "## The constrained-sketch workflow (this is how you size and position things)\n\n"
331
+ "Never try to pick exact-mm pixel coordinates in the viewport. Instead:\n\n"
332
+ " 0. Make sure you're in a Part Studio. If you see a documents list, "
333
+ "call `doc.new` or `doc.open` first.\n"
334
+ ' 1. `sketch.start`: Pick ANY plane — `plane="Top"`, `plane="Front"`, or `plane="Right"` '
335
+ "(or pass a face coordinate). The view will automatically orient normal to that plane with the origin centered.\n"
336
+ " 2. Positioning and Quadrants:\n"
337
+ " - Centered at Origin: pass `centered=true` to `sketch.rectangle` or `sketch.circle` (or `corner1=[0,0]` / `center=[0,0]`).\n"
338
+ ' - In a specific Quadrant (anchored at Origin): pass `quadrant="I"` (top-right), `quadrant="II"` (top-left), '
339
+ '`quadrant="III"` (bottom-left), or `quadrant="IV"` (bottom-right).\n'
340
+ " - Arbitrary corners: pass `corner1=[x1, y1]`, `corner2=[x2, y2]` in pixels or relative CAD units.\n"
341
+ " 3. Sizing:\n"
342
+ ' - `sketch.rectangle` accepts `width` and `height` (e.g. width="12 cm", height="8 cm" or width_mm=50).\n'
343
+ " - It automatically applies constraints and drives the Onshape dimension solver for both width and height!\n"
344
+ " - After calling `sketch.rectangle` with width and height, both dimensions are ALREADY applied, so call `sketch.exit` next!\n"
345
+ ' - Alternatively, call `sketch.dimension` with the entity coordinate and `value` (e.g. "5 cm").\n'
346
+ " 4. `sketch.exit`: Commits and closes the sketch dialog.\n"
347
+ " 5. `feature.extrude`: Extrudes the sketch with `depth` in mm or cm.\n\n"
348
+ "For example: to draw a 12cm by 8cm box on the Front plane, call:\n"
349
+ ' step 0: sketch.start({"plane": "Front"})\n'
350
+ ' step 1: sketch.rectangle({"centered": true, "width": "12 cm", "height": "8 cm"})\n'
351
+ ' step 2: sketch.exit({})\n'
352
+ ' step 3: {"done": true, "summary": "12cm by 8cm box drawn and dimensioned on Front plane"}\n\n'
353
+ "For a '5x5 square extruded 5mm into a cube' that's just sketch.start, sketch.rectangle with width='5 mm', "
354
+ "sketch.exit, and feature.extrude with depth='5 mm'!\n\n"
355
+ "## Be conservative\n\n"
356
+ "When in doubt, take a screenshot and re-look. The UI is fragile: wrong "
357
+ "clicks can deselect, dismiss dialogs, or trigger unrelated tools. If a "
358
+ "tool fails twice in a row, undo and try a different approach.\n\n"
359
+ "## Available tools\n\n" + datasheet.as_prompt_block()
360
+ )
src/onshape_mcp/driver.py ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Playwright driver for Onshape. One headless Chromium, persistent
2
+ profile for cache, plain-JSON cookie file for the session.
3
+
4
+ Why JSON cookies instead of relying on Chrome's encrypted DB:
5
+ on Linux, Chrome encrypts cookies in its DB with a key from the system
6
+ keyring. The keyring is only available to graphical sessions, so
7
+ headless Playwright can't decrypt them; the cookies are present but
8
+ invisible. Storing them as plain JSON (and re-injecting via
9
+ `context.add_cookies()`) sidesteps the whole keyring issue.
10
+
11
+ Profile dir is gitignored (see SECURITY.md).
12
+
13
+ This module holds the lowest-level UI primitives. Higher-level Onshape
14
+ ops (sketch, extrude, fillet) live in ui_actions.py and compose these.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from playwright.async_api import BrowserContext, Locator, Page, async_playwright
25
+
26
+ from .config import settings
27
+
28
+ ONSHAPE_URL = "https://cad.onshape.com"
29
+
30
+
31
+ class OnshapeDriver:
32
+ def __init__(
33
+ self,
34
+ profile_dir: Path | None = None,
35
+ cookie_file: Path | None = None,
36
+ ) -> None:
37
+ self.profile_dir = (profile_dir or settings.onshape_browser_profile).resolve()
38
+ self.cookie_file = (cookie_file or settings.onshape_cookie_file).resolve()
39
+ self.channel = settings.browser_channel
40
+ self._pw: Any = None
41
+ self._ctx: BrowserContext | None = None
42
+ self._page: Page | None = None
43
+
44
+ async def start(self, headless: bool = True) -> Page:
45
+ self._pw = await async_playwright().start()
46
+ self._clear_stale_locks()
47
+ # We use the profile dir mainly for the SingletonLock dance and
48
+ # any future extensions we might want to install. Cache is not
49
+ # worth the stale-state headaches, so we use a non-persistent
50
+ # context when possible. Cookies live in self.cookie_file (plain
51
+ # JSON) and get injected after launch — no need for the profile
52
+ # to carry the session.
53
+ #
54
+ # Channel strategy: try real Chrome first (no automation markers,
55
+ # dodges Google's "this browser may not be secure" block). Fall
56
+ # back to bundled Chromium. Set ONSHAPE_BROWSER_CHANNEL=chromium
57
+ # to skip the Chrome attempt (e.g. on a Pi with no Chrome).
58
+ #
59
+ # WebGL args: Onshape renders the 3D viewport with WebGL. In
60
+ # headless mode we need to ask explicitly for software WebGL or
61
+ # the canvas stays blank. `swiftshader` is Google's software
62
+ # WebGL implementation and works on any machine.
63
+ common_args = [
64
+ "--use-gl=swiftshader",
65
+ "--enable-webgl",
66
+ "--ignore-gpu-blocklist",
67
+ "--disable-blink-features=AutomationControlled",
68
+ ]
69
+ if self.channel in ("auto", "chrome"):
70
+ try:
71
+ self._ctx = await self._pw.chromium.launch_persistent_context(
72
+ user_data_dir=str(self.profile_dir),
73
+ headless=headless,
74
+ viewport={"width": 1440, "height": 900},
75
+ args=common_args,
76
+ channel="chrome",
77
+ )
78
+ self._channel_used = "chrome"
79
+ except Exception as e:
80
+ if self.channel == "chrome":
81
+ raise
82
+ print(f"[driver] real Chrome unavailable ({e}); using bundled Chromium")
83
+ self._ctx = await self._pw.chromium.launch_persistent_context(
84
+ user_data_dir=str(self.profile_dir),
85
+ headless=headless,
86
+ viewport={"width": 1440, "height": 900},
87
+ args=common_args,
88
+ )
89
+ self._channel_used = "chromium"
90
+ else:
91
+ self._ctx = await self._pw.chromium.launch_persistent_context(
92
+ user_data_dir=str(self.profile_dir),
93
+ headless=headless,
94
+ viewport={"width": 1440, "height": 900},
95
+ args=common_args,
96
+ )
97
+ self._channel_used = "chromium"
98
+ await self._load_cookies()
99
+ self._page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
100
+ return self._page
101
+
102
+ def _clear_stale_locks(self) -> None:
103
+ """Remove leftover SingletonLock/SingletonCookie files from prior
104
+ Chrome runs. Without this, a crashed previous run blocks all
105
+ subsequent launches of the same profile.
106
+ """
107
+ for fname in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
108
+ p = self.profile_dir / fname
109
+ if p.exists():
110
+ try:
111
+ p.unlink()
112
+ except OSError:
113
+ pass
114
+
115
+ async def _load_cookies(self) -> None:
116
+ """Re-inject saved cookies into the context before any navigation.
117
+ Without this, even a logged-in session looks anonymous because
118
+ Chrome's encrypted DB can't be read headless.
119
+ """
120
+ raw = []
121
+ # 1. Environment variable support (useful for remote/cloud hosting)
122
+ env_cookies = os.getenv("ONSHAPE_COOKIES_JSON")
123
+ if env_cookies:
124
+ try:
125
+ raw = json.loads(env_cookies)
126
+ except Exception as e:
127
+ print(f"[driver] failed parsing ONSHAPE_COOKIES_JSON: {e}")
128
+
129
+ # 2. Local cookie file
130
+ if not raw and self.cookie_file.exists():
131
+ try:
132
+ raw = json.loads(self.cookie_file.read_text(encoding="utf-8"))
133
+ except (json.JSONDecodeError, OSError) as e:
134
+ print(f"[driver] cookie file unreadable ({e}); ignoring")
135
+
136
+ # 3. Auto-extract from available installed browsers
137
+ if not raw:
138
+ try:
139
+ import browser_cookie3
140
+
141
+ browser_loaders = [
142
+ ("Chrome", getattr(browser_cookie3, "chrome", None)),
143
+ ("Brave", getattr(browser_cookie3, "brave", None)),
144
+ ("Edge", getattr(browser_cookie3, "edge", None)),
145
+ ("Chromium", getattr(browser_cookie3, "chromium", None)),
146
+ ("Firefox", getattr(browser_cookie3, "firefox", None)),
147
+ ("Arc", getattr(browser_cookie3, "arc", None)),
148
+ ("Opera", getattr(browser_cookie3, "opera", None)),
149
+ ("Vivaldi", getattr(browser_cookie3, "vivaldi", None)),
150
+ ]
151
+ for browser_name, loader in browser_loaders:
152
+ if loader is None:
153
+ continue
154
+ try:
155
+ cj = loader(domain_name="onshape.com")
156
+ for c in cj:
157
+ cookie = {
158
+ "name": c.name,
159
+ "value": c.value,
160
+ "domain": c.domain,
161
+ "path": c.path,
162
+ "secure": bool(c.secure),
163
+ "httpOnly": bool(
164
+ c.has_nonstandard_attr("HttpOnly")
165
+ or c.has_nonstandard_attr("httponly")
166
+ ),
167
+ }
168
+ if c.expires:
169
+ cookie["expires"] = float(c.expires)
170
+ raw.append(cookie)
171
+ if raw:
172
+ self.cookie_file.parent.mkdir(parents=True, exist_ok=True)
173
+ self.cookie_file.write_text(json.dumps(raw, indent=2), encoding="utf-8")
174
+ print(
175
+ f"[driver] auto-synced {len(raw)} Onshape cookies from {browser_name} to {self.cookie_file.name}"
176
+ )
177
+ break
178
+ except Exception:
179
+ continue
180
+ except Exception as e:
181
+ print(f"[driver] could not extract Onshape cookies from local browsers: {e}")
182
+ if not raw:
183
+ return
184
+ # Playwright's add_cookies wants the same shape it returns.
185
+ await self._ctx.add_cookies(raw)
186
+ print(f"[driver] loaded {len(raw)} cookies from {self.cookie_file.name}")
187
+
188
+ async def save_cookies(self) -> int:
189
+ """Dump current context cookies to self.cookie_file. Returns count."""
190
+ cookies = await self._ctx.cookies()
191
+ self.cookie_file.parent.mkdir(parents=True, exist_ok=True)
192
+ self.cookie_file.write_text(json.dumps(cookies, indent=2), encoding="utf-8")
193
+ return len(cookies)
194
+
195
+ @property
196
+ def page(self) -> Page:
197
+ if self._page is None:
198
+ raise RuntimeError("Driver not started; call .start() first")
199
+ return self._page
200
+
201
+ @property
202
+ def channel_used(self) -> str:
203
+ return getattr(self, "_channel_used", "unknown")
204
+
205
+ async def open(self, url: str | None = None) -> None:
206
+ if not url:
207
+ if settings.onshape_default_doc:
208
+ doc = settings.onshape_default_doc
209
+ url = (
210
+ doc if doc.startswith("http") else f"https://cad.onshape.com/{doc.lstrip('/')}"
211
+ )
212
+ else:
213
+ url = ONSHAPE_URL
214
+ # Onshape is a SPA that keeps WebSocket connections open, so
215
+ # `networkidle` never fires (it would time out at 60s). We use
216
+ # `load` for the navigation then `wait_for_app()` to confirm the
217
+ # toolbar is actually on screen.
218
+ await self.page.goto(url, wait_until="load", timeout=60_000)
219
+ await self.wait_for_app()
220
+ if "signin" in self.page.url:
221
+ raise RuntimeError(
222
+ "Onshape redirected to signin page. Cookies may be invalid or expired. "
223
+ "Please log in to cad.onshape.com in Google Chrome."
224
+ )
225
+
226
+ # Screenshots
227
+
228
+ async def wait_for_app(self, timeout: float = 30.0) -> bool:
229
+ """Wait until the Onshape app shell has rendered something we can
230
+ see. The marker is broad: any toolbar button, the documents list,
231
+ a sketch button, whatever the current page has. Returns True if
232
+ something rendered, False if the page is still blank after the
233
+ timeout. Either way we sleep an extra beat for the WebGL canvas.
234
+ """
235
+ try:
236
+ await self.page.wait_for_function(
237
+ """() => {
238
+ // any rendered buttons with visible text or aria-label
239
+ const btns = document.querySelectorAll('button, [role="button"], a, .document-card, .document-list-item');
240
+ let n = 0;
241
+ for (const b of btns) {
242
+ const t = (b.textContent || b.getAttribute('aria-label') || '').trim();
243
+ if (t.length > 0) n++;
244
+ if (n >= 3) return true;
245
+ }
246
+ // any substantial content (h1-h3, paragraphs, etc.)
247
+ const text = document.body.innerText || '';
248
+ return text.length > 100;
249
+ }""",
250
+ timeout=timeout * 1000,
251
+ )
252
+ # Wait for graphics / loading spinner to clear
253
+ try:
254
+ await self.page.locator(".loading-progress-message").wait_for(
255
+ state="hidden", timeout=30_000
256
+ )
257
+ except Exception:
258
+ pass
259
+ await asyncio.sleep(1.0)
260
+ return True
261
+ except Exception:
262
+ return False
263
+
264
+ async def screenshot(self, name: str = "shot.png") -> Path:
265
+ out = settings.journal_dir / name
266
+ out.parent.mkdir(parents=True, exist_ok=True)
267
+ await self.page.screenshot(path=str(out), full_page=False)
268
+ return out
269
+
270
+ async def screenshot_clip(self, name: str, rect: dict[str, float]) -> Path:
271
+ out = settings.journal_dir / name
272
+ out.parent.mkdir(parents=True, exist_ok=True)
273
+ await self.page.screenshot(path=str(out), clip=rect)
274
+ return out
275
+
276
+ async def viewport_box(self) -> dict[str, float]:
277
+ return await self.page.evaluate("() => ({w: window.innerWidth, h: window.innerHeight})")
278
+
279
+ # Clicks and drags
280
+
281
+ async def click(
282
+ self,
283
+ x: float,
284
+ y: float,
285
+ button: str = "left",
286
+ click_count: int = 1,
287
+ ) -> None:
288
+ await self.page.mouse.click(x, y, button=button, click_count=click_count)
289
+
290
+ async def double_click(self, x: float, y: float) -> None:
291
+ await self.click(x, y, click_count=2)
292
+
293
+ async def drag(
294
+ self,
295
+ start: tuple[float, float],
296
+ end: tuple[float, float],
297
+ steps: int = 10,
298
+ button: str = "left",
299
+ ) -> None:
300
+ sx, sy = start
301
+ ex, ey = end
302
+ await self.page.mouse.move(sx, sy)
303
+ await self.page.mouse.down(button=button)
304
+ await self.page.mouse.move(ex, ey, steps=steps)
305
+ await self.page.mouse.up(button=button)
306
+
307
+ async def hover(self, x: float, y: float) -> None:
308
+ await self.page.mouse.move(x, y)
309
+
310
+ # Keyboard input
311
+
312
+ async def type_text(self, text: str, delay_ms: int = 10) -> None:
313
+ await self.page.keyboard.type(text, delay=delay_ms)
314
+
315
+ async def press_key(self, key: str) -> None:
316
+ """Press a single key. e.g. 'Enter', 'Escape', 'Tab'."""
317
+ await self.page.keyboard.press(key)
318
+
319
+ async def press_chord(self, *keys: str) -> None:
320
+ """Press a key combination, e.g. press_chord('Control', 'z')."""
321
+ combo = "+".join(keys)
322
+ await self.page.keyboard.press(combo)
323
+
324
+ # Element finding
325
+ # Most of the Onshape UI is canvas + custom DOM, so text/aria selectors
326
+ # are the most reliable. We try a few strategies and return the first hit.
327
+
328
+ async def find_by_text(
329
+ self,
330
+ text: str,
331
+ *,
332
+ partial: bool = True,
333
+ role: str | None = None,
334
+ timeout_ms: int = 3000,
335
+ ) -> Locator | None:
336
+ """Return a locator matching the given visible text (or aria-label).
337
+
338
+ Returns None if not found within timeout. Caller decides whether to
339
+ click, get the box, etc.
340
+ """
341
+ strategies: list[Locator] = []
342
+ if role:
343
+ strategies.append(self.page.get_by_role(role, name=text, exact=not partial))
344
+ # aria-label / title
345
+ strategies.append(
346
+ self.page.locator(f"[aria-label*='{text}']" if partial else f"[aria-label='{text}']")
347
+ )
348
+ # visible text content
349
+ strategies.append(self.page.get_by_text(text, exact=not partial))
350
+ for loc in strategies:
351
+ try:
352
+ if await loc.first.is_visible(timeout=timeout_ms / 1000):
353
+ return loc.first
354
+ except Exception:
355
+ continue
356
+ return None
357
+
358
+ async def click_text(
359
+ self,
360
+ text: str,
361
+ *,
362
+ partial: bool = True,
363
+ timeout_ms: int = 3000,
364
+ ) -> bool:
365
+ loc = await self.find_by_text(text, partial=partial, timeout_ms=timeout_ms)
366
+ if loc is None:
367
+ return False
368
+ await loc.click()
369
+ return True
370
+
371
+ async def wait_for_text(self, text: str, timeout: float = 5.0) -> bool:
372
+ try:
373
+ await self.page.get_by_text(text).first.wait_for(timeout=timeout * 1000)
374
+ return True
375
+ except Exception:
376
+ return False
377
+
378
+ async def wait_for_no_text(self, text: str, timeout: float = 5.0) -> bool:
379
+ """True if the text disappears within timeout. False if it's still there."""
380
+ try:
381
+ await self.page.get_by_text(text).first.wait_for(state="hidden", timeout=timeout * 1000)
382
+ return True
383
+ except Exception:
384
+ return False
385
+
386
+ # Lifecycle
387
+
388
+ async def close(self) -> None:
389
+ if self._ctx is not None:
390
+ await self._ctx.close()
391
+ if self._pw is not None:
392
+ await self._pw.stop()
393
+ self._ctx = None
394
+ self._page = None
395
+ self._pw = None
396
+
397
+
398
+ async def login_interactive() -> None:
399
+ """Open a headed browser, let the user log in, then dump the session
400
+ cookies to self.cookie_file. Subsequent headless runs re-inject them.
401
+ """
402
+ d = OnshapeDriver()
403
+ await d.start(headless=False)
404
+ await d.open(ONSHAPE_URL)
405
+ print("Log into Onshape in the opened browser, then press Enter here.")
406
+ input("> ")
407
+ n = await d.save_cookies()
408
+ print(f"Saved {n} cookies to {d.cookie_file}")
409
+ await d.close()
410
+
411
+
412
+ def main() -> None:
413
+ """`python -m onshape_mcp.driver login`"""
414
+ import sys
415
+
416
+ if len(sys.argv) >= 2 and sys.argv[1] == "login":
417
+ asyncio.run(login_interactive())
418
+ else:
419
+ print("usage: python -m onshape_mcp.driver login")
420
+
421
+
422
+ if __name__ == "__main__":
423
+ main()
src/onshape_mcp/fast_exec.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fast direct executor. Runs a parsed Plan against the Onshape driver
2
+ without any LLM calls. This is the fast path for deterministic tasks.
3
+
4
+ Typical execution: ~5-8s for sketch + dimension + commit, compared to
5
+ ~60s with the vision loop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .dispatch import dispatch
16
+ from .driver import OnshapeDriver
17
+ from .intent import Plan
18
+
19
+
20
+ @dataclass
21
+ class FastResult:
22
+ plan: Plan
23
+ ok: bool
24
+ total_elapsed_s: float = 0.0
25
+ final_screenshot: Path | None = None
26
+ step_times: list[tuple[str, float]] = None # type: ignore[assignment]
27
+ error: str | None = None
28
+
29
+ def __post_init__(self) -> None:
30
+ if self.step_times is None:
31
+ self.step_times = []
32
+
33
+ def summary(self) -> str:
34
+ n = len(self.step_times)
35
+ if self.ok:
36
+ return f"completed {n} actions ({self.total_elapsed_s:.1f}s): {self.plan.summary}"
37
+ return f"failed at action {n} ({self.total_elapsed_s:.1f}s): {self.error}"
38
+
39
+
40
+ async def execute(d: OnshapeDriver, plan: Plan) -> FastResult:
41
+ """Execute a Plan's actions sequentially against the driver.
42
+ No LLM calls, no screenshots between steps (except final).
43
+ """
44
+ t0 = time.monotonic()
45
+ result = FastResult(plan=plan, ok=True)
46
+
47
+ for i, action in enumerate(plan.actions):
48
+ step_t0 = time.monotonic()
49
+ tool = action.tool
50
+ args = action.args
51
+ try:
52
+ await dispatch(d, tool, args)
53
+ elapsed = time.monotonic() - step_t0
54
+ result.step_times.append((f"{tool}({_compact(args)})", elapsed))
55
+ print(f" [{i}] {tool}({_compact(args)}) ({elapsed:.1f}s)", flush=True)
56
+ except Exception as e:
57
+ elapsed = time.monotonic() - step_t0
58
+ result.step_times.append((f"{tool} ERROR", elapsed))
59
+ result.ok = False
60
+ result.error = f"{tool}: {e}"
61
+ print(f" [{i}] {tool} ERROR: {e} ({elapsed:.1f}s)", flush=True)
62
+ break
63
+
64
+ try:
65
+ result.final_screenshot = await d.screenshot("task_final.png")
66
+ except Exception as e:
67
+ print(f" [warn] could not capture final screenshot: {e}", flush=True)
68
+ result.total_elapsed_s = time.monotonic() - t0
69
+ return result
70
+
71
+
72
+ def _compact(d: dict[str, Any]) -> str:
73
+ import json
74
+ try:
75
+ return json.dumps(d, ensure_ascii=False)
76
+ except Exception:
77
+ return str(d)
src/onshape_mcp/intent.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parse natural-language CAD prompts into structured action plans.
2
+
3
+ This module eliminates the LLM from deterministic tasks. Instead of
4
+ sending 4 screenshots to Gemini and waiting 60s for it to say
5
+ "sketch.start, sketch.rectangle, sketch.exit, done", we parse the
6
+ prompt directly and produce the same action sequence in <1ms.
7
+
8
+ If the parser can't handle a prompt, it returns None and the caller
9
+ falls back to the vision loop.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+
19
+ @dataclass
20
+ class Action:
21
+ tool: str
22
+ args: dict[str, Any] = field(default_factory=dict)
23
+
24
+
25
+ @dataclass
26
+ class Plan:
27
+ actions: list[Action]
28
+ summary: str
29
+
30
+
31
+ # Regex patterns for dimension extraction.
32
+ # Handles "12cm by 8cm", "12 cm x 8 cm", "50mm × 30mm" etc.
33
+ # Groups: (1)=width, (2)=width_unit, (3)=height, (4)=height_unit
34
+ _DIM_RE = re.compile(
35
+ r"(\d+(?:\.\d+)?)\s*(mm|cm|in|inch)?\s*(?:x|by|×)\s*(\d+(?:\.\d+)?)\s*(mm|cm|in|inch)?",
36
+ re.IGNORECASE,
37
+ )
38
+ _SINGLE_DIM_RE = re.compile(
39
+ r"(\d+(?:\.\d+)?)\s*(mm|cm|in|inch)",
40
+ re.IGNORECASE,
41
+ )
42
+ _PLANE_RE = re.compile(r"\b(top|front|right)\b", re.IGNORECASE)
43
+ _QUADRANT_RE = re.compile(
44
+ r"(?:quadrant|q)\s*([1-4]|I{1,3}V?|IV)", re.IGNORECASE
45
+ )
46
+ _EXTRUDE_RE = re.compile(
47
+ r"extrude\s+(?:it\s+)?(\d+(?:\.\d+)?)\s*(mm|cm|in)?", re.IGNORECASE
48
+ )
49
+ _DIAMETER_RE = re.compile(
50
+ r"(\d+(?:\.\d+)?)\s*(mm|cm|in)?\s*diameter", re.IGNORECASE
51
+ )
52
+ _RADIUS_RE = re.compile(
53
+ r"(\d+(?:\.\d+)?)\s*(mm|cm|in)?\s*radius", re.IGNORECASE
54
+ )
55
+ _POLYGON_SIDES_RE = re.compile(
56
+ r"(\d+)[ -]sided\b", re.IGNORECASE
57
+ )
58
+ _CENTERED_RE = re.compile(
59
+ r"\b(center(?:ed)?|at\s+(?:the\s+)?origin)\b", re.IGNORECASE
60
+ )
61
+
62
+
63
+ def _format_dim(value: float, unit: str | None) -> str:
64
+ """Format a dimension value with unit for Onshape's solver."""
65
+ u = (unit or "cm").lower()
66
+ if u in ("in", "inch"):
67
+ u = "in"
68
+ v = f"{value:g}"
69
+ return f"{v} {u}"
70
+
71
+
72
+ def _extract_plane(text: str) -> str:
73
+ m = _PLANE_RE.search(text)
74
+ return m.group(1).capitalize() if m else "Top"
75
+
76
+
77
+ def _extract_quadrant(text: str) -> str | None:
78
+ m = _QUADRANT_RE.search(text)
79
+ if not m:
80
+ return None
81
+ q = m.group(1).upper()
82
+ roman = {"I": "1", "II": "2", "III": "3", "IV": "4"}
83
+ return roman.get(q, q)
84
+
85
+
86
+ def _is_centered(text: str) -> bool:
87
+ return bool(_CENTERED_RE.search(text))
88
+
89
+
90
+ def parse(text: str) -> Plan | None:
91
+ """Try to parse a natural-language CAD prompt into a deterministic
92
+ action plan. Returns None if the prompt is too ambiguous or complex
93
+ for direct execution.
94
+ """
95
+ t = text.strip()
96
+ lower = t.lower()
97
+
98
+ if not t:
99
+ return None
100
+
101
+ # Detect shape type
102
+ is_rect = any(w in lower for w in ("box", "rectangle", "rect", "square"))
103
+ is_circle = any(w in lower for w in ("circle", "cylinder", "disc", "disk"))
104
+ is_polygon = any(w in lower for w in ("polygon", "hexagon", "pentagon", "octagon"))
105
+ is_arc = "arc" in lower and not is_circle
106
+ is_point = "point" in lower and not any((is_rect, is_circle, is_polygon, is_arc))
107
+ has_extrude = "extrude" in lower
108
+
109
+ if not (is_rect or is_circle or is_polygon or is_arc or is_point):
110
+ return None
111
+
112
+ plane = _extract_plane(t)
113
+ quadrant = _extract_quadrant(t)
114
+ centered = _is_centered(t)
115
+
116
+ actions: list[Action] = []
117
+ summary_parts: list[str] = []
118
+
119
+ # Step 1: Start sketch
120
+ actions.append(Action("sketch.start", {"plane": plane}))
121
+
122
+ if is_rect:
123
+ dim_m = _DIM_RE.search(t)
124
+ if dim_m:
125
+ w_val = float(dim_m.group(1))
126
+ h_val = float(dim_m.group(3))
127
+ unit = dim_m.group(2) or dim_m.group(4)
128
+ width = _format_dim(w_val, unit)
129
+ height = _format_dim(h_val, unit)
130
+ elif "square" in lower:
131
+ single = _SINGLE_DIM_RE.search(t)
132
+ if single:
133
+ val = float(single.group(1))
134
+ unit = single.group(2)
135
+ width = height = _format_dim(val, unit)
136
+ else:
137
+ return None
138
+ else:
139
+ return None
140
+
141
+ rect_args: dict[str, Any] = {"width": width, "height": height}
142
+ if quadrant:
143
+ rect_args["quadrant"] = quadrant
144
+ elif centered or not quadrant:
145
+ rect_args["centered"] = True
146
+
147
+ actions.append(Action("sketch.rectangle", rect_args))
148
+ label = "square" if width == height else "rectangle"
149
+ summary_parts.append(f"{width} × {height} {label}")
150
+
151
+ elif is_circle:
152
+ diam_m = _DIAMETER_RE.search(t)
153
+ rad_m = _RADIUS_RE.search(t)
154
+ if diam_m:
155
+ val = float(diam_m.group(1))
156
+ unit = diam_m.group(2)
157
+ radius_mm = val / 2.0
158
+ if unit and unit.lower() == "cm":
159
+ radius_mm = val * 10.0 / 2.0
160
+ circle_args: dict[str, Any] = {
161
+ "centered": True,
162
+ "radius": radius_mm,
163
+ }
164
+ actions.append(Action("sketch.circle", circle_args))
165
+ summary_parts.append(f"{_format_dim(val, unit)} diameter circle")
166
+ elif rad_m:
167
+ val = float(rad_m.group(1))
168
+ unit = rad_m.group(2)
169
+ circle_args = {"centered": True, "radius": val}
170
+ actions.append(Action("sketch.circle", circle_args))
171
+ summary_parts.append(f"{_format_dim(val, unit)} radius circle")
172
+ else:
173
+ single = _SINGLE_DIM_RE.search(t)
174
+ if single:
175
+ val = float(single.group(1))
176
+ unit = single.group(2)
177
+ circle_args = {"centered": True, "radius": val / 2.0}
178
+ actions.append(Action("sketch.circle", circle_args))
179
+ summary_parts.append(f"{_format_dim(val, unit)} circle")
180
+ else:
181
+ return None
182
+
183
+ elif is_polygon:
184
+ # Determine number of sides
185
+ sides = 6
186
+ side_m = _POLYGON_SIDES_RE.search(t)
187
+ if side_m:
188
+ sides = int(side_m.group(1))
189
+ elif "hexagon" in lower:
190
+ sides = 6
191
+ elif "pentagon" in lower:
192
+ sides = 5
193
+ elif "octagon" in lower:
194
+ sides = 8
195
+
196
+ # Determine radius
197
+ rad_m = _RADIUS_RE.search(t) or _SINGLE_DIM_RE.search(t)
198
+ radius_val = float(rad_m.group(1)) if rad_m else 50.0
199
+ unit = rad_m.group(2) if rad_m else "mm"
200
+ poly_args: dict[str, Any] = {
201
+ "center_x": 0.0,
202
+ "center_y": 0.0,
203
+ "radius": radius_val,
204
+ "sides": sides,
205
+ }
206
+ actions.append(Action("sketch.polygon", poly_args))
207
+ summary_parts.append(f"{sides}-sided polygon (r={_format_dim(radius_val, unit)})")
208
+
209
+ elif is_arc:
210
+ arc_args: dict[str, Any] = {
211
+ "p1_x": -50.0,
212
+ "p1_y": 0.0,
213
+ "p2_x": 50.0,
214
+ "p2_y": 0.0,
215
+ }
216
+ actions.append(Action("sketch.arc", arc_args))
217
+ summary_parts.append("3-point arc")
218
+
219
+ elif is_point:
220
+ actions.append(Action("sketch.point", {"x": 0.0, "y": 0.0}))
221
+ summary_parts.append("point at origin")
222
+
223
+ # Step: Exit sketch
224
+ actions.append(Action("sketch.exit", {}))
225
+
226
+ # Step: Extrude (optional)
227
+ ext_m = _EXTRUDE_RE.search(t)
228
+ if has_extrude and ext_m:
229
+ depth_val = float(ext_m.group(1))
230
+ depth_unit = (ext_m.group(2) or "mm").lower()
231
+ if depth_unit == "cm":
232
+ depth_mm = depth_val * 10.0
233
+ elif depth_unit in ("in", "inch"):
234
+ depth_mm = depth_val * 25.4
235
+ else:
236
+ depth_mm = depth_val
237
+ actions.append(Action("feature.extrude", {"depth_mm": depth_mm}))
238
+ summary_parts.append(f"extruded {_format_dim(depth_val, depth_unit)}")
239
+
240
+ position = f"Quadrant {quadrant}" if quadrant else "centered at origin"
241
+ summary = f"Drew {', '.join(summary_parts)} on {plane} plane, {position}"
242
+
243
+ return Plan(actions=actions, summary=summary)
src/onshape_mcp/journal.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Append-only action journal. Every UI op is recorded for undo/replay/debug.
2
+
3
+ This file lives in `./state/` and is gitignored. Don't change that.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ import uuid
11
+ from dataclasses import asdict, dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .config import settings
16
+
17
+
18
+ @dataclass
19
+ class JournalEntry:
20
+ ts: float
21
+ action_id: str
22
+ tool: str
23
+ args: dict[str, Any] = field(default_factory=dict)
24
+ result: str = "ok" # "ok" | "fail" | "skipped"
25
+ note: str = ""
26
+ screenshot: str | None = None # relative path under state/ if captured
27
+
28
+ @classmethod
29
+ def new(cls, tool: str, args: dict[str, Any]) -> JournalEntry:
30
+ return cls(
31
+ ts=time.time(),
32
+ action_id=uuid.uuid4().hex[:12],
33
+ tool=tool,
34
+ args=args,
35
+ )
36
+
37
+
38
+ class Journal:
39
+ def __init__(self, path: Path | None = None) -> None:
40
+ self.path = path or (settings.journal_dir / "session.journal.jsonl")
41
+ self.path.parent.mkdir(parents=True, exist_ok=True)
42
+
43
+ def append(self, entry: JournalEntry) -> None:
44
+ with self.path.open("a", encoding="utf-8") as f:
45
+ f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n")
46
+
47
+ def tail(self, n: int = 20) -> list[dict[str, Any]]:
48
+ if not self.path.exists():
49
+ return []
50
+ lines = self.path.read_text(encoding="utf-8").splitlines()[-n:]
51
+ return [json.loads(line) for line in lines if line.strip()]
52
+
53
+
54
+ journal = Journal()
src/onshape_mcp/loop.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Closed-loop agent runner. Screenshot -> Gemini -> dispatch -> repeat.
2
+
3
+ Used by both the MCP `act()` tool and the run_task CLI. The dispatch
4
+ table + system prompt live in `dispatch.py` so the two callers can't
5
+ drift apart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import hashlib
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .dispatch import build_agent_system_prompt, dispatch, parse_decision
19
+ from .driver import OnshapeDriver
20
+ from .vision import GeminiWeb
21
+
22
+ STUCK_THRESHOLD = 5 # unchanged screenshots in a row -> stop
23
+
24
+
25
+ @dataclass
26
+ class StepRecord:
27
+ step: int
28
+ decision: dict[str, Any]
29
+ ok: bool
30
+ error: str | None = None
31
+ elapsed_s: float = 0.0
32
+
33
+
34
+ @dataclass
35
+ class LoopResult:
36
+ goal: str
37
+ steps: list[StepRecord] = field(default_factory=list)
38
+ completed: bool = False
39
+ final_screenshot: Path | None = None
40
+ stop_reason: str = ""
41
+ total_elapsed_s: float = 0.0
42
+
43
+ def summary(self) -> str:
44
+ if self.completed:
45
+ last = self.steps[-1].decision if self.steps else {}
46
+ return f"completed in {len(self.steps)} steps ({self.total_elapsed_s:.1f}s): {last.get('summary', '')}"
47
+ return f"stopped ({self.stop_reason}) after {len(self.steps)} steps ({self.total_elapsed_s:.1f}s)"
48
+
49
+
50
+ class AgentLoop:
51
+ def __init__(
52
+ self,
53
+ driver: OnshapeDriver | None = None,
54
+ vision: GeminiWeb | None = None,
55
+ stuck_threshold: int = STUCK_THRESHOLD,
56
+ ) -> None:
57
+ self.driver = driver
58
+ self.vision = vision
59
+ self.stuck_threshold = stuck_threshold
60
+
61
+ async def _ensure(self) -> tuple[OnshapeDriver, GeminiWeb]:
62
+ if self.driver is None:
63
+ self.driver = OnshapeDriver()
64
+ await self.driver.start(headless=True)
65
+ await self.driver.open()
66
+ if self.vision is None:
67
+ self.vision = GeminiWeb()
68
+ # Warm the vision client. The first call has higher latency
69
+ # because of init + token exchange. Doing it before the loop
70
+ # keeps the per-step timing predictable.
71
+ await self.vision._ensure_client()
72
+ return self.driver, self.vision
73
+
74
+ async def close(self) -> None:
75
+ if self.vision is not None:
76
+ await self.vision.close()
77
+ self.vision = None
78
+ if self.driver is not None:
79
+ await self.driver.close()
80
+ self.driver = None
81
+
82
+ async def run(self, goal: str, max_steps: int = 25) -> LoopResult:
83
+ run_t0 = time.monotonic()
84
+ d, v = await self._ensure()
85
+ result = LoopResult(goal=goal)
86
+ last_hash: str | None = None
87
+ unchanged = 0
88
+
89
+ system_prompt = build_agent_system_prompt()
90
+ history: list[dict[str, Any]] = []
91
+
92
+ # Maintain a single chat session for this task run to prevent
93
+ # spawning multiple separate chats per step.
94
+ chat_session = await v.new_session()
95
+
96
+ last_vertex: tuple[float, float] | None = None
97
+
98
+ for step in range(max_steps):
99
+ t0 = time.monotonic()
100
+ shot = await d.screenshot(f"task_step_{step:02d}.png")
101
+ h = hashlib.sha256(shot.read_bytes()).hexdigest()
102
+ if h == last_hash:
103
+ unchanged += 1
104
+ if unchanged >= self.stuck_threshold:
105
+ result.stop_reason = f"stuck after {self.stuck_threshold} unchanged screenshots"
106
+ break
107
+ else:
108
+ unchanged = 0
109
+ last_hash = h
110
+
111
+ history_text = ""
112
+ for r in history[-3:]:
113
+ history_text += f"\nStep {r['step']}: {r['decision']}\n"
114
+ vertex_text = f"\nLast drawn vertex on canvas: {last_vertex}\n" if last_vertex else ""
115
+ prompt = (
116
+ f"{system_prompt}\n\n"
117
+ f"---\n"
118
+ f"User goal: {goal}\n\n"
119
+ f"Recent action history:{history_text}{vertex_text}\n"
120
+ f"Step {step + 1}/{max_steps}. Look at the latest screenshot. "
121
+ "Reply ONLY with a JSON object: "
122
+ '{"tool": "<tool_name>", "args": {...}} OR {"done": true, "summary": "..."}'
123
+ )
124
+ try:
125
+ answer = await v.ask_with_image(prompt, shot)
126
+ except Exception as e:
127
+ result.steps.append(
128
+ StepRecord(
129
+ step,
130
+ {"error": f"vision: {e}"},
131
+ ok=False,
132
+ error=str(e),
133
+ elapsed_s=time.monotonic() - t0,
134
+ )
135
+ )
136
+ result.stop_reason = f"vision error: {e}"
137
+ break
138
+
139
+ decision = parse_decision(answer)
140
+ history.append({"step": step, "decision": decision})
141
+ if decision.get("done"):
142
+ if str(decision.get("summary", "")).startswith("Could not parse decision"):
143
+ rec = StepRecord(
144
+ step,
145
+ decision,
146
+ ok=False,
147
+ error=decision.get("summary"),
148
+ elapsed_s=time.monotonic() - t0,
149
+ )
150
+ result.steps.append(rec)
151
+ continue
152
+ result.completed = True
153
+ result.steps.append(
154
+ StepRecord(step, decision, ok=True, elapsed_s=time.monotonic() - t0)
155
+ )
156
+ break
157
+
158
+ tool = decision.get("tool")
159
+ args = decision.get("args", {}) or {}
160
+ rec = StepRecord(
161
+ step, {"tool": tool, "args": args}, ok=True, elapsed_s=time.monotonic() - t0
162
+ )
163
+ try:
164
+ action_res = await dispatch(d, tool, args)
165
+ if (
166
+ hasattr(action_res, "meta")
167
+ and isinstance(action_res.meta, dict)
168
+ and "last_vertex" in action_res.meta
169
+ ):
170
+ last_vertex = tuple(action_res.meta["last_vertex"])
171
+ except Exception as e:
172
+ rec.ok = False
173
+ rec.error = f"{type(e).__name__}: {e}"
174
+ rec.elapsed_s = time.monotonic() - t0
175
+ result.steps.append(rec)
176
+ if rec.ok:
177
+ unchanged = 0
178
+ status_str = (
179
+ f"ERROR {rec.error}"
180
+ if rec.error
181
+ else f"{tool}({json.dumps(args, ensure_ascii=False)})"
182
+ )
183
+ print(f" step {step}: {status_str} ({rec.elapsed_s:.1f}s)", flush=True)
184
+ # brief beat so the UI has time to paint
185
+ await asyncio.sleep(0.4)
186
+
187
+ result.final_screenshot = await d.screenshot("task_final.png")
188
+ result.total_elapsed_s = time.monotonic() - run_t0
189
+ if not result.completed and not result.stop_reason:
190
+ result.stop_reason = f"max_steps ({max_steps}) reached"
191
+ return result
192
+
193
+
194
+ def build_loop() -> AgentLoop:
195
+ """Factory. Caller can override driver/vision if they already have one."""
196
+ return AgentLoop()
197
+
198
+
199
+ def run_sync(goal: str, max_steps: int = 25) -> LoopResult:
200
+ """Convenience wrapper for non-async callers."""
201
+ return asyncio.run(AgentLoop().run(goal, max_steps=max_steps))
src/onshape_mcp/server.py ADDED
@@ -0,0 +1,697 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP server. Exposes the complete Onshape tool vocabulary as MCP tools.
2
+ Designed for Claude Desktop, ChatGPT, and custom LLM clients.
3
+
4
+ All tool executions are wrapped with error-handling to prevent JSON-RPC
5
+ pipe crashes and provide clear, human-readable public messages.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import traceback
13
+ from typing import Any
14
+
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+ from . import tools as datasheet
18
+ from . import ui_actions
19
+ from .dispatch import TOOL_DISPATCH, build_agent_system_prompt, dispatch
20
+ from .driver import OnshapeDriver
21
+ from .fast_exec import execute as fast_execute
22
+ from .intent import parse as parse_intent
23
+ from .journal import JournalEntry, journal
24
+ from .loop import AgentLoop
25
+ from .vision import GeminiWeb
26
+
27
+ mcp = FastMCP("onshape-mcp")
28
+
29
+ _driver: OnshapeDriver | None = None
30
+ _vision: GeminiWeb | None = None
31
+ _loop: AgentLoop | None = None
32
+
33
+
34
+ def _format_error(tool_name: str, err: Exception) -> str:
35
+ """Return a clean JSON string with public error details."""
36
+ return json.dumps(
37
+ {
38
+ "ok": False,
39
+ "tool": tool_name,
40
+ "error": type(err).__name__,
41
+ "message": str(err),
42
+ "hint": "Check coordinates, tool preconditions, or document connection.",
43
+ },
44
+ indent=2,
45
+ )
46
+
47
+
48
+ async def _driver_lazy() -> OnshapeDriver:
49
+ global _driver
50
+ if _driver is None:
51
+ _driver = OnshapeDriver()
52
+ await _driver.start(headless=True)
53
+ await _driver.open()
54
+ return _driver
55
+
56
+
57
+ async def _vision_lazy() -> GeminiWeb:
58
+ global _vision
59
+ if _vision is None:
60
+ _vision = GeminiWeb()
61
+ return _vision
62
+
63
+
64
+ async def _loop_lazy() -> AgentLoop:
65
+ global _loop
66
+ if _loop is None:
67
+ _loop = AgentLoop()
68
+ return _loop
69
+
70
+
71
+ # Meta & Information Tools
72
+
73
+
74
+ @mcp.tool()
75
+ async def screenshot(name: str = "shot.png") -> str:
76
+ """Take a screenshot of the current Onshape viewport. Returns the absolute file path."""
77
+ try:
78
+ d = await _driver_lazy()
79
+ path = await d.screenshot(name=name)
80
+ e = JournalEntry.new("screenshot", {"name": name})
81
+ e.screenshot = str(path)
82
+ journal.append(e)
83
+ return json.dumps({"ok": True, "screenshot": str(path), "message": f"Saved screenshot to {path}"})
84
+ except Exception as e:
85
+ return _format_error("screenshot", e)
86
+
87
+
88
+ @mcp.tool()
89
+ async def describe_view(question: str = "What do you see in the Onshape viewport?") -> str:
90
+ """Screenshot + ask Gemini vision to describe what's in the viewport."""
91
+ try:
92
+ d = await _driver_lazy()
93
+ img = await d.screenshot("describe.png")
94
+ v = await _vision_lazy()
95
+ desc = await v.ask_with_image(question, img)
96
+ return json.dumps({"ok": True, "description": desc})
97
+ except Exception as e:
98
+ return _format_error("describe_view", e)
99
+
100
+
101
+ @mcp.tool()
102
+ async def tool_datasheet() -> str:
103
+ """Return the entire Onshape tool vocabulary, parameters, and preconditions as markdown."""
104
+ return datasheet.as_prompt_block()
105
+
106
+
107
+ @mcp.tool()
108
+ async def journal_tail(n: int = 20) -> str:
109
+ """Return the last N actions executed against Onshape."""
110
+ return json.dumps(journal.tail(n), indent=2, ensure_ascii=False)
111
+
112
+
113
+ @mcp.tool()
114
+ async def open_doc(url: str) -> str:
115
+ """Navigate to an Onshape document URL or path."""
116
+ try:
117
+ d = await _driver_lazy()
118
+ full = url if url.startswith("http") else f"https://cad.onshape.com/{url.lstrip('/')}"
119
+ await d.open(full)
120
+ journal.append(JournalEntry.new("doc.open", {"url": full}))
121
+ return json.dumps({"ok": True, "message": f"Opened document {full}"})
122
+ except Exception as e:
123
+ return _format_error("open_doc", e)
124
+
125
+
126
+ @mcp.tool()
127
+ async def viewport_size() -> str:
128
+ """Return the current viewport dimensions in pixels."""
129
+ try:
130
+ d = await _driver_lazy()
131
+ return json.dumps(await d.viewport_box())
132
+ except Exception as e:
133
+ return _format_error("viewport_size", e)
134
+
135
+
136
+ # View & Camera Tools
137
+
138
+
139
+ @mcp.tool()
140
+ async def onshape_view_fit() -> str:
141
+ """Zoom and pan to fit all visible geometry in the viewport ('f')."""
142
+ try:
143
+ d = await _driver_lazy()
144
+ return json.dumps((await ui_actions.view_fit(d)).to_dict())
145
+ except Exception as e:
146
+ return _format_error("view.fit", e)
147
+
148
+
149
+ @mcp.tool()
150
+ async def onshape_view_top() -> str:
151
+ """Orient camera to look directly at the Top view."""
152
+ try:
153
+ d = await _driver_lazy()
154
+ return json.dumps((await ui_actions.view_top(d)).to_dict())
155
+ except Exception as e:
156
+ return _format_error("view.top", e)
157
+
158
+
159
+ @mcp.tool()
160
+ async def onshape_view_front() -> str:
161
+ """Orient camera to look directly at the Front view."""
162
+ try:
163
+ d = await _driver_lazy()
164
+ return json.dumps((await ui_actions.view_front(d)).to_dict())
165
+ except Exception as e:
166
+ return _format_error("view.front", e)
167
+
168
+
169
+ @mcp.tool()
170
+ async def onshape_view_iso() -> str:
171
+ """Orient camera to Isometric view."""
172
+ try:
173
+ d = await _driver_lazy()
174
+ return json.dumps((await ui_actions.view_iso(d)).to_dict())
175
+ except Exception as e:
176
+ return _format_error("view.iso", e)
177
+
178
+
179
+ # Sketch Creation Tools
180
+
181
+
182
+ @mcp.tool()
183
+ async def onshape_sketch_start(
184
+ plane_x: float | None = None,
185
+ plane_y: float | None = None,
186
+ plane: str | None = None,
187
+ ) -> str:
188
+ """Open a new sketch. Pass plane name ('Top', 'Front', 'Right') or plane_x/plane_y coordinates.
189
+ Default plane is 'Top'. Automatically aligns camera normal to the sketch plane."""
190
+ try:
191
+ d = await _driver_lazy()
192
+ pt = (plane_x, plane_y) if plane_x is not None and plane_y is not None else None
193
+ return json.dumps((await ui_actions.sketch_start(d, pt, plane_name=plane)).to_dict())
194
+ except Exception as e:
195
+ return _format_error("sketch.start", e)
196
+
197
+
198
+ @mcp.tool()
199
+ async def onshape_sketch_exit(commit: bool = True) -> str:
200
+ """Exit the active sketch and accept (commit=True) or discard (commit=False) changes."""
201
+ try:
202
+ d = await _driver_lazy()
203
+ return json.dumps((await ui_actions.sketch_exit(d, commit=commit)).to_dict())
204
+ except Exception as e:
205
+ return _format_error("sketch.exit", e)
206
+
207
+
208
+ @mcp.tool()
209
+ async def onshape_sketch_rectangle(
210
+ corner1_x: float | None = None,
211
+ corner1_y: float | None = None,
212
+ corner2_x: float | None = None,
213
+ corner2_y: float | None = None,
214
+ width: float | str | None = None,
215
+ height: float | str | None = None,
216
+ quadrant: str | int | None = None,
217
+ centered: bool | None = None,
218
+ ) -> str:
219
+ """Draw a rectangle. Accepts dimensions (e.g. width='10 cm', height='5 cm'), quadrant ('1', '2', '3', '4'),
220
+ or centered=True. Dimensions are automatically driven into Onshape's solver."""
221
+ try:
222
+ d = await _driver_lazy()
223
+ c1 = (corner1_x, corner1_y) if corner1_x is not None and corner1_y is not None else None
224
+ c2 = (corner2_x, corner2_y) if corner2_x is not None and corner2_y is not None else None
225
+ return json.dumps(
226
+ (
227
+ await ui_actions.sketch_rectangle(
228
+ d, c1, c2, width=width, height=height, quadrant=quadrant, centered=centered
229
+ )
230
+ ).to_dict()
231
+ )
232
+ except Exception as e:
233
+ return _format_error("sketch.rectangle", e)
234
+
235
+
236
+ @mcp.tool()
237
+ async def onshape_sketch_circle(
238
+ center_x: float | None = None,
239
+ center_y: float | None = None,
240
+ radius_px: float = 50.0,
241
+ centered: bool | None = None,
242
+ ) -> str:
243
+ """Draw a circle. Pass center coordinates (center_x, center_y) or centered=True for origin."""
244
+ try:
245
+ d = await _driver_lazy()
246
+ center = (center_x, center_y) if center_x is not None and center_y is not None else None
247
+ return json.dumps(
248
+ (await ui_actions.sketch_circle(d, center, radius_px, centered=centered)).to_dict()
249
+ )
250
+ except Exception as e:
251
+ return _format_error("sketch.circle", e)
252
+
253
+
254
+ @mcp.tool()
255
+ async def onshape_sketch_line(p1_x: float, p1_y: float, p2_x: float, p2_y: float) -> str:
256
+ """Draw a line segment from (p1_x, p1_y) to (p2_x, p2_y)."""
257
+ try:
258
+ d = await _driver_lazy()
259
+ return json.dumps((await ui_actions.sketch_line(d, (p1_x, p1_y), (p2_x, p2_y))).to_dict())
260
+ except Exception as e:
261
+ return _format_error("sketch.line", e)
262
+
263
+
264
+ @mcp.tool()
265
+ async def onshape_sketch_polygon(
266
+ sides: int = 6,
267
+ radius: float = 60.0,
268
+ center_x: float | None = None,
269
+ center_y: float | None = None,
270
+ circumscribed: bool = False,
271
+ ) -> str:
272
+ """Draw an inscribed or circumscribed polygon with N sides and radius."""
273
+ try:
274
+ d = await _driver_lazy()
275
+ center = (center_x, center_y) if center_x is not None and center_y is not None else None
276
+ return json.dumps(
277
+ (
278
+ await ui_actions.sketch_polygon(
279
+ d, center=center, radius=radius, sides=sides, circumscribed=circumscribed
280
+ )
281
+ ).to_dict()
282
+ )
283
+ except Exception as e:
284
+ return _format_error("sketch.polygon", e)
285
+
286
+
287
+ @mcp.tool()
288
+ async def onshape_sketch_spline(points: list[list[float]]) -> str:
289
+ """Draw a smooth spline curve through a list of [[x1, y1], [x2, y2], ...] points."""
290
+ try:
291
+ d = await _driver_lazy()
292
+ pts = [(p[0], p[1]) for p in points]
293
+ return json.dumps((await ui_actions.sketch_spline(d, pts)).to_dict())
294
+ except Exception as e:
295
+ return _format_error("sketch.spline", e)
296
+
297
+
298
+ @mcp.tool()
299
+ async def onshape_sketch_arc(
300
+ p1_x: float,
301
+ p1_y: float,
302
+ p2_x: float,
303
+ p2_y: float,
304
+ radius_x: float | None = None,
305
+ radius_y: float | None = None,
306
+ ) -> str:
307
+ """Draw a 3-point arc from (p1_x, p1_y) to (p2_x, p2_y) passing through (radius_x, radius_y)."""
308
+ try:
309
+ d = await _driver_lazy()
310
+ rad_pt = (radius_x, radius_y) if radius_x is not None and radius_y is not None else None
311
+ return json.dumps(
312
+ (await ui_actions.sketch_arc(d, (p1_x, p1_y), (p2_x, p2_y), radius_pt=rad_pt)).to_dict()
313
+ )
314
+ except Exception as e:
315
+ return _format_error("sketch.arc", e)
316
+
317
+
318
+ @mcp.tool()
319
+ async def onshape_sketch_point(x: float, y: float) -> str:
320
+ """Place a sketch point at (x, y)."""
321
+ try:
322
+ d = await _driver_lazy()
323
+ return json.dumps((await ui_actions.sketch_point(d, (x, y))).to_dict())
324
+ except Exception as e:
325
+ return _format_error("sketch.point", e)
326
+
327
+
328
+ @mcp.tool()
329
+ async def onshape_sketch_text(
330
+ corner1_x: float, corner1_y: float, corner2_x: float, corner2_y: float, text: str
331
+ ) -> str:
332
+ """Place sketch text in a box defined by corner1 and corner2."""
333
+ try:
334
+ d = await _driver_lazy()
335
+ return json.dumps(
336
+ (
337
+ await ui_actions.sketch_text(
338
+ d, (corner1_x, corner1_y), (corner2_x, corner2_y), text=text
339
+ )
340
+ ).to_dict()
341
+ )
342
+ except Exception as e:
343
+ return _format_error("sketch.text", e)
344
+
345
+
346
+ # Sketch Modifications & Operations
347
+
348
+
349
+ @mcp.tool()
350
+ async def onshape_sketch_construction(x: float | None = None, y: float | None = None) -> str:
351
+ """Toggle construction mode, or convert the entity at (x, y) to/from construction geometry ('q')."""
352
+ try:
353
+ d = await _driver_lazy()
354
+ pt = (x, y) if x is not None and y is not None else None
355
+ return json.dumps((await ui_actions.sketch_construction(d, pt=pt)).to_dict())
356
+ except Exception as e:
357
+ return _format_error("sketch.construction", e)
358
+
359
+
360
+ @mcp.tool()
361
+ async def onshape_sketch_fillet(vertex_x: float, vertex_y: float, radius_mm: float = 5.0) -> str:
362
+ """Add a rounded 2D fillet of radius_mm at corner/vertex (vertex_x, vertex_y)."""
363
+ try:
364
+ d = await _driver_lazy()
365
+ return json.dumps(
366
+ (await ui_actions.sketch_fillet(d, (vertex_x, vertex_y), radius_mm=radius_mm)).to_dict()
367
+ )
368
+ except Exception as e:
369
+ return _format_error("sketch.fillet", e)
370
+
371
+
372
+ @mcp.tool()
373
+ async def onshape_sketch_chamfer(
374
+ vertex_x: float, vertex_y: float, distance_mm: float = 5.0
375
+ ) -> str:
376
+ """Add a 2D chamfer of distance_mm at corner/vertex (vertex_x, vertex_y)."""
377
+ try:
378
+ d = await _driver_lazy()
379
+ return json.dumps(
380
+ (
381
+ await ui_actions.sketch_chamfer(d, (vertex_x, vertex_y), distance_mm=distance_mm)
382
+ ).to_dict()
383
+ )
384
+ except Exception as e:
385
+ return _format_error("sketch.chamfer", e)
386
+
387
+
388
+ @mcp.tool()
389
+ async def onshape_sketch_trim(x: float, y: float) -> str:
390
+ """Trim a curve segment back to the nearest intersections by clicking at (x, y) ('m')."""
391
+ try:
392
+ d = await _driver_lazy()
393
+ return json.dumps((await ui_actions.sketch_trim(d, (x, y))).to_dict())
394
+ except Exception as e:
395
+ return _format_error("sketch.trim", e)
396
+
397
+
398
+ @mcp.tool()
399
+ async def onshape_sketch_extend(x: float, y: float) -> str:
400
+ """Extend a curve endpoint at (x, y) to the nearest boundary ('x')."""
401
+ try:
402
+ d = await _driver_lazy()
403
+ return json.dumps((await ui_actions.sketch_extend(d, (x, y))).to_dict())
404
+ except Exception as e:
405
+ return _format_error("sketch.extend", e)
406
+
407
+
408
+ @mcp.tool()
409
+ async def onshape_sketch_offset(
410
+ entity_x: float,
411
+ entity_y: float,
412
+ distance_mm: float = 5.0,
413
+ side_x: float | None = None,
414
+ side_y: float | None = None,
415
+ ) -> str:
416
+ """Offset curve at (entity_x, entity_y) by distance_mm toward optional side (side_x, side_y) ('o')."""
417
+ try:
418
+ d = await _driver_lazy()
419
+ side = (side_x, side_y) if side_x is not None and side_y is not None else None
420
+ return json.dumps(
421
+ (
422
+ await ui_actions.sketch_offset(
423
+ d, (entity_x, entity_y), distance_mm=distance_mm, side_xy=side
424
+ )
425
+ ).to_dict()
426
+ )
427
+ except Exception as e:
428
+ return _format_error("sketch.offset", e)
429
+
430
+
431
+ @mcp.tool()
432
+ async def onshape_sketch_mirror(
433
+ centerline_x: float, centerline_y: float, entity_x: float, entity_y: float
434
+ ) -> str:
435
+ """Mirror an entity at (entity_x, entity_y) across a centerline at (centerline_x, centerline_y)."""
436
+ try:
437
+ d = await _driver_lazy()
438
+ return json.dumps(
439
+ (
440
+ await ui_actions.sketch_mirror(
441
+ d, (centerline_x, centerline_y), (entity_x, entity_y)
442
+ )
443
+ ).to_dict()
444
+ )
445
+ except Exception as e:
446
+ return _format_error("sketch.mirror", e)
447
+
448
+
449
+ # Dimensions & Constraints
450
+
451
+
452
+ @mcp.tool()
453
+ async def onshape_sketch_dimension(
454
+ entity_x: float, entity_y: float, label_x: float, label_y: float, value_mm: float | str
455
+ ) -> str:
456
+ """Drive an Onshape sketch dimension ('d'). Clicks entity at (entity_x, entity_y), places label
457
+ at (label_x, label_y), and enters value_mm into the solver."""
458
+ try:
459
+ d = await _driver_lazy()
460
+ return json.dumps(
461
+ (
462
+ await ui_actions.sketch_dimension(
463
+ d, (entity_x, entity_y), (label_x, label_y), value_mm
464
+ )
465
+ ).to_dict()
466
+ )
467
+ except Exception as e:
468
+ return _format_error("sketch.dimension", e)
469
+
470
+
471
+ @mcp.tool()
472
+ async def onshape_sketch_constrain(
473
+ constraint_type: str,
474
+ entities: list[list[float]],
475
+ ) -> str:
476
+ """Apply a geometric constraint across entities.
477
+ Valid types: coincident, concentric, parallel, tangent, horizontal, vertical,
478
+ perpendicular, equal, midpoint, normal, pierce, symmetric, fix."""
479
+ try:
480
+ d = await _driver_lazy()
481
+ pts = [(e[0], e[1]) for e in entities]
482
+ return json.dumps(
483
+ (await ui_actions.sketch_constrain(d, constraint_type, *pts)).to_dict()
484
+ )
485
+ except Exception as e:
486
+ return _format_error(f"constraint.{constraint_type}", e)
487
+
488
+
489
+ # Feature Operations
490
+
491
+
492
+ @mcp.tool()
493
+ async def onshape_feature_extrude(depth_mm: float | None = None) -> str:
494
+ """Extrude the active sketch or selected face by depth_mm."""
495
+ try:
496
+ d = await _driver_lazy()
497
+ return json.dumps((await ui_actions.feature_extrude(d, depth_mm)).to_dict())
498
+ except Exception as e:
499
+ return _format_error("feature.extrude", e)
500
+
501
+
502
+ @mcp.tool()
503
+ async def onshape_feature_fillet(radius_mm: float | None = None) -> str:
504
+ """Round selected 3D model edges by radius_mm."""
505
+ try:
506
+ d = await _driver_lazy()
507
+ return json.dumps((await ui_actions.feature_fillet(d, radius_mm)).to_dict())
508
+ except Exception as e:
509
+ return _format_error("feature.fillet", e)
510
+
511
+
512
+ @mcp.tool()
513
+ async def onshape_feature_chamfer(distance_mm: float | None = None) -> str:
514
+ """Bevel selected 3D model edges by distance_mm."""
515
+ try:
516
+ d = await _driver_lazy()
517
+ return json.dumps((await ui_actions.feature_chamfer(d, distance_mm)).to_dict())
518
+ except Exception as e:
519
+ return _format_error("feature.chamfer", e)
520
+
521
+
522
+ # Generic Tool Dispatcher (with clean un-bound tool guard)
523
+
524
+
525
+ @mcp.tool()
526
+ async def onshape_execute(tool: str, args: dict[str, Any] | None = None) -> str:
527
+ """Generic tool dispatcher. Calls any registered tool by name with arguments.
528
+ Returns clear public error messages if a tool is un-bound or fails."""
529
+ if tool not in TOOL_DISPATCH:
530
+ return json.dumps(
531
+ {
532
+ "ok": False,
533
+ "tool": tool,
534
+ "error": "ToolNotBoundError",
535
+ "message": f"Tool '{tool}' is not bound or supported. Please choose from available tools.",
536
+ "available_tools": sorted(TOOL_DISPATCH.keys()),
537
+ },
538
+ indent=2,
539
+ )
540
+ try:
541
+ d = await _driver_lazy()
542
+ res = await dispatch(d, tool, args or {})
543
+ out = res.to_dict() if hasattr(res, "to_dict") else {"ok": True, "result": str(res)}
544
+ return json.dumps(out, indent=2)
545
+ except Exception as e:
546
+ return _format_error(tool, e)
547
+
548
+
549
+ # Autonomous Agent (Fast Path + Vision Loop)
550
+
551
+
552
+ @mcp.tool()
553
+ async def act(goal: str, max_steps: int = 25) -> str:
554
+ """Autonomous CAD agent. Takes a high-level goal in natural language (e.g.
555
+ 'draw a 10cm by 5cm box in Quadrant 1 on the top plane', 'draw a 6-sided polygon')
556
+ and executes it. Tries deterministic fast execution first (10-20s), falling back
557
+ to vision loop if ambiguous."""
558
+ try:
559
+ # 1. Try fast path first
560
+ plan = parse_intent(goal)
561
+ if plan is not None:
562
+ d = await _driver_lazy()
563
+ result_fast = await fast_execute(d, plan)
564
+ return json.dumps(
565
+ {
566
+ "ok": result_fast.ok,
567
+ "mode": "fast_path",
568
+ "goal": goal,
569
+ "summary": result_fast.summary(),
570
+ "actions_count": len(result_fast.step_times),
571
+ "total_elapsed_s": round(result_fast.total_elapsed_s, 1),
572
+ "final_screenshot": str(result_fast.final_screenshot)
573
+ if result_fast.final_screenshot
574
+ else None,
575
+ "error": result_fast.error,
576
+ },
577
+ indent=2,
578
+ )
579
+
580
+ # 2. Fallback to vision loop
581
+ loop = await _loop_lazy()
582
+ result_loop = await loop.run(goal, max_steps=max_steps)
583
+ tail = [
584
+ {
585
+ "step": r.step,
586
+ "tool": r.decision.get("tool"),
587
+ "args": r.decision.get("args", {}),
588
+ "ok": r.ok,
589
+ "error": r.error,
590
+ }
591
+ for r in result_loop.steps[-5:]
592
+ ]
593
+ return json.dumps(
594
+ {
595
+ "ok": result_loop.completed,
596
+ "mode": "vision_loop",
597
+ "goal": goal,
598
+ "summary": result_loop.summary(),
599
+ "total_elapsed_s": round(result_loop.total_elapsed_s, 1),
600
+ "steps_total": len(result_loop.steps),
601
+ "transcript_tail": tail,
602
+ "final_screenshot": str(result_loop.final_screenshot)
603
+ if result_loop.final_screenshot
604
+ else None,
605
+ "stop_reason": result_loop.stop_reason,
606
+ },
607
+ indent=2,
608
+ )
609
+ except Exception as e:
610
+ return _format_error("act", e)
611
+
612
+
613
+ # Lifecycle
614
+
615
+
616
+ async def _cleanup() -> None:
617
+ global _driver, _vision, _loop
618
+ if _loop is not None:
619
+ await _loop.close()
620
+ if _vision is not None:
621
+ await _vision.close()
622
+ if _driver is not None:
623
+ await _driver.close()
624
+ _driver = None
625
+ _vision = None
626
+ _loop = None
627
+
628
+
629
+ def main() -> None:
630
+ import argparse
631
+ import os
632
+ import sys
633
+
634
+ # Quick dispatch for setup wizard or login
635
+ if len(sys.argv) >= 2 and sys.argv[1] == "setup":
636
+ from .setup import main as run_setup
637
+
638
+ sys.argv.pop(1)
639
+ run_setup()
640
+ return
641
+
642
+ if len(sys.argv) >= 2 and sys.argv[1] == "login":
643
+ from .driver import login_interactive
644
+
645
+ asyncio.run(login_interactive())
646
+ return
647
+
648
+ if len(sys.argv) >= 2 and sys.argv[1] in ("share", "tunnel"):
649
+ from .tunnel import run_tunnel_and_server
650
+
651
+ port = int(os.environ.get("PORT", os.environ.get("MCP_PORT", "8000")))
652
+ run_tunnel_and_server(port=port)
653
+ return
654
+
655
+ parser = argparse.ArgumentParser(description="Onshape MCP Server")
656
+ parser.add_argument(
657
+ "--transport",
658
+ choices=["stdio", "sse"],
659
+ default=os.environ.get("MCP_TRANSPORT", "stdio"),
660
+ help="Transport protocol: 'stdio' for Claude Desktop / LLM clients (default), or 'sse' for remote hosting.",
661
+ )
662
+ parser.add_argument(
663
+ "--tunnel",
664
+ action="store_true",
665
+ help="Auto-start Cloudflare tunnel to expose live HTTPS link for ChatGPT Web / LibreChat.",
666
+ )
667
+ parser.add_argument(
668
+ "--host",
669
+ default=os.environ.get("MCP_HOST", "0.0.0.0"),
670
+ help="Host interface for SSE transport (default: 0.0.0.0)",
671
+ )
672
+ parser.add_argument(
673
+ "--port",
674
+ type=int,
675
+ default=int(os.environ.get("PORT", os.environ.get("MCP_PORT", "8000"))),
676
+ help="Port for SSE transport (default: 8000 or $PORT)",
677
+ )
678
+ args, _ = parser.parse_known_args()
679
+
680
+ if args.tunnel:
681
+ from .tunnel import run_tunnel_and_server
682
+
683
+ run_tunnel_and_server(port=args.port, host=args.host)
684
+ return
685
+
686
+ if args.transport == "sse":
687
+ mcp.settings.host = args.host
688
+ mcp.settings.port = args.port
689
+
690
+ try:
691
+ mcp.run(transport=args.transport)
692
+ finally:
693
+ asyncio.run(_cleanup())
694
+
695
+
696
+ if __name__ == "__main__":
697
+ main()
src/onshape_mcp/setup.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smart setup wizard for Onshape MCP.
2
+ Configures Playwright, syncs Onshape cookies, and automatically
3
+ adds the server to Claude Desktop across macOS, Windows, and Linux.
4
+
5
+ Zero hardcoded user paths or credentials.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import platform
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from .config import _BASE_DIR, settings
20
+
21
+
22
+ def get_claude_desktop_config_path() -> Path:
23
+ """Return platform-specific path to claude_desktop_config.json."""
24
+ system = platform.system()
25
+ if system == "Darwin": # macOS
26
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
27
+ elif system == "Windows":
28
+ app_data = os.environ.get("APPDATA")
29
+ base = Path(app_data) if app_data else Path.home() / "AppData" / "Roaming"
30
+ return base / "Claude" / "claude_desktop_config.json"
31
+ else: # Linux / FreeBSD / other POSIX
32
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
33
+ base = Path(xdg_config) if xdg_config else Path.home() / ".config"
34
+ return base / "Claude" / "claude_desktop_config.json"
35
+
36
+
37
+ def ensure_playwright_browsers() -> bool:
38
+ """Ensure Chromium binary is downloaded for Playwright."""
39
+ print("[1/4] Checking Playwright browser dependencies...")
40
+ try:
41
+ res = subprocess.run(
42
+ [sys.executable, "-m", "playwright", "install", "chromium"],
43
+ capture_output=True,
44
+ text=True,
45
+ check=False,
46
+ )
47
+ if res.returncode == 0:
48
+ print(" ✓ Chromium browser engine is installed and ready.")
49
+ return True
50
+ else:
51
+ print(f" ⚠ Note: Playwright browser check returned: {res.stderr.strip()[:100]}")
52
+ return True
53
+ except Exception as e:
54
+ print(f" ⚠ Could not run playwright install: {e}")
55
+ return False
56
+
57
+
58
+ def sync_onshape_cookies(interactive: bool = True) -> bool:
59
+ """Check for Onshape cookies or sync from local browsers or interactive login."""
60
+ print("[2/4] Checking Onshape authentication & session cookies...")
61
+
62
+ # 1. Existing cookie file
63
+ if settings.onshape_cookie_file.exists() and settings.onshape_cookie_file.stat().st_size > 20:
64
+ try:
65
+ data = json.loads(settings.onshape_cookie_file.read_text(encoding="utf-8"))
66
+ if data and isinstance(data, list):
67
+ print(f" ✓ Existing session found ({len(data)} cookies) at: {settings.onshape_cookie_file}")
68
+ return True
69
+ except Exception:
70
+ pass
71
+
72
+ # 2. Extract from installed browsers
73
+ try:
74
+ import browser_cookie3
75
+
76
+ browser_loaders = [
77
+ ("Google Chrome", getattr(browser_cookie3, "chrome", None)),
78
+ ("Brave", getattr(browser_cookie3, "brave", None)),
79
+ ("Microsoft Edge", getattr(browser_cookie3, "edge", None)),
80
+ ("Chromium", getattr(browser_cookie3, "chromium", None)),
81
+ ("Firefox", getattr(browser_cookie3, "firefox", None)),
82
+ ("Arc", getattr(browser_cookie3, "arc", None)),
83
+ ("Opera", getattr(browser_cookie3, "opera", None)),
84
+ ("Vivaldi", getattr(browser_cookie3, "vivaldi", None)),
85
+ ]
86
+ for browser_name, loader in browser_loaders:
87
+ if loader is None:
88
+ continue
89
+ try:
90
+ cj = loader(domain_name="onshape.com")
91
+ raw = []
92
+ for c in cj:
93
+ cookie = {
94
+ "name": c.name,
95
+ "value": c.value,
96
+ "domain": c.domain,
97
+ "path": c.path,
98
+ "secure": bool(c.secure),
99
+ "httpOnly": bool(
100
+ c.has_nonstandard_attr("HttpOnly")
101
+ or c.has_nonstandard_attr("httponly")
102
+ ),
103
+ }
104
+ if c.expires:
105
+ cookie["expires"] = float(c.expires)
106
+ raw.append(cookie)
107
+ if raw:
108
+ settings.onshape_cookie_file.parent.mkdir(parents=True, exist_ok=True)
109
+ settings.onshape_cookie_file.write_text(
110
+ json.dumps(raw, indent=2), encoding="utf-8"
111
+ )
112
+ print(
113
+ f" ✓ Auto-synced {len(raw)} Onshape cookies from {browser_name}."
114
+ )
115
+ return True
116
+ except Exception:
117
+ continue
118
+ except Exception:
119
+ pass
120
+
121
+ # 3. If missing and interactive, offer browser login
122
+ if interactive:
123
+ print(" ⚠ No active Onshape session found in local browsers.")
124
+ choice = input(" [?] Would you like to open a browser window to log in now? [Y/n]: ").strip().lower()
125
+ if choice not in ("n", "no"):
126
+ import asyncio
127
+ from .driver import login_interactive
128
+
129
+ asyncio.run(login_interactive())
130
+ return settings.onshape_cookie_file.exists()
131
+
132
+ print(" ⚠ Please log into https://cad.onshape.com in your browser before running commands.")
133
+ return False
134
+
135
+
136
+ def configure_claude_desktop(
137
+ doc_url: str = "",
138
+ use_local_env: bool = False,
139
+ auto_yes: bool = False,
140
+ ) -> Path | None:
141
+ path = get_claude_desktop_config_path()
142
+ _write_mcp_json(path, doc_url)
143
+ return path
144
+
145
+
146
+ def _write_mcp_json(config_path: Path, doc_url: str = "") -> bool:
147
+ """Helper to safely read, backup, and inject the onshape MCP entry into any mcp.json file."""
148
+ data: dict[str, Any] = {}
149
+ if config_path.exists():
150
+ try:
151
+ bak_path = config_path.with_suffix(".json.bak")
152
+ shutil.copy2(config_path, bak_path)
153
+ data = json.loads(config_path.read_text(encoding="utf-8"))
154
+ except Exception:
155
+ data = {}
156
+
157
+ if not isinstance(data, dict):
158
+ data = {}
159
+
160
+ if "mcpServers" not in data:
161
+ data["mcpServers"] = {}
162
+
163
+ server_entry: dict[str, Any] = {
164
+ "command": "uvx",
165
+ "args": [
166
+ "--from",
167
+ "git+https://github.com/WilliamSamson/onshape-mcp",
168
+ "onshape-mcp",
169
+ ],
170
+ }
171
+ if doc_url:
172
+ server_entry["env"] = {"ONSHAPE_DEFAULT_DOC": doc_url}
173
+
174
+ data["mcpServers"]["onshape"] = server_entry
175
+
176
+ config_path.parent.mkdir(parents=True, exist_ok=True)
177
+ config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
178
+ return True
179
+
180
+
181
+ def detect_supported_clients() -> list[tuple[str, Path]]:
182
+ """Detect all installed AI clients (Claude Desktop, Cursor, Windsurf, VS Code Cline)."""
183
+ clients: list[tuple[str, Path]] = []
184
+
185
+ # 1. Claude Desktop
186
+ claude_cfg = get_claude_desktop_config_path()
187
+ if claude_cfg.parent.exists() or claude_cfg.exists():
188
+ clients.append(("Claude Desktop", claude_cfg))
189
+
190
+ # 2. Cursor IDE
191
+ cursor_dir = Path.home() / ".cursor"
192
+ cursor_cfg = cursor_dir / "mcp.json"
193
+ if cursor_dir.exists() or cursor_cfg.exists():
194
+ clients.append(("Cursor IDE", cursor_cfg))
195
+
196
+ # 3. Windsurf
197
+ windsurf_dir = Path.home() / ".codeium" / "windsurf"
198
+ windsurf_cfg = windsurf_dir / "mcp_config.json"
199
+ if windsurf_dir.exists() or windsurf_cfg.exists():
200
+ clients.append(("Windsurf IDE", windsurf_cfg))
201
+
202
+ # 4. VS Code (Cline extension)
203
+ system = platform.system()
204
+ if system == "Darwin":
205
+ vscode_base = Path.home() / "Library" / "Application Support" / "Code"
206
+ elif system == "Windows":
207
+ app_data = os.environ.get("APPDATA")
208
+ vscode_base = (Path(app_data) if app_data else Path.home() / "AppData" / "Roaming") / "Code"
209
+ else:
210
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
211
+ vscode_base = (Path(xdg_config) if xdg_config else Path.home() / ".config") / "Code"
212
+
213
+ cline_cfg = vscode_base / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json"
214
+ if cline_cfg.parent.exists() or cline_cfg.exists():
215
+ clients.append(("VS Code (Cline)", cline_cfg))
216
+
217
+ roo_cfg = vscode_base / "User" / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings" / "cline_mcp_settings.json"
218
+ if roo_cfg.parent.exists() or roo_cfg.exists():
219
+ clients.append(("VS Code (Roo Code)", roo_cfg))
220
+
221
+ return clients
222
+
223
+
224
+ def configure_detected_clients(
225
+ doc_url: str = "",
226
+ auto_yes: bool = False,
227
+ ) -> list[str]:
228
+ """Auto-configure all detected AI clients."""
229
+ print("[4/4] Configuring AI Client Integrations (Claude, Cursor, VS Code, etc.)...")
230
+ detected = detect_supported_clients()
231
+ configured: list[str] = []
232
+
233
+ if not detected:
234
+ print(" ℹ No desktop AI clients (Claude, Cursor, Windsurf, Cline) detected automatically.")
235
+ # Offer default Claude Desktop location
236
+ default_claude = get_claude_desktop_config_path()
237
+ choice = "y" if auto_yes else input(
238
+ f" [?] Create Claude Desktop config anyway at:\n {default_claude}? [Y/n]: "
239
+ ).strip().lower()
240
+ if choice not in ("n", "no"):
241
+ _write_mcp_json(default_claude, doc_url)
242
+ configured.append(f"Claude Desktop ({default_claude})")
243
+ print(f" ✓ Configured Claude Desktop at: {default_claude}")
244
+ return configured
245
+
246
+ for name, path in detected:
247
+ if not auto_yes:
248
+ choice = input(f" [?] Auto-configure {name} at:\n {path}? [Y/n]: ").strip().lower()
249
+ if choice in ("n", "no"):
250
+ print(f" Skipped {name}.")
251
+ continue
252
+ _write_mcp_json(path, doc_url)
253
+ configured.append(f"{name} ({path})")
254
+ print(f" ✓ Configured {name} at: {path}")
255
+
256
+ return configured
257
+
258
+
259
+ def run_setup(
260
+ doc_url: str = "",
261
+ auto_yes: bool = False,
262
+ skip_browser_install: bool = False,
263
+ ) -> None:
264
+ print("=" * 68)
265
+ print(" Onshape MCP — 1-Click Multi-AI Setup & Tester Wizard")
266
+ print("=" * 68)
267
+ print(f"Base data directory: {_BASE_DIR}\n")
268
+
269
+ # 1. Playwright install
270
+ if not skip_browser_install:
271
+ ensure_playwright_browsers()
272
+ else:
273
+ print("[1/4] Skipping browser download flag.")
274
+
275
+ print()
276
+
277
+ # 2. Cookie sync
278
+ sync_onshape_cookies(interactive=not auto_yes)
279
+ print()
280
+
281
+ # 3. Document URL
282
+ print("[3/4] Document Configuration...")
283
+ final_doc_url = doc_url
284
+ if not final_doc_url and not auto_yes:
285
+ try:
286
+ val = input(
287
+ " [?] Default Onshape Document URL (optional, press Enter to skip):\n > "
288
+ ).strip()
289
+ if val:
290
+ final_doc_url = val
291
+ except (EOFError, KeyboardInterrupt):
292
+ pass
293
+
294
+ if final_doc_url:
295
+ env_file = _BASE_DIR / ".env"
296
+ existing = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
297
+ if "ONSHAPE_DEFAULT_DOC=" in existing:
298
+ import re
299
+ new_text = re.sub(r"ONSHAPE_DEFAULT_DOC=.*", f"ONSHAPE_DEFAULT_DOC={final_doc_url}", existing)
300
+ else:
301
+ new_text = existing + f"\nONSHAPE_DEFAULT_DOC={final_doc_url}\n"
302
+ env_file.write_text(new_text, encoding="utf-8")
303
+ print(f" ✓ Saved default document URL to {env_file}")
304
+ else:
305
+ print(" ✓ No default document set (you can specify URLs per prompt).")
306
+
307
+ print()
308
+
309
+ # 4. Client config
310
+ configured = configure_detected_clients(
311
+ doc_url=final_doc_url,
312
+ auto_yes=auto_yes,
313
+ )
314
+ print()
315
+
316
+ # Summary
317
+ print("=" * 68)
318
+ print("🎉 Setup Complete!")
319
+ print("=" * 68)
320
+ if configured:
321
+ print("Configured Desktop Clients:")
322
+ for c in configured:
323
+ print(f" • {c}")
324
+ print("\nRestart your AI client, then ask:")
325
+ print(" 'Draw a 10cm by 5cm box on the Top plane in Onshape'")
326
+ else:
327
+ print("Manual Desktop Config (Claude Desktop, Cursor, Windsurf):")
328
+ print(
329
+ json.dumps(
330
+ {
331
+ "mcpServers": {
332
+ "onshape": {
333
+ "command": "uvx",
334
+ "args": [
335
+ "--from",
336
+ "git+https://github.com/WilliamSamson/onshape-mcp",
337
+ "onshape-mcp",
338
+ ],
339
+ }
340
+ }
341
+ },
342
+ indent=2,
343
+ )
344
+ )
345
+
346
+ print("-" * 68)
347
+ print("🌐 Using Web Versions Only? (ChatGPT Web, LibreChat, Open WebUI):")
348
+ print("1. Start the server in SSE mode:")
349
+ print(" onshape-mcp --transport sse --port 8000")
350
+ print("2. Expose it via a secure tunnel (e.g. Cloudflare or Localtunnel):")
351
+ print(" cloudflared tunnel --url http://localhost:8000")
352
+ print(" # Or: npx localtunnel --port 8000")
353
+ print("3. Paste the resulting URL (e.g. https://xxxx.trycloudflare.com/sse)")
354
+ print(" into your web AI connector / LibreChat / Open WebUI!")
355
+ print("=" * 68)
356
+
357
+
358
+ def main() -> None:
359
+ parser = argparse.ArgumentParser(description="Onshape MCP 1-Click Setup Wizard")
360
+ parser.add_argument("--yes", "-y", action="store_true", help="Auto-confirm all prompts")
361
+ parser.add_argument("--doc-url", default="", help="Default Onshape document URL to operate on")
362
+ parser.add_argument("--skip-browsers", action="store_true", help="Skip downloading Chromium")
363
+ args = parser.parse_args()
364
+
365
+ run_setup(
366
+ doc_url=args.doc_url,
367
+ auto_yes=args.yes,
368
+ skip_browser_install=args.skip_browsers,
369
+ )
370
+
371
+
372
+ if __name__ == "__main__":
373
+ main()
src/onshape_mcp/shortcuts.py ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Onshape UI bindings. Keyboard shortcuts and toolbar button labels.
2
+
3
+ This is the bridge between the semantic tool vocabulary in tools.py and
4
+ the actual UI. Onshape changes this occasionally; when an action fails,
5
+ this is the first place to check.
6
+
7
+ Confidence flags: high (tested or well-documented), medium (plausible,
8
+ not yet verified), low (guess based on convention).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass
17
+ class Binding:
18
+ keys: tuple[str, ...] | None = None
19
+ toolbar_text: str | None = None # visible label / aria-label
20
+ toolbar_role: str = "button"
21
+ notes: str = ""
22
+ confidence: str = "medium"
23
+
24
+
25
+ # View and camera
26
+ VIEW_FIT = Binding(keys=("f",), confidence="high", notes="Fit all; 'F' in viewport")
27
+ VIEW_TOP = Binding(keys=("7",), confidence="low")
28
+ VIEW_FRONT = Binding(keys=("1",), confidence="low")
29
+ VIEW_ISO = Binding(keys=("Shift", "7"), confidence="high", notes="Isometric view: Shift+7")
30
+
31
+ # Sketch start and exit
32
+ SKETCH_START = Binding(
33
+ toolbar_text="Sketch",
34
+ toolbar_role="button",
35
+ confidence="high",
36
+ notes="Click the 'Sketch' button in the left toolbar, then click a plane or face.",
37
+ )
38
+ SKETCH_EXIT = Binding(
39
+ keys=("Escape",),
40
+ confidence="high",
41
+ notes="Press Esc to exit the current sketch tool.",
42
+ )
43
+
44
+ # Sketch Primitives
45
+ SKETCH_LINE = Binding(
46
+ keys=("l",),
47
+ toolbar_text="Line",
48
+ toolbar_role="button",
49
+ confidence="high",
50
+ notes="Line tool shortcut 'l'. Click start, click end.",
51
+ )
52
+ SKETCH_LINE_MIDPOINT = Binding(
53
+ toolbar_text="Midpoint line",
54
+ toolbar_role="button",
55
+ confidence="medium",
56
+ notes="Line anchored at midpoint.",
57
+ )
58
+ SKETCH_RECTANGLE = Binding(
59
+ keys=("g",),
60
+ toolbar_text="Rectangle",
61
+ toolbar_role="button",
62
+ confidence="high",
63
+ notes="Corner rectangle shortcut 'g'.",
64
+ )
65
+ SKETCH_RECTANGLE_CENTER = Binding(
66
+ keys=("r",),
67
+ toolbar_text="Center point rectangle",
68
+ toolbar_role="button",
69
+ confidence="high",
70
+ notes="Center point rectangle shortcut 'r'.",
71
+ )
72
+ SKETCH_RECTANGLE_ALIGNED = Binding(
73
+ toolbar_text="Aligned rectangle",
74
+ toolbar_role="button",
75
+ confidence="medium",
76
+ notes="3-point aligned rectangle.",
77
+ )
78
+ SKETCH_CIRCLE = Binding(
79
+ keys=("c",),
80
+ toolbar_text="Circle",
81
+ toolbar_role="button",
82
+ confidence="high",
83
+ notes="Center point circle shortcut 'c'.",
84
+ )
85
+ SKETCH_CIRCLE_3POINT = Binding(
86
+ toolbar_text="3 point circle",
87
+ toolbar_role="button",
88
+ confidence="medium",
89
+ notes="Circle defined by 3 perimeter points.",
90
+ )
91
+ SKETCH_ELLIPSE = Binding(
92
+ toolbar_text="Ellipse",
93
+ toolbar_role="button",
94
+ confidence="medium",
95
+ notes="Center point and major/minor axes.",
96
+ )
97
+ SKETCH_ARC = Binding(
98
+ keys=("a",),
99
+ toolbar_text="Arc",
100
+ toolbar_role="button",
101
+ confidence="high",
102
+ notes="3-point arc shortcut 'a'. Click start, click end, click radius point.",
103
+ )
104
+ SKETCH_ARC_3POINT = SKETCH_ARC
105
+ SKETCH_ARC_TANGENT = Binding(
106
+ toolbar_text="Tangent arc",
107
+ toolbar_role="button",
108
+ confidence="medium",
109
+ notes="Arc tangent to an existing line or curve.",
110
+ )
111
+ SKETCH_ARC_CENTER = Binding(
112
+ toolbar_text="Center point arc",
113
+ toolbar_role="button",
114
+ confidence="medium",
115
+ notes="Center point, start angle, end angle.",
116
+ )
117
+ SKETCH_ARC_ELLIPTICAL = Binding(
118
+ toolbar_text="Elliptical arc",
119
+ toolbar_role="button",
120
+ confidence="medium",
121
+ )
122
+ SKETCH_CONIC = Binding(
123
+ toolbar_text="Conic",
124
+ toolbar_role="button",
125
+ confidence="medium",
126
+ )
127
+ SKETCH_POLYGON = Binding(
128
+ toolbar_text="Inscribed polygon",
129
+ toolbar_role="button",
130
+ confidence="high",
131
+ notes="Inscribed polygon. Click center, click radius, type side count.",
132
+ )
133
+ SKETCH_POLYGON_INSCRIBED = SKETCH_POLYGON
134
+ SKETCH_POLYGON_CIRCUMSCRIBED = Binding(
135
+ toolbar_text="Circumscribed polygon",
136
+ toolbar_role="button",
137
+ confidence="medium",
138
+ )
139
+ SKETCH_SPLINE = Binding(
140
+ toolbar_text="Spline",
141
+ toolbar_role="button",
142
+ confidence="medium",
143
+ notes="Spline through control points.",
144
+ )
145
+ SKETCH_BEZIER = Binding(
146
+ toolbar_text="Bezier",
147
+ toolbar_role="button",
148
+ confidence="medium",
149
+ )
150
+ SKETCH_POINT = Binding(
151
+ toolbar_text="Point",
152
+ toolbar_role="button",
153
+ confidence="high",
154
+ notes="Single sketch point.",
155
+ )
156
+ SKETCH_TEXT = Binding(
157
+ toolbar_text="Text",
158
+ toolbar_role="button",
159
+ confidence="high",
160
+ notes="Text in sketch. Drag bounding box, enter string.",
161
+ )
162
+ SKETCH_USE = Binding(
163
+ keys=("u",),
164
+ toolbar_text="Use",
165
+ toolbar_role="button",
166
+ confidence="high",
167
+ notes="Project existing geometry onto sketch plane ('u').",
168
+ )
169
+ SKETCH_INTERSECTION = Binding(
170
+ toolbar_text="Intersection",
171
+ toolbar_role="button",
172
+ confidence="medium",
173
+ )
174
+
175
+ # Modifications & Operations
176
+ SKETCH_CONSTRUCTION = Binding(
177
+ keys=("q",),
178
+ toolbar_text="Construction",
179
+ toolbar_role="button",
180
+ confidence="high",
181
+ notes="Toggle construction geometry on/off ('q').",
182
+ )
183
+ SKETCH_FILLET = Binding(
184
+ keys=("Shift", "f"),
185
+ toolbar_text="Fillet",
186
+ toolbar_role="button",
187
+ confidence="high",
188
+ notes="Sketch fillet between two lines or at a vertex ('Shift+f').",
189
+ )
190
+ SKETCH_CHAMFER = Binding(
191
+ toolbar_text="Chamfer",
192
+ toolbar_role="button",
193
+ confidence="medium",
194
+ notes="Sketch chamfer at corner.",
195
+ )
196
+ SKETCH_TRIM = Binding(
197
+ keys=("m",),
198
+ toolbar_text="Trim",
199
+ toolbar_role="button",
200
+ confidence="high",
201
+ notes="Trim curve to nearest intersection ('m').",
202
+ )
203
+ SKETCH_EXTEND = Binding(
204
+ keys=("x",),
205
+ toolbar_text="Extend",
206
+ toolbar_role="button",
207
+ confidence="high",
208
+ notes="Extend curve to boundary ('x').",
209
+ )
210
+ SKETCH_SPLIT = Binding(
211
+ toolbar_text="Split",
212
+ toolbar_role="button",
213
+ confidence="medium",
214
+ notes="Split curve at point.",
215
+ )
216
+ SKETCH_OFFSET = Binding(
217
+ keys=("o",),
218
+ toolbar_text="Offset",
219
+ toolbar_role="button",
220
+ confidence="high",
221
+ notes="Offset selected sketch curve ('o').",
222
+ )
223
+ SKETCH_SLOT = Binding(
224
+ toolbar_text="Slot",
225
+ toolbar_role="button",
226
+ confidence="medium",
227
+ )
228
+ SKETCH_MIRROR = Binding(
229
+ toolbar_text="Mirror",
230
+ toolbar_role="button",
231
+ confidence="high",
232
+ notes="Mirror entities across a centerline.",
233
+ )
234
+ SKETCH_PATTERN_LINEAR = Binding(
235
+ toolbar_text="Linear pattern",
236
+ toolbar_role="button",
237
+ confidence="medium",
238
+ )
239
+ SKETCH_PATTERN_CIRCULAR = Binding(
240
+ toolbar_text="Circular pattern",
241
+ toolbar_role="button",
242
+ confidence="medium",
243
+ )
244
+ SKETCH_TRANSFORM = Binding(
245
+ toolbar_text="Transform",
246
+ toolbar_role="button",
247
+ confidence="medium",
248
+ )
249
+
250
+ # Dimension & Constraints
251
+ SKETCH_DIMENSION = Binding(
252
+ keys=("d",),
253
+ toolbar_text="Dimension",
254
+ toolbar_role="button",
255
+ confidence="high",
256
+ notes="Drive linear/angular/radial dimension ('d').",
257
+ )
258
+ CONSTRAINT_COINCIDENT = Binding(
259
+ keys=("i",),
260
+ toolbar_text="Coincident",
261
+ toolbar_role="button",
262
+ confidence="high",
263
+ notes="Coincident constraint ('i').",
264
+ )
265
+ CONSTRAINT_CONCENTRIC = Binding(
266
+ toolbar_text="Concentric",
267
+ toolbar_role="button",
268
+ confidence="high",
269
+ notes="Concentric constraint between two circles/arcs.",
270
+ )
271
+ CONSTRAINT_PARALLEL = Binding(
272
+ keys=("b",),
273
+ toolbar_text="Parallel",
274
+ toolbar_role="button",
275
+ confidence="high",
276
+ notes="Parallel constraint between two lines ('b').",
277
+ )
278
+ CONSTRAINT_TANGENT = Binding(
279
+ keys=("t",),
280
+ toolbar_text="Tangent",
281
+ toolbar_role="button",
282
+ confidence="high",
283
+ notes="Tangent constraint ('t').",
284
+ )
285
+ CONSTRAINT_HORIZONTAL = Binding(
286
+ keys=("h",),
287
+ toolbar_text="Horizontal",
288
+ toolbar_role="button",
289
+ confidence="high",
290
+ notes="Horizontal constraint ('h').",
291
+ )
292
+ CONSTRAINT_VERTICAL = Binding(
293
+ keys=("v",),
294
+ toolbar_text="Vertical",
295
+ toolbar_role="button",
296
+ confidence="high",
297
+ notes="Vertical constraint ('v').",
298
+ )
299
+ CONSTRAINT_PERPENDICULAR = Binding(
300
+ toolbar_text="Perpendicular",
301
+ toolbar_role="button",
302
+ confidence="high",
303
+ notes="Perpendicular constraint between two lines.",
304
+ )
305
+ CONSTRAINT_EQUAL = Binding(
306
+ keys=("e",),
307
+ toolbar_text="Equal",
308
+ toolbar_role="button",
309
+ confidence="high",
310
+ notes="Equal length/radius constraint ('e').",
311
+ )
312
+ CONSTRAINT_MIDPOINT = Binding(
313
+ toolbar_text="Midpoint",
314
+ toolbar_role="button",
315
+ confidence="high",
316
+ notes="Midpoint constraint between point and line.",
317
+ )
318
+ CONSTRAINT_NORMAL = Binding(
319
+ toolbar_text="Normal",
320
+ toolbar_role="button",
321
+ confidence="medium",
322
+ notes="Normal constraint to plane or surface.",
323
+ )
324
+ CONSTRAINT_PIERCE = Binding(
325
+ toolbar_text="Pierce",
326
+ toolbar_role="button",
327
+ confidence="medium",
328
+ notes="Pierce constraint between point and curve intersecting plane.",
329
+ )
330
+ CONSTRAINT_SYMMETRIC = Binding(
331
+ toolbar_text="Symmetric",
332
+ toolbar_role="button",
333
+ confidence="medium",
334
+ notes="Symmetric constraint across a centerline.",
335
+ )
336
+ CONSTRAINT_FIX = Binding(
337
+ toolbar_text="Fix",
338
+ toolbar_role="button",
339
+ confidence="high",
340
+ notes="Fix entity in place.",
341
+ )
342
+ CONSTRAINT_CURVATURE = Binding(
343
+ toolbar_text="Curvature",
344
+ toolbar_role="button",
345
+ confidence="medium",
346
+ notes="Curvature continuous constraint.",
347
+ )
348
+
349
+ # Feature tools
350
+ FEATURE_EXTRUDE = Binding(
351
+ keys=("Shift", "e"),
352
+ toolbar_text="Extrude",
353
+ toolbar_role="button",
354
+ confidence="high",
355
+ notes="With a closed sketch region selected, press Shift+E or click Extrude.",
356
+ )
357
+ FEATURE_REVOLVE = Binding(
358
+ keys=("r",),
359
+ toolbar_text="Revolve",
360
+ toolbar_role="button",
361
+ confidence="medium",
362
+ )
363
+ FEATURE_FILLET = Binding(
364
+ keys=("f",),
365
+ toolbar_text="Fillet",
366
+ toolbar_role="button",
367
+ confidence="medium",
368
+ notes="'F' collides with view.fit when nothing is selected. Prefer toolbar click.",
369
+ )
370
+ FEATURE_CHAMFER = Binding(toolbar_text="Chamfer", toolbar_role="button", confidence="high")
371
+ FEATURE_SHELL = Binding(toolbar_text="Shell", toolbar_role="button", confidence="high")
372
+ FEATURE_PATTERN = Binding(toolbar_text="Pattern", toolbar_role="button", confidence="medium")
373
+ FEATURE_MIRROR_BODY = Binding(toolbar_text="Mirror", toolbar_role="button", confidence="medium")
374
+
375
+ # Assembly and meta
376
+ ASSEMBLY_MATE = Binding(toolbar_text="Mate", toolbar_role="button", confidence="high")
377
+ ASSEMBLY_PATTERN = Binding(toolbar_text="Pattern", toolbar_role="button", confidence="medium")
378
+
379
+ # Global bindings
380
+ UNDO = Binding(keys=("Control", "z"), confidence="high")
381
+ REDO = Binding(keys=("Control", "shift", "z"), confidence="high")
382
+ SAVE = Binding(
383
+ keys=("Control", "s"),
384
+ confidence="high",
385
+ notes="Onshape auto-saves; this is a no-op but the shortcut still works.",
386
+ )
387
+ DESELECT = Binding(keys=("Escape",), confidence="high")
388
+ CONFIRM = Binding(keys=("Enter",), confidence="high")
389
+ DELETE = Binding(keys=("Delete",), confidence="high")
390
+
391
+ # Map datasheet tool name -> binding.
392
+ BINDINGS: dict[str, Binding] = {
393
+ "view.fit": VIEW_FIT,
394
+ "view.top": VIEW_TOP,
395
+ "view.front": VIEW_FRONT,
396
+ "view.iso": VIEW_ISO,
397
+ "view.isometric": VIEW_ISO,
398
+ "view.rotate": Binding(
399
+ notes="Middle-mouse drag (no fixed coords). The LLM passes start/end via the call.",
400
+ confidence="high",
401
+ ),
402
+ # Sketch lifecycle
403
+ "sketch.start": SKETCH_START,
404
+ "sketch.exit": SKETCH_EXIT,
405
+ # Sketch Primitives
406
+ "sketch.line": SKETCH_LINE,
407
+ "sketch.line_midpoint": SKETCH_LINE_MIDPOINT,
408
+ "sketch.rectangle": SKETCH_RECTANGLE,
409
+ "sketch.rectangle_center": SKETCH_RECTANGLE_CENTER,
410
+ "sketch.rectangle_aligned": SKETCH_RECTANGLE_ALIGNED,
411
+ "sketch.circle": SKETCH_CIRCLE,
412
+ "sketch.circle_3point": SKETCH_CIRCLE_3POINT,
413
+ "sketch.ellipse": SKETCH_ELLIPSE,
414
+ "sketch.arc": SKETCH_ARC,
415
+ "sketch.arc_3point": SKETCH_ARC_3POINT,
416
+ "sketch.arc_tangent": SKETCH_ARC_TANGENT,
417
+ "sketch.arc_center": SKETCH_ARC_CENTER,
418
+ "sketch.arc_elliptical": SKETCH_ARC_ELLIPTICAL,
419
+ "sketch.conic": SKETCH_CONIC,
420
+ "sketch.polygon": SKETCH_POLYGON,
421
+ "sketch.polygon_inscribed": SKETCH_POLYGON_INSCRIBED,
422
+ "sketch.polygon_circumscribed": SKETCH_POLYGON_CIRCUMSCRIBED,
423
+ "sketch.spline": SKETCH_SPLINE,
424
+ "sketch.bezier": SKETCH_BEZIER,
425
+ "sketch.point": SKETCH_POINT,
426
+ "sketch.text": SKETCH_TEXT,
427
+ "sketch.use": SKETCH_USE,
428
+ "sketch.intersection": SKETCH_INTERSECTION,
429
+ # Operations
430
+ "sketch.construction": SKETCH_CONSTRUCTION,
431
+ "sketch.fillet": SKETCH_FILLET,
432
+ "sketch.chamfer": SKETCH_CHAMFER,
433
+ "sketch.trim": SKETCH_TRIM,
434
+ "sketch.extend": SKETCH_EXTEND,
435
+ "sketch.split": SKETCH_SPLIT,
436
+ "sketch.offset": SKETCH_OFFSET,
437
+ "sketch.slot": SKETCH_SLOT,
438
+ "sketch.mirror": SKETCH_MIRROR,
439
+ "sketch.pattern_linear": SKETCH_PATTERN_LINEAR,
440
+ "sketch.pattern_circular": SKETCH_PATTERN_CIRCULAR,
441
+ "sketch.transform": SKETCH_TRANSFORM,
442
+ # Dimensions & Constraints
443
+ "sketch.dimension": SKETCH_DIMENSION,
444
+ "sketch.equal": CONSTRAINT_EQUAL,
445
+ "sketch.constrain": CONSTRAINT_COINCIDENT,
446
+ "constraint.coincident": CONSTRAINT_COINCIDENT,
447
+ "constraint.concentric": CONSTRAINT_CONCENTRIC,
448
+ "constraint.parallel": CONSTRAINT_PARALLEL,
449
+ "constraint.tangent": CONSTRAINT_TANGENT,
450
+ "constraint.horizontal": CONSTRAINT_HORIZONTAL,
451
+ "constraint.vertical": CONSTRAINT_VERTICAL,
452
+ "constraint.perpendicular": CONSTRAINT_PERPENDICULAR,
453
+ "constraint.equal": CONSTRAINT_EQUAL,
454
+ "constraint.midpoint": CONSTRAINT_MIDPOINT,
455
+ "constraint.normal": CONSTRAINT_NORMAL,
456
+ "constraint.pierce": CONSTRAINT_PIERCE,
457
+ "constraint.symmetric": CONSTRAINT_SYMMETRIC,
458
+ "constraint.fix": CONSTRAINT_FIX,
459
+ "constraint.curvature": CONSTRAINT_CURVATURE,
460
+ # Features
461
+ "feature.extrude": FEATURE_EXTRUDE,
462
+ "feature.revolve": FEATURE_REVOLVE,
463
+ "feature.fillet": FEATURE_FILLET,
464
+ "feature.chamfer": FEATURE_CHAMFER,
465
+ "feature.shell": FEATURE_SHELL,
466
+ "feature.pattern": FEATURE_PATTERN,
467
+ "feature.mirror_body": FEATURE_MIRROR_BODY,
468
+ "assembly.mate": ASSEMBLY_MATE,
469
+ "assembly.pattern": ASSEMBLY_PATTERN,
470
+ "select.face": Binding(
471
+ notes="Click in viewport at (x,y); ui_actions handles the click.", confidence="high"
472
+ ),
473
+ "select.edge": Binding(notes="Click in viewport at (x,y).", confidence="high"),
474
+ "select.body": Binding(notes="Click in viewport or in the feature tree.", confidence="high"),
475
+ "ui.undo": UNDO,
476
+ "ui.redo": REDO,
477
+ "ui.wait": Binding(
478
+ notes="No-op. Pauses for N seconds and screenshots. Use when the LLM wants to re-observe the viewport.",
479
+ confidence="high",
480
+ ),
481
+ "doc.save": SAVE,
482
+ "doc.new": Binding(
483
+ toolbar_text="Create",
484
+ toolbar_role="button",
485
+ notes="Create a new document. Or just navigate to /documents/new.",
486
+ confidence="medium",
487
+ ),
488
+ "ui.deselect": DESELECT,
489
+ "ui.confirm": CONFIRM,
490
+ "ui.delete": DELETE,
491
+ }
492
+
493
+
494
+ def get(tool_name: str) -> Binding:
495
+ try:
496
+ return BINDINGS[tool_name]
497
+ except KeyError as e:
498
+ raise KeyError(f"No UI binding for {tool_name!r} in shortcuts.py") from e
src/onshape_mcp/tools.py ADDED
@@ -0,0 +1,539 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Onshape tool datasheet. One entry per semantic op the agent can call.
2
+ The actual clicks live in ui_actions.py / driver.py. This file is the
3
+ vocabulary the LLM sees via the `tool_datasheet` MCP tool.
4
+
5
+ Status flags: planned, stub, working.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ @dataclass
14
+ class ToolSpec:
15
+ name: str
16
+ purpose: str
17
+ requires: list[str] = field(default_factory=list) # preconditions
18
+ produces: list[str] = field(default_factory=list) # postconditions
19
+ next_steps: list[str] = field(default_factory=list) # likely follow-ups
20
+ status: str = "working"
21
+
22
+
23
+ # Sketch lifecycle
24
+ SKETCH_START = ToolSpec(
25
+ name="sketch.start",
26
+ purpose="Open a new sketch on a chosen plane (Top/Front/Right or a face).",
27
+ produces=["sketch.active=true"],
28
+ next_steps=["sketch.rectangle", "sketch.circle", "sketch.line", "sketch.spline"],
29
+ status="working",
30
+ )
31
+ SKETCH_EXIT = ToolSpec(
32
+ name="sketch.exit",
33
+ purpose="Close the active sketch and return to part studio.",
34
+ requires=["sketch.active=true"],
35
+ produces=["sketch.active=false"],
36
+ next_steps=["feature.extrude", "feature.revolve", "feature.fillet", "feature.chamfer"],
37
+ status="working",
38
+ )
39
+
40
+ # Sketch Primitives
41
+ SKETCH_LINE = ToolSpec(
42
+ name="sketch.line",
43
+ purpose="Single line segment from p1=(x1,y1) to p2=(x2,y2).",
44
+ requires=["sketch.active=true"],
45
+ next_steps=["sketch.dimension", "sketch.constrain", "sketch.exit"],
46
+ status="working",
47
+ )
48
+ SKETCH_LINE_MIDPOINT = ToolSpec(
49
+ name="sketch.line_midpoint",
50
+ purpose="Line segment symmetric around a midpoint coordinate.",
51
+ requires=["sketch.active=true"],
52
+ next_steps=["sketch.dimension", "sketch.exit"],
53
+ status="working",
54
+ )
55
+ SKETCH_RECTANGLE = ToolSpec(
56
+ name="sketch.rectangle",
57
+ purpose="Two-corner rectangle. Pass quadrant='1'|'2'|'3'|'4', centered=true, or corner1=(x,y), corner2=(x,y), with width/height (e.g. '10 cm' or 50).",
58
+ requires=["sketch.active=true"],
59
+ next_steps=["sketch.dimension", "feature.extrude", "sketch.exit"],
60
+ status="working",
61
+ )
62
+ SKETCH_RECTANGLE_CENTER = ToolSpec(
63
+ name="sketch.rectangle_center",
64
+ purpose="Center-point rectangle defined by center=(x,y) and corner=(x,y) or width/height.",
65
+ requires=["sketch.active=true"],
66
+ next_steps=["sketch.dimension", "feature.extrude", "sketch.exit"],
67
+ status="working",
68
+ )
69
+ SKETCH_RECTANGLE_ALIGNED = ToolSpec(
70
+ name="sketch.rectangle_aligned",
71
+ purpose="3-point aligned rectangle defined by baseline (p1, p2) and height point p3.",
72
+ requires=["sketch.active=true"],
73
+ next_steps=["sketch.dimension", "sketch.exit"],
74
+ status="working",
75
+ )
76
+ SKETCH_CIRCLE = ToolSpec(
77
+ name="sketch.circle",
78
+ purpose="Center point circle. Pass centered=true (for origin) or center=(x,y), radius=mm or radius_px.",
79
+ requires=["sketch.active=true"],
80
+ next_steps=["sketch.dimension", "feature.extrude", "sketch.exit"],
81
+ status="working",
82
+ )
83
+ SKETCH_CIRCLE_3POINT = ToolSpec(
84
+ name="sketch.circle_3point",
85
+ purpose="Circle passing through three points p1, p2, p3.",
86
+ requires=["sketch.active=true"],
87
+ next_steps=["sketch.dimension", "sketch.exit"],
88
+ status="working",
89
+ )
90
+ SKETCH_ELLIPSE = ToolSpec(
91
+ name="sketch.ellipse",
92
+ purpose="Ellipse defined by center=(x,y), major axis point, and minor axis point.",
93
+ requires=["sketch.active=true"],
94
+ next_steps=["sketch.dimension", "sketch.exit"],
95
+ status="working",
96
+ )
97
+ SKETCH_ARC = ToolSpec(
98
+ name="sketch.arc",
99
+ purpose="3-point arc from p1 to p2 passing through radius/point p3.",
100
+ requires=["sketch.active=true"],
101
+ next_steps=["sketch.dimension", "sketch.exit"],
102
+ status="working",
103
+ )
104
+ SKETCH_ARC_TANGENT = ToolSpec(
105
+ name="sketch.arc_tangent",
106
+ purpose="Arc tangent to an existing line/curve starting at vertex p1 ending at p2.",
107
+ requires=["sketch.active=true"],
108
+ next_steps=["sketch.dimension", "sketch.exit"],
109
+ status="working",
110
+ )
111
+ SKETCH_ARC_CENTER = ToolSpec(
112
+ name="sketch.arc_center",
113
+ purpose="Center point arc defined by center=(x,y), start angle/point p1, end angle/point p2.",
114
+ requires=["sketch.active=true"],
115
+ next_steps=["sketch.dimension", "sketch.exit"],
116
+ status="working",
117
+ )
118
+ SKETCH_POLYGON = ToolSpec(
119
+ name="sketch.polygon",
120
+ purpose="Inscribed or circumscribed polygon with center=(x,y), radius=r, and sides=n (e.g. 6 for hexagon).",
121
+ requires=["sketch.active=true"],
122
+ next_steps=["sketch.dimension", "feature.extrude", "sketch.exit"],
123
+ status="working",
124
+ )
125
+ SKETCH_SPLINE = ToolSpec(
126
+ name="sketch.spline",
127
+ purpose="Spline through control points [(x1,y1), (x2,y2), ...].",
128
+ requires=["sketch.active=true"],
129
+ next_steps=["sketch.dimension", "sketch.exit"],
130
+ status="working",
131
+ )
132
+ SKETCH_POINT = ToolSpec(
133
+ name="sketch.point",
134
+ purpose="Single point at (x,y).",
135
+ requires=["sketch.active=true"],
136
+ next_steps=["sketch.constrain", "sketch.exit"],
137
+ status="working",
138
+ )
139
+ SKETCH_TEXT = ToolSpec(
140
+ name="sketch.text",
141
+ purpose="Text box defined by corner1=(x,y), corner2=(x,y), and text string.",
142
+ requires=["sketch.active=true"],
143
+ next_steps=["feature.extrude", "sketch.exit"],
144
+ status="working",
145
+ )
146
+ SKETCH_USE = ToolSpec(
147
+ name="sketch.use",
148
+ purpose="Project 3D edge or face onto active sketch plane.",
149
+ requires=["sketch.active=true"],
150
+ next_steps=["sketch.offset", "sketch.trim", "sketch.exit"],
151
+ status="working",
152
+ )
153
+
154
+ # Modifications & Operations
155
+ SKETCH_CONSTRUCTION = ToolSpec(
156
+ name="sketch.construction",
157
+ purpose="Toggle construction mode or convert entity at (x,y) to/from construction line ('q').",
158
+ requires=["sketch.active=true"],
159
+ next_steps=["sketch.line", "sketch.circle", "sketch.exit"],
160
+ status="working",
161
+ )
162
+ SKETCH_FILLET = ToolSpec(
163
+ name="sketch.fillet",
164
+ purpose="Create a 2D rounded fillet at vertex (x,y) or between two curves with radius_mm.",
165
+ requires=["sketch.active=true"],
166
+ next_steps=["sketch.dimension", "sketch.exit"],
167
+ status="working",
168
+ )
169
+ SKETCH_CHAMFER = ToolSpec(
170
+ name="sketch.chamfer",
171
+ purpose="Create a 2D chamfer at vertex (x,y) with distance_mm.",
172
+ requires=["sketch.active=true"],
173
+ next_steps=["sketch.dimension", "sketch.exit"],
174
+ status="working",
175
+ )
176
+ SKETCH_TRIM = ToolSpec(
177
+ name="sketch.trim",
178
+ purpose="Trim a curve segment at (x,y) back to the nearest intersections ('m').",
179
+ requires=["sketch.active=true"],
180
+ next_steps=["sketch.dimension", "sketch.exit"],
181
+ status="working",
182
+ )
183
+ SKETCH_EXTEND = ToolSpec(
184
+ name="sketch.extend",
185
+ purpose="Extend an endpoint at (x,y) forward to the next curve boundary ('x').",
186
+ requires=["sketch.active=true"],
187
+ next_steps=["sketch.trim", "sketch.exit"],
188
+ status="working",
189
+ )
190
+ SKETCH_SPLIT = ToolSpec(
191
+ name="sketch.split",
192
+ purpose="Split curve into two segments at coordinate (x,y).",
193
+ requires=["sketch.active=true"],
194
+ next_steps=["sketch.exit"],
195
+ status="working",
196
+ )
197
+ SKETCH_OFFSET = ToolSpec(
198
+ name="sketch.offset",
199
+ purpose="Offset entity at (x,y) by distance_mm to target side.",
200
+ requires=["sketch.active=true"],
201
+ next_steps=["sketch.dimension", "sketch.exit"],
202
+ status="working",
203
+ )
204
+ SKETCH_SLOT = ToolSpec(
205
+ name="sketch.slot",
206
+ purpose="Create an elongated slot along a line segment with a given width/radius.",
207
+ requires=["sketch.active=true"],
208
+ next_steps=["sketch.dimension", "sketch.exit"],
209
+ status="working",
210
+ )
211
+ SKETCH_MIRROR = ToolSpec(
212
+ name="sketch.mirror",
213
+ purpose="Mirror entities across a centerline (line_xy, entities=[(x,y), ...]).",
214
+ requires=["sketch.active=true"],
215
+ next_steps=["sketch.exit", "feature.extrude"],
216
+ status="working",
217
+ )
218
+ SKETCH_PATTERN_LINEAR = ToolSpec(
219
+ name="sketch.pattern_linear",
220
+ purpose="Linear pattern of entities along X and Y axes with counts and spacings.",
221
+ requires=["sketch.active=true"],
222
+ next_steps=["sketch.exit"],
223
+ status="working",
224
+ )
225
+ SKETCH_PATTERN_CIRCULAR = ToolSpec(
226
+ name="sketch.pattern_circular",
227
+ purpose="Circular pattern of entities around center=(x,y) with count and angle.",
228
+ requires=["sketch.active=true"],
229
+ next_steps=["sketch.exit"],
230
+ status="working",
231
+ )
232
+ SKETCH_TRANSFORM = ToolSpec(
233
+ name="sketch.transform",
234
+ purpose="Translate, scale, or rotate selected entities.",
235
+ requires=["sketch.active=true"],
236
+ next_steps=["sketch.exit"],
237
+ status="working",
238
+ )
239
+
240
+ # Dimensions & Constraints
241
+ SKETCH_DIMENSION = ToolSpec(
242
+ name="sketch.dimension",
243
+ purpose="Apply a driven linear, radial, or angular dimension (entity_xy, label_xy, value).",
244
+ requires=["sketch.active=true"],
245
+ next_steps=["sketch.exit", "feature.extrude"],
246
+ status="working",
247
+ )
248
+ CONSTRAINT_COINCIDENT = ToolSpec(
249
+ name="constraint.coincident",
250
+ purpose="Make two points coincident, or point coincident to a curve.",
251
+ requires=["sketch.active=true"],
252
+ next_steps=["sketch.dimension", "sketch.exit"],
253
+ status="working",
254
+ )
255
+ CONSTRAINT_CONCENTRIC = ToolSpec(
256
+ name="constraint.concentric",
257
+ purpose="Make two arcs or circles share the same center point.",
258
+ requires=["sketch.active=true"],
259
+ next_steps=["sketch.dimension", "sketch.exit"],
260
+ status="working",
261
+ )
262
+ CONSTRAINT_PARALLEL = ToolSpec(
263
+ name="constraint.parallel",
264
+ purpose="Make two lines parallel ('b').",
265
+ requires=["sketch.active=true"],
266
+ next_steps=["sketch.dimension", "sketch.exit"],
267
+ status="working",
268
+ )
269
+ CONSTRAINT_TANGENT = ToolSpec(
270
+ name="constraint.tangent",
271
+ purpose="Make an arc/circle tangent to a line or another arc ('t').",
272
+ requires=["sketch.active=true"],
273
+ next_steps=["sketch.dimension", "sketch.exit"],
274
+ status="working",
275
+ )
276
+ CONSTRAINT_HORIZONTAL = ToolSpec(
277
+ name="constraint.horizontal",
278
+ purpose="Make a line horizontal or align two points horizontally ('h').",
279
+ requires=["sketch.active=true"],
280
+ next_steps=["sketch.dimension", "sketch.exit"],
281
+ status="working",
282
+ )
283
+ CONSTRAINT_VERTICAL = ToolSpec(
284
+ name="constraint.vertical",
285
+ purpose="Make a line vertical or align two points vertically ('v').",
286
+ requires=["sketch.active=true"],
287
+ next_steps=["sketch.dimension", "sketch.exit"],
288
+ status="working",
289
+ )
290
+ CONSTRAINT_PERPENDICULAR = ToolSpec(
291
+ name="constraint.perpendicular",
292
+ purpose="Make two lines perpendicular (90 degrees).",
293
+ requires=["sketch.active=true"],
294
+ next_steps=["sketch.dimension", "sketch.exit"],
295
+ status="working",
296
+ )
297
+ CONSTRAINT_EQUAL = ToolSpec(
298
+ name="constraint.equal",
299
+ purpose="Constrain two lines to equal length, or two circles/arcs to equal radius ('e').",
300
+ requires=["sketch.active=true"],
301
+ next_steps=["sketch.dimension", "sketch.exit"],
302
+ status="working",
303
+ )
304
+ CONSTRAINT_MIDPOINT = ToolSpec(
305
+ name="constraint.midpoint",
306
+ purpose="Constrain a point to the midpoint of a line segment.",
307
+ requires=["sketch.active=true"],
308
+ next_steps=["sketch.dimension", "sketch.exit"],
309
+ status="working",
310
+ )
311
+ CONSTRAINT_NORMAL = ToolSpec(
312
+ name="constraint.normal",
313
+ purpose="Constrain a curve normal to a plane.",
314
+ requires=["sketch.active=true"],
315
+ next_steps=["sketch.exit"],
316
+ status="working",
317
+ )
318
+ CONSTRAINT_PIERCE = ToolSpec(
319
+ name="constraint.pierce",
320
+ purpose="Constrain a point to the pierce point of an intersecting 3D curve.",
321
+ requires=["sketch.active=true"],
322
+ next_steps=["sketch.exit"],
323
+ status="working",
324
+ )
325
+ CONSTRAINT_SYMMETRIC = ToolSpec(
326
+ name="constraint.symmetric",
327
+ purpose="Constrain two entities symmetrically across a centerline.",
328
+ requires=["sketch.active=true"],
329
+ next_steps=["sketch.exit"],
330
+ status="working",
331
+ )
332
+ CONSTRAINT_FIX = ToolSpec(
333
+ name="constraint.fix",
334
+ purpose="Fix an entity in place preventing solver movement.",
335
+ requires=["sketch.active=true"],
336
+ next_steps=["sketch.exit"],
337
+ status="working",
338
+ )
339
+
340
+ # Feature tools
341
+ FEATURE_EXTRUDE = ToolSpec(
342
+ name="feature.extrude",
343
+ purpose="Extrude the active sketch region by depth, optionally with draft/taper.",
344
+ requires=["sketch.active=false"],
345
+ next_steps=["feature.fillet", "feature.chamfer", "feature.shell", "feature.pattern"],
346
+ status="working",
347
+ )
348
+ FEATURE_REVOLVE = ToolSpec(
349
+ name="feature.revolve",
350
+ purpose="Revolve a sketch region around an axis by an angle.",
351
+ requires=["sketch.active=false"],
352
+ next_steps=["feature.fillet", "feature.chamfer"],
353
+ status="working",
354
+ )
355
+ FEATURE_FILLET = ToolSpec(
356
+ name="feature.fillet",
357
+ purpose="Round selected 3D edges with a constant radius.",
358
+ next_steps=["feature.chamfer", "feature.shell", "feature.pattern"],
359
+ status="working",
360
+ )
361
+ FEATURE_CHAMFER = ToolSpec(
362
+ name="feature.chamfer",
363
+ purpose="Bevel selected 3D edges with a distance.",
364
+ next_steps=["feature.fillet", "feature.shell", "feature.pattern"],
365
+ status="working",
366
+ )
367
+ FEATURE_SHELL = ToolSpec(
368
+ name="feature.shell",
369
+ purpose="Hollow a body, removing selected faces, with a wall thickness.",
370
+ next_steps=["feature.fillet", "feature.pattern"],
371
+ status="working",
372
+ )
373
+ FEATURE_PATTERN = ToolSpec(
374
+ name="feature.pattern",
375
+ purpose="Linear or circular pattern of features / faces / bodies.",
376
+ next_steps=["feature.mirror_body", "assembly.mate"],
377
+ status="working",
378
+ )
379
+ FEATURE_MIRROR_BODY = ToolSpec(
380
+ name="feature.mirror_body",
381
+ purpose="Mirror bodies across a plane in the part studio.",
382
+ next_steps=["feature.pattern", "assembly.mate"],
383
+ status="working",
384
+ )
385
+
386
+ # Selection and view
387
+ SELECT_FACE = ToolSpec(
388
+ name="select.face",
389
+ purpose="Click a face in the viewport at (x,y).",
390
+ next_steps=["sketch.start", "feature.extrude", "feature.fillet"],
391
+ status="working",
392
+ )
393
+ SELECT_EDGE = ToolSpec(
394
+ name="select.edge",
395
+ purpose="Click an edge in the viewport at (x,y).",
396
+ next_steps=["feature.fillet", "feature.chamfer"],
397
+ status="working",
398
+ )
399
+ SELECT_BODY = ToolSpec(
400
+ name="select.body",
401
+ purpose="Click a body in the viewport or feature tree.",
402
+ next_steps=["feature.mirror_body"],
403
+ status="working",
404
+ )
405
+ VIEW_FIT = ToolSpec(
406
+ name="view.fit",
407
+ purpose="Zoom to fit all visible geometry in viewport ('f').",
408
+ status="working",
409
+ )
410
+ VIEW_ROTATE = ToolSpec(
411
+ name="view.rotate",
412
+ purpose="Right-mouse drag from start to end to orbit the camera.",
413
+ status="working",
414
+ )
415
+ ASSEMBLY_MATE = ToolSpec(
416
+ name="assembly.mate",
417
+ purpose="Create a mate between two mate connectors.",
418
+ status="planned",
419
+ )
420
+ ASSEMBLY_PATTERN = ToolSpec(
421
+ name="assembly.pattern",
422
+ purpose="Pattern an instance in an assembly.",
423
+ status="planned",
424
+ )
425
+ DOC_OPEN = ToolSpec(
426
+ name="doc.open",
427
+ purpose="Navigate to an Onshape document by URL.",
428
+ status="working",
429
+ )
430
+ DOC_SAVE = ToolSpec(
431
+ name="doc.save",
432
+ purpose="No-op; Onshape auto-saves continuously.",
433
+ status="working",
434
+ )
435
+ UNDO = ToolSpec(
436
+ name="ui.undo",
437
+ purpose="Send the standard Undo shortcut (Ctrl/Cmd+Z).",
438
+ next_steps=["ui.redo", "view.fit"],
439
+ status="working",
440
+ )
441
+ REDO = ToolSpec(
442
+ name="ui.redo",
443
+ purpose="Send Redo (Ctrl/Cmd+Shift+Z).",
444
+ next_steps=["ui.undo", "view.fit"],
445
+ status="working",
446
+ )
447
+
448
+ ALL_TOOLS: list[ToolSpec] = [
449
+ # Lifecycle
450
+ SKETCH_START,
451
+ SKETCH_EXIT,
452
+ # Primitives
453
+ SKETCH_LINE,
454
+ SKETCH_LINE_MIDPOINT,
455
+ SKETCH_RECTANGLE,
456
+ SKETCH_RECTANGLE_CENTER,
457
+ SKETCH_RECTANGLE_ALIGNED,
458
+ SKETCH_CIRCLE,
459
+ SKETCH_CIRCLE_3POINT,
460
+ SKETCH_ELLIPSE,
461
+ SKETCH_ARC,
462
+ SKETCH_ARC_TANGENT,
463
+ SKETCH_ARC_CENTER,
464
+ SKETCH_POLYGON,
465
+ SKETCH_SPLINE,
466
+ SKETCH_POINT,
467
+ SKETCH_TEXT,
468
+ SKETCH_USE,
469
+ # Operations
470
+ SKETCH_CONSTRUCTION,
471
+ SKETCH_FILLET,
472
+ SKETCH_CHAMFER,
473
+ SKETCH_TRIM,
474
+ SKETCH_EXTEND,
475
+ SKETCH_SPLIT,
476
+ SKETCH_OFFSET,
477
+ SKETCH_SLOT,
478
+ SKETCH_MIRROR,
479
+ SKETCH_PATTERN_LINEAR,
480
+ SKETCH_PATTERN_CIRCULAR,
481
+ SKETCH_TRANSFORM,
482
+ # Dimensions & Constraints
483
+ SKETCH_DIMENSION,
484
+ CONSTRAINT_COINCIDENT,
485
+ CONSTRAINT_CONCENTRIC,
486
+ CONSTRAINT_PARALLEL,
487
+ CONSTRAINT_TANGENT,
488
+ CONSTRAINT_HORIZONTAL,
489
+ CONSTRAINT_VERTICAL,
490
+ CONSTRAINT_PERPENDICULAR,
491
+ CONSTRAINT_EQUAL,
492
+ CONSTRAINT_MIDPOINT,
493
+ CONSTRAINT_NORMAL,
494
+ CONSTRAINT_PIERCE,
495
+ CONSTRAINT_SYMMETRIC,
496
+ CONSTRAINT_FIX,
497
+ # Features
498
+ FEATURE_EXTRUDE,
499
+ FEATURE_REVOLVE,
500
+ FEATURE_FILLET,
501
+ FEATURE_CHAMFER,
502
+ FEATURE_SHELL,
503
+ FEATURE_PATTERN,
504
+ FEATURE_MIRROR_BODY,
505
+ SELECT_FACE,
506
+ SELECT_EDGE,
507
+ SELECT_BODY,
508
+ VIEW_FIT,
509
+ VIEW_ROTATE,
510
+ ASSEMBLY_MATE,
511
+ ASSEMBLY_PATTERN,
512
+ DOC_OPEN,
513
+ DOC_SAVE,
514
+ UNDO,
515
+ REDO,
516
+ ]
517
+
518
+
519
+ def as_prompt_block() -> str:
520
+ """Render the datasheet as a compact prompt block for the LLM."""
521
+ lines = ["# Onshape tool vocabulary", ""]
522
+ for t in ALL_TOOLS:
523
+ bits = [f"**{t.name}** {t.purpose}"]
524
+ if t.requires:
525
+ bits.append(f" requires: {', '.join(t.requires)}")
526
+ if t.produces:
527
+ bits.append(f" produces: {', '.join(t.produces)}")
528
+ if t.next_steps:
529
+ bits.append(f" next: {', '.join(t.next_steps)}")
530
+ bits.append(f" status: {t.status}")
531
+ lines.extend(bits)
532
+ return "\n".join(lines)
533
+
534
+
535
+ def lookup(name: str) -> ToolSpec:
536
+ for t in ALL_TOOLS:
537
+ if t.name == name:
538
+ return t
539
+ raise KeyError(f"Unknown tool: {name}")
src/onshape_mcp/tunnel.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Automatic web tunnel manager for Onshape MCP.
2
+ Spawns the local SSE server and exposes a secure public HTTPS endpoint
3
+ (via Cloudflare Quick Tunnels or Localtunnel) with zero configuration.
4
+
5
+ Allows web AIs (ChatGPT Web, LibreChat, Open WebUI) to connect instantly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from typing import Any
18
+
19
+ from .config import settings
20
+
21
+
22
+ def find_tunnel_binary() -> tuple[str, list[str]] | None:
23
+ """Find available tunnel tool (cloudflared or localtunnel)."""
24
+ # 1. Cloudflare tunnel (no sign-up, fast, reliable)
25
+ cf = shutil.which("cloudflared")
26
+ if cf:
27
+ return ("cloudflared", [cf, "tunnel", "--url"])
28
+
29
+ # 2. Localtunnel via npx
30
+ npx = shutil.which("npx")
31
+ if npx:
32
+ return ("localtunnel", [npx, "-y", "localtunnel", "--port"])
33
+
34
+ return None
35
+
36
+
37
+ def run_tunnel_and_server(port: int = 8000, host: str = "127.0.0.1") -> None:
38
+ """Start the FastMCP SSE server and attach an auto-tunnel for web AIs."""
39
+ tunnel_info = find_tunnel_binary()
40
+ if not tunnel_info:
41
+ print("=" * 68)
42
+ print("⚠ No tunneling tool (cloudflared or npx) found on your system.")
43
+ print("To enable instant web sharing, install cloudflared:")
44
+ print(" • Mac: brew install cloudflared")
45
+ print(" • Linux: sudo apt install cloudflared (or download binary)")
46
+ print(" • Windows: winget install Cloudflare.cloudflared")
47
+ print("=" * 68)
48
+ # Fallback to local SSE only
49
+ from .server import mcp
50
+ mcp.settings.host = host
51
+ mcp.settings.port = port
52
+ print(f"Starting local SSE server at http://{host}:{port}/sse ...")
53
+ mcp.run(transport="sse")
54
+ return
55
+
56
+ tunnel_type, tunnel_cmd = tunnel_info
57
+ local_url = f"http://{host}:{port}"
58
+
59
+ print("=" * 68)
60
+ print(" 🌐 Onshape MCP — Instant Web Sharing & ChatGPT Connector")
61
+ print("=" * 68)
62
+ print(f"Starting local SSE engine on port {port}...")
63
+
64
+ # Start the tunnel process in the background
65
+ if tunnel_type == "cloudflared":
66
+ cmd = [*tunnel_cmd, local_url]
67
+ else:
68
+ cmd = [*tunnel_cmd, str(port)]
69
+
70
+ tunnel_proc = subprocess.Popen(
71
+ cmd,
72
+ stdout=subprocess.PIPE,
73
+ stderr=subprocess.PIPE,
74
+ text=True,
75
+ )
76
+
77
+ public_url: str | None = None
78
+ start_time = time.time()
79
+
80
+ # Read output to capture the public URL
81
+ def _read_stream(stream: Any) -> None:
82
+ nonlocal public_url
83
+ if not stream:
84
+ return
85
+ for line in iter(stream.readline, ""):
86
+ if not line:
87
+ break
88
+ # Cloudflare pattern
89
+ m_cf = re.search(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com", line)
90
+ if m_cf:
91
+ public_url = m_cf.group(0)
92
+ break
93
+ # Localtunnel pattern
94
+ m_lt = re.search(r"https://[a-zA-Z0-9-]+\.loca\.lt", line)
95
+ if m_lt:
96
+ public_url = m_lt.group(0)
97
+ break
98
+
99
+ t_err = threading.Thread(target=_read_stream, args=(tunnel_proc.stderr,), daemon=True)
100
+ t_out = threading.Thread(target=_read_stream, args=(tunnel_proc.stdout,), daemon=True)
101
+ t_err.start()
102
+ t_out.start()
103
+
104
+ print("Requesting secure HTTPS tunnel from Cloudflare...")
105
+ while public_url is None and (time.time() - start_time < 15):
106
+ time.sleep(0.5)
107
+
108
+ if public_url:
109
+ sse_url = f"{public_url.rstrip('/')}/sse"
110
+ print("\n" + "=" * 68)
111
+ print("🎉 SUCCESS! Your Onshape MCP is live on the internet:")
112
+ print(f"\n👉 MCP SSE URL: \033[1;32m{sse_url}\033[0m\n")
113
+ print("How to use this link:")
114
+ print("1. In ChatGPT Web: Add this link to your MCP / Custom GPT connector.")
115
+ print("2. In LibreChat / Open WebUI: Add this link under MCP servers.")
116
+ print("3. Press Ctrl+C in this terminal when you want to disconnect.")
117
+ print("=" * 68 + "\n")
118
+ else:
119
+ print("⚠ Tunnel did not return a public URL in 15s. Running locally.")
120
+ print(f"Local endpoint: http://{host}:{port}/sse\n")
121
+
122
+ # Now run the SSE server in the main thread
123
+ from .server import mcp
124
+ mcp.settings.host = host
125
+ mcp.settings.port = port
126
+
127
+ try:
128
+ mcp.run(transport="sse")
129
+ finally:
130
+ tunnel_proc.terminate()
131
+ tunnel_proc.wait()
src/onshape_mcp/ui_actions.py ADDED
@@ -0,0 +1,1216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """High-level Onshape operations. Each public function corresponds to a
2
+ datasheet tool. Uses driver.py primitives + shortcuts.py bindings, journals
3
+ to journal.py. Raises before touching the UI if a precondition isn't met.
4
+
5
+ Conventions: every function takes the driver explicitly (no globals), returns
6
+ a `Result` dict, and uses viewport pixel coords. Call `driver.viewport_box()`
7
+ to get bounds.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .driver import OnshapeDriver
18
+ from .journal import JournalEntry, journal
19
+ from .shortcuts import get as binding_for
20
+
21
+
22
+ @dataclass
23
+ class Result:
24
+ ok: bool
25
+ note: str = ""
26
+ screenshot: Path | None = None
27
+ extra: dict[str, Any] | None = None
28
+
29
+ @property
30
+ def summary(self) -> str:
31
+ return self.note
32
+
33
+ @property
34
+ def meta(self) -> dict[str, Any]:
35
+ return self.extra or {}
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ d: dict[str, Any] = {"ok": self.ok, "note": self.note}
39
+ if self.screenshot is not None:
40
+ d["screenshot"] = str(self.screenshot)
41
+ if self.extra:
42
+ d.update(self.extra)
43
+ return d
44
+
45
+
46
+ def _record(tool: str, args: dict[str, Any], result: Result) -> None:
47
+ e = JournalEntry.new(tool, args)
48
+ e.result = "ok" if result.ok else "fail"
49
+ e.note = result.note
50
+ if result.screenshot is not None:
51
+ try:
52
+ e.screenshot = str(result.screenshot.relative_to(result.screenshot.parent.parent))
53
+ except Exception:
54
+ e.screenshot = str(result.screenshot)
55
+ journal.append(e)
56
+
57
+
58
+ # View and camera
59
+
60
+
61
+ async def view_fit(d: OnshapeDriver) -> Result:
62
+ b = binding_for("view.fit")
63
+ if b.keys:
64
+ await d.press_chord(*b.keys)
65
+ elif b.toolbar_text:
66
+ ok = await d.click_text(b.toolbar_text)
67
+ if not ok:
68
+ return Result(False, f"view.fit: toolbar button {b.toolbar_text!r} not found")
69
+ shot = await d.screenshot("view_fit.png")
70
+ r = Result(True, "fit to view", shot)
71
+ _record("view.fit", {}, r)
72
+ return r
73
+
74
+
75
+ async def view_top(d: OnshapeDriver) -> Result:
76
+ b = binding_for("view.top")
77
+ if b.keys:
78
+ await d.press_chord(*b.keys)
79
+ shot = await d.screenshot("view_top.png")
80
+ r = Result(True, "top view", shot)
81
+ _record("view.top", {}, r)
82
+ return r
83
+
84
+
85
+ async def view_front(d: OnshapeDriver) -> Result:
86
+ b = binding_for("view.front")
87
+ if b.keys:
88
+ await d.press_chord(*b.keys)
89
+ shot = await d.screenshot("view_front.png")
90
+ r = Result(True, "front view", shot)
91
+ _record("view.front", {}, r)
92
+ return r
93
+
94
+
95
+ async def view_iso(d: OnshapeDriver) -> Result:
96
+ b = binding_for("view.iso")
97
+ if b.keys:
98
+ await d.press_chord(*b.keys)
99
+ shot = await d.screenshot("view_isometric.png")
100
+ r = Result(True, "isometric view", shot)
101
+ _record("view.iso", {}, r)
102
+ return r
103
+
104
+
105
+ view_isometric = view_iso
106
+
107
+ # Sketching
108
+
109
+
110
+ async def sketch_start(
111
+ d: OnshapeDriver,
112
+ plane_xy: tuple[float, float] | None = None,
113
+ plane_name: str | None = None,
114
+ ) -> Result:
115
+ """Click the Sketch button, then click a plane (or pass a coordinate/name).
116
+
117
+ If `plane_xy` is None, we select the desired plane in the feature tree
118
+ using DOM locator (default: Top plane) and press 'n' to orient normal to the sketch plane.
119
+ """
120
+ sketch_btn = d.page.locator("[command-id='newSketch']")
121
+ if await sketch_btn.count() > 0:
122
+ await sketch_btn.first.click()
123
+ else:
124
+ b = binding_for("sketch.start")
125
+ clicked = False
126
+ if b.toolbar_text:
127
+ clicked = await d.click_text(b.toolbar_text, timeout_ms=500)
128
+ if not clicked:
129
+ if b.keys:
130
+ await d.press_chord(*b.keys)
131
+ else:
132
+ await d.click(155.0, 58.0)
133
+ # small settle delay for the UI to switch into plane-pick mode
134
+ await asyncio.sleep(0.3)
135
+
136
+ target_name = (plane_name or "Top").capitalize()
137
+ if plane_xy is not None:
138
+ await d.click(*plane_xy)
139
+ await asyncio.sleep(0.4)
140
+ try:
141
+ await d.page.locator("canvas").first.hover()
142
+ except Exception:
143
+ pass
144
+ await d.press_key("n")
145
+ else:
146
+ # Use exact DOM locator for feature tree plane
147
+ loc = d.page.locator("span.os-list-item-name", has_text=target_name)
148
+ if await loc.count() > 0:
149
+ await loc.first.click()
150
+ await asyncio.sleep(0.3)
151
+ # Deterministically orient normal to the selected plane via context menu
152
+ try:
153
+ await loc.first.click(button="right")
154
+ await asyncio.sleep(0.3)
155
+ normal_item = d.page.locator(
156
+ "div.context-menu-item-text", has_text="View normal to"
157
+ )
158
+ if await normal_item.count() > 0:
159
+ await normal_item.first.click()
160
+ else:
161
+ await d.press_key("n")
162
+ except Exception:
163
+ await d.press_key("n")
164
+ else:
165
+ await d.click(65.0, 232.0)
166
+ await asyncio.sleep(0.4)
167
+ await d.press_key("n")
168
+ await asyncio.sleep(1.2)
169
+ shot = await d.screenshot("sketch_start.png")
170
+ r = Result(True, f"sketch started on {target_name}", shot, {"plane": target_name})
171
+ _record("sketch.start", {"plane": target_name}, r)
172
+ return r
173
+
174
+
175
+ SKETCH_COMMAND_IDS: dict[str, str] = {
176
+ "sketch.start": "newSketch",
177
+ "sketch.line": "LINESEGMENT",
178
+ "sketch.line_midpoint": "LINESEGMENT_MIDPOINT",
179
+ "sketch.rectangle": "RECTANGLE_TWO_CORNERS",
180
+ "sketch.rectangle_center": "RECTANGLE_CENTER_CORNER",
181
+ "sketch.rectangle_aligned": "ALIGNED_RECTANGLE",
182
+ "sketch.circle": "CIRCLE_CENTER_RADIUS",
183
+ "sketch.circle_3point": "CIRCLE_THREE_POINTS",
184
+ "sketch.ellipse": "ELLIPSE",
185
+ "sketch.arc": "ARC_START_END_RADIUS",
186
+ "sketch.arc_3point": "ARC_START_END_RADIUS",
187
+ "sketch.arc_tangent": "ARC_TANGENT",
188
+ "sketch.arc_center": "ARC_CENTER_START_END",
189
+ "sketch.polygon": "INSCRIBED_POLYGON",
190
+ "sketch.polygon_inscribed": "INSCRIBED_POLYGON",
191
+ "sketch.polygon_circumscribed": "CIRCUMSCRIBED_POLYGON",
192
+ "sketch.spline": "SPLINE",
193
+ "sketch.bezier": "BEZIER",
194
+ "sketch.point": "POINT",
195
+ "sketch.text": "TEXT_RECTANGLE_TWO_CORNERS",
196
+ "sketch.use": "USE",
197
+ "sketch.intersection": "INTERSECTION",
198
+ "sketch.construction": "TOGGLE_CONSTRUCTION",
199
+ "sketch.fillet": "FILLET",
200
+ "sketch.chamfer": "SKETCH_CHAMFER",
201
+ "sketch.trim": "TRIM",
202
+ "sketch.extend": "EXTEND",
203
+ "sketch.split": "SPLIT",
204
+ "sketch.offset": "OFFSET",
205
+ "sketch.slot": "SLOT",
206
+ "sketch.mirror": "SKETCHMIRROR",
207
+ "sketch.pattern_linear": "SKETCHLPATTERN",
208
+ "sketch.pattern_circular": "SKETCHCPATTERN",
209
+ "sketch.transform": "SKETCH_TRANSFORM",
210
+ "sketch.dimension": "DIMENSION",
211
+ # Constraints
212
+ "sketch.equal": "EQUAL",
213
+ "constraint.coincident": "COINCIDENT",
214
+ "constraint.concentric": "CONCENTRIC",
215
+ "constraint.parallel": "PARALLEL",
216
+ "constraint.tangent": "TANGENT",
217
+ "constraint.horizontal": "HORIZONTAL",
218
+ "constraint.vertical": "VERTICAL",
219
+ "constraint.perpendicular": "PERPENDICULAR",
220
+ "constraint.equal": "EQUAL",
221
+ "constraint.midpoint": "MIDPOINT",
222
+ "constraint.normal": "NORMAL",
223
+ "constraint.pierce": "PIERCE",
224
+ "constraint.symmetric": "MIRROR",
225
+ "constraint.fix": "FIX",
226
+ "constraint.curvature": "CURVATURE",
227
+ # 3D Features
228
+ "feature.extrude": "extrude",
229
+ "feature.revolve": "revolve",
230
+ }
231
+
232
+
233
+ async def _activate_sketch_tool(d: OnshapeDriver, name: str) -> Result:
234
+ """Activate a sketch tool via DOM command-id, keyboard shortcut, or text locator."""
235
+ # 1. Try keyboard shortcut first if available (fastest, most reliable)
236
+ b = binding_for(name)
237
+ if b.keys:
238
+ await d.press_chord(*b.keys)
239
+ await asyncio.sleep(0.2)
240
+ return Result(True, f"{name} tool active (keys: {b.keys})")
241
+
242
+ # 2. Try direct DOM command-id locator
243
+ cmd_id = SKETCH_COMMAND_IDS.get(name)
244
+ if cmd_id:
245
+ loc = d.page.locator(f"[command-id='{cmd_id}']")
246
+ if await loc.count() > 0 and await loc.first.is_visible():
247
+ classes = await loc.first.get_attribute("class") or ""
248
+ if "is-active" in classes:
249
+ return Result(True, f"{name} already active")
250
+ await loc.first.click()
251
+ await asyncio.sleep(0.25)
252
+ return Result(True, f"{name} tool active (command-id: {cmd_id})")
253
+
254
+ # 3. Fall back to visible toolbar text
255
+ if b.toolbar_text:
256
+ clicked = await d.click_text(b.toolbar_text, timeout_ms=1000)
257
+ if clicked:
258
+ await asyncio.sleep(0.2)
259
+ return Result(True, f"{name} tool active (text: {b.toolbar_text})")
260
+
261
+ return Result(False, f"{name}: no binding or visible tool button available")
262
+
263
+
264
+ def _parse_dim_px(val: float | str | None, default_px: float = 120.0) -> float:
265
+ if val is None:
266
+ return default_px
267
+ try:
268
+ s = str(val).strip().lower()
269
+ num = float("".join(c for c in s if c.isdigit() or c == "."))
270
+ if "cm" in s:
271
+ px = num * 32.583
272
+ elif "mm" in s:
273
+ px = num * 3.2583
274
+ elif "in" in s or "inch" in s:
275
+ px = num * 82.75
276
+ elif num <= 25:
277
+ px = num * 32.583
278
+ else:
279
+ px = num * 3.2583
280
+ return max(40.0, min(550.0, px))
281
+ except Exception:
282
+ return default_px
283
+
284
+
285
+ async def get_canvas_origin(d: OnshapeDriver) -> tuple[float, float]:
286
+ """Calculate the exact center point (origin) of the WebGL canvas.
287
+ Takes into account the left feature panel (246px) and toolbar (76px).
288
+ """
289
+ try:
290
+ box = await d.page.evaluate("""() => {
291
+ const c = document.querySelector('canvas.os-main-canvas') || document.querySelector('canvas');
292
+ if (!c) return {cx: 843.0, cy: 473.0};
293
+ const r = c.getBoundingClientRect();
294
+ return {cx: r.x + r.width / 2.0, cy: r.y + r.height / 2.0};
295
+ }""")
296
+ return (float(box.get("cx", 843.0)), float(box.get("cy", 473.0)))
297
+ except Exception:
298
+ return (843.0, 473.0)
299
+
300
+
301
+ async def _ensure_viewport_coords(d: OnshapeDriver, pt: tuple[float, float]) -> tuple[float, float]:
302
+ x, y = pt
303
+ cx, cy = await get_canvas_origin(d)
304
+ # If pt is (0, 0), return exact origin
305
+ if x == 0 and y == 0:
306
+ return (cx, cy)
307
+ # If small coordinates (CAD units relative to origin), convert to screen pixels:
308
+ # In CAD space, +X is right, +Y is up (so screen Y is cy - y*scale)
309
+ if abs(x) <= 200 and abs(y) <= 200:
310
+ scale = 10.0 if (abs(x) <= 25 and abs(y) <= 25) else 1.0
311
+ return (cx + x * scale, cy - y * scale)
312
+ return (x, y)
313
+
314
+
315
+ async def sketch_rectangle(
316
+ d: OnshapeDriver,
317
+ corner1: tuple[float, float] | None = None,
318
+ corner2: tuple[float, float] | None = None,
319
+ width: float | str | None = None,
320
+ height: float | str | None = None,
321
+ quadrant: str | int | None = None,
322
+ centered: bool | None = None,
323
+ ) -> Result:
324
+ cx, cy = await get_canvas_origin(d)
325
+ span_x = _parse_dim_px(width, default_px=120.0)
326
+ span_y = _parse_dim_px(height, default_px=120.0)
327
+
328
+ if centered:
329
+ corner1 = (cx - span_x / 2.0, cy - span_y / 2.0)
330
+ corner2 = (cx + span_x / 2.0, cy + span_y / 2.0)
331
+ elif quadrant is not None:
332
+ q = str(quadrant).upper().strip()
333
+ if q in ("1", "I", "TOP-RIGHT", "NE"):
334
+ corner1 = (cx, cy)
335
+ corner2 = (cx + span_x, cy - span_y)
336
+ elif q in ("2", "II", "TOP-LEFT", "NW"):
337
+ corner1 = (cx, cy)
338
+ corner2 = (cx - span_x, cy - span_y)
339
+ elif q in ("3", "III", "BOTTOM-LEFT", "SW"):
340
+ corner1 = (cx, cy)
341
+ corner2 = (cx - span_x, cy + span_y)
342
+ elif q in ("4", "IV", "BOTTOM-RIGHT", "SE"):
343
+ corner1 = (cx, cy)
344
+ corner2 = (cx + span_x, cy + span_y)
345
+ elif corner1 is None and corner2 is None:
346
+ corner1 = (cx - span_x / 2.0, cy - span_y / 2.0)
347
+ corner2 = (cx + span_x / 2.0, cy + span_y / 2.0)
348
+
349
+ c1 = await _ensure_viewport_coords(
350
+ d, corner1 if corner1 is not None else (cx - 70.0, cy - 70.0)
351
+ )
352
+ c2 = await _ensure_viewport_coords(
353
+ d, corner2 if corner2 is not None else (cx + 70.0, cy + 70.0)
354
+ )
355
+
356
+ t = await _activate_sketch_tool(d, "sketch.rectangle")
357
+ if not t.ok:
358
+ _record("sketch.rectangle", {"corner1": list(c1), "corner2": list(c2)}, t)
359
+ return t
360
+ await d.click(*c1)
361
+ await asyncio.sleep(0.3)
362
+ await d.click(*c2)
363
+ await asyncio.sleep(0.3)
364
+ await d.press_key("Escape")
365
+ await asyncio.sleep(0.3)
366
+
367
+ min_x, max_x = min(c1[0], c2[0]), max(c1[0], c2[0])
368
+ min_y, max_y = min(c1[1], c2[1]), max(c1[1], c2[1])
369
+ # Pick points at 30% along edges to safely avoid datum axis lines and origin glyphs
370
+ dx = max_x - min_x
371
+ dy = max_y - min_y
372
+ top_pick = (min_x + dx * 0.3, min_y)
373
+ left_pick = (min_x, min_y + dy * 0.3)
374
+ top_label = (top_pick[0], top_pick[1] - 45.0)
375
+ left_label = (left_pick[0] - 55.0, left_pick[1])
376
+
377
+ # If width or height are specified, apply constraints and dimensions
378
+ if width is not None and height is not None and str(width).strip() == str(height).strip():
379
+ # Square: apply Equal constraint first so geometry is strictly equilateral
380
+ await sketch_equal(d, top_pick, left_pick)
381
+ await asyncio.sleep(0.5)
382
+ # Dimension top edge
383
+ await sketch_dimension(d, top_pick, top_label, width)
384
+ elif width is not None:
385
+ await sketch_dimension(d, top_pick, top_label, width)
386
+ if height is not None:
387
+ await asyncio.sleep(0.6)
388
+ await sketch_dimension(d, left_pick, left_label, height)
389
+ elif height is not None:
390
+ await sketch_dimension(d, left_pick, left_label, height)
391
+
392
+ shot = await d.screenshot("sketch_rectangle.png")
393
+ meta = {
394
+ "corner1": list(c1),
395
+ "corner2": list(c2),
396
+ "last_vertex": list(c2),
397
+ "width": width,
398
+ "height": height,
399
+ "quadrant": quadrant,
400
+ "centered": centered,
401
+ }
402
+ r = Result(True, f"rectangle {c1} -> {c2} (dim={width}x{height})", shot, meta)
403
+ _record("sketch.rectangle", meta, r)
404
+ return r
405
+
406
+
407
+ async def sketch_circle(
408
+ d: OnshapeDriver,
409
+ center: tuple[float, float] | None = None,
410
+ radius_px: float = 50.0,
411
+ centered: bool | None = None,
412
+ ) -> Result:
413
+ t = await _activate_sketch_tool(d, "sketch.circle")
414
+ if not t.ok:
415
+ _record(
416
+ "sketch.circle", {"center": list(center) if center else None, "radius_px": radius_px}, t
417
+ )
418
+ return t
419
+ cx, cy = await get_canvas_origin(d)
420
+ if centered or center is None or center == (0, 0):
421
+ c = (cx, cy)
422
+ else:
423
+ c = await _ensure_viewport_coords(d, center)
424
+ r_px = radius_px if radius_px > 10 else radius_px * 15.0
425
+ await d.click(*c)
426
+ await asyncio.sleep(0.1)
427
+ await d.click(c[0] + r_px, c[1])
428
+ await asyncio.sleep(0.1)
429
+ await d.press_key("Escape")
430
+ shot = await d.screenshot("sketch_circle.png")
431
+ r = Result(True, f"circle center={c} r={r_px}", shot)
432
+ _record("sketch.circle", {"center": list(c), "radius_px": r_px}, r)
433
+ return r
434
+
435
+
436
+ async def sketch_line(
437
+ d: OnshapeDriver,
438
+ p1: tuple[float, float],
439
+ p2: tuple[float, float],
440
+ ) -> Result:
441
+ t = await _activate_sketch_tool(d, "sketch.line")
442
+ if not t.ok:
443
+ _record("sketch.line", {"p1": list(p1), "p2": list(p2)}, t)
444
+ return t
445
+ pt1 = await _ensure_viewport_coords(d, p1)
446
+ pt2 = await _ensure_viewport_coords(d, p2)
447
+ await d.click(*pt1)
448
+ await asyncio.sleep(0.05)
449
+ await d.click(*pt2)
450
+ await asyncio.sleep(0.05)
451
+ await d.press_key("Escape")
452
+ shot = await d.screenshot("sketch_line.png")
453
+ r = Result(True, f"line {pt1} -> {pt2}", shot)
454
+ _record("sketch.line", {"p1": list(pt1), "p2": list(pt2)}, r)
455
+ return r
456
+
457
+
458
+ async def sketch_dimension(
459
+ d: OnshapeDriver,
460
+ entity_xy: tuple[float, float],
461
+ label_xy: tuple[float, float],
462
+ value_mm: float | str,
463
+ ) -> Result:
464
+ """Add or set a dimension. Click the entity at `entity_xy`, place the
465
+ dimension label at `label_xy`, then type the new value + Enter.
466
+
467
+ This is the workhorse of the "make it 5x5" workflow. Draw any
468
+ rectangle, then call this twice (once for each side) with the
469
+ target mm value. The LLM doesn't need to know the px-to-mm ratio
470
+ because Onshape's solver does the conversion.
471
+ """
472
+ t = await _activate_sketch_tool(d, "sketch.dimension")
473
+ if not t.ok:
474
+ return t
475
+ await asyncio.sleep(0.4)
476
+ e_xy = await _ensure_viewport_coords(d, entity_xy)
477
+ l_xy = await _ensure_viewport_coords(d, label_xy)
478
+ # Click the entity (line/edge/circle) to dimension
479
+ await d.page.mouse.move(*e_xy)
480
+ await asyncio.sleep(0.15)
481
+ await d.page.mouse.click(*e_xy)
482
+ await asyncio.sleep(0.4)
483
+ # Click where to place the dimension label
484
+ await d.page.mouse.move(*l_xy)
485
+ await asyncio.sleep(0.15)
486
+ await d.page.mouse.click(*l_xy)
487
+ await asyncio.sleep(0.4)
488
+ # Type the new value into input.os-canvas-text-edit if present or directly
489
+ val_str = f"{value_mm} mm" if isinstance(value_mm, (int, float)) else str(value_mm)
490
+ dim_input = d.page.locator("input.os-canvas-text-edit")
491
+ if await dim_input.count() > 0:
492
+ await dim_input.first.fill(val_str)
493
+ await asyncio.sleep(0.1)
494
+ await d.page.keyboard.press("Enter")
495
+ await asyncio.sleep(0.4)
496
+ else:
497
+ await d.type_text(val_str)
498
+ await asyncio.sleep(0.1)
499
+ await d.press_key("Enter")
500
+ await asyncio.sleep(0.4)
501
+ # Esc to drop the dimension tool, stay in sketch
502
+ await d.press_key("Escape")
503
+ shot = await d.screenshot("sketch_dimension.png")
504
+ r = Result(
505
+ True,
506
+ f"dimensioned {val_str} at {e_xy}",
507
+ shot,
508
+ {"value": val_str},
509
+ )
510
+ _record(
511
+ "sketch.dimension",
512
+ {"entity_xy": list(e_xy), "label_xy": list(l_xy), "value": val_str},
513
+ r,
514
+ )
515
+ return r
516
+
517
+
518
+ async def sketch_equal(d: OnshapeDriver, *entity_xys: tuple[float, float]) -> Result:
519
+ """Apply the Equal constraint to make two or more entities the same size.
520
+
521
+ Workflow: activate Equal tool, click each entity in turn, press Esc.
522
+ The LLM passes N coordinates (each on an entity to be made equal).
523
+ """
524
+ if len(entity_xys) < 2:
525
+ return Result(False, "sketch.equal needs at least 2 entities")
526
+ t = await _activate_sketch_tool(d, "sketch.equal")
527
+ if not t.ok:
528
+ return t
529
+ await asyncio.sleep(0.4)
530
+ for xy in entity_xys:
531
+ pt = await _ensure_viewport_coords(d, xy)
532
+ await d.page.mouse.move(*pt)
533
+ await asyncio.sleep(0.15)
534
+ await d.page.mouse.click(*pt)
535
+ await asyncio.sleep(0.3)
536
+ await d.press_key("Escape")
537
+ await asyncio.sleep(0.4)
538
+ shot = await d.screenshot("sketch_equal.png")
539
+ r = Result(True, f"equal across {len(entity_xys)} entities", shot)
540
+ _record("sketch.equal", {"entities": [list(xy) for xy in entity_xys]}, r)
541
+ return r
542
+
543
+
544
+ async def sketch_arc(
545
+ d: OnshapeDriver,
546
+ p1: tuple[float, float],
547
+ p2: tuple[float, float],
548
+ radius_pt: tuple[float, float] | None = None,
549
+ ) -> Result:
550
+ """Create a 3-point arc from p1 to p2 passing through radius_pt."""
551
+ t = await _activate_sketch_tool(d, "sketch.arc")
552
+ if not t.ok:
553
+ return t
554
+ pt1 = await _ensure_viewport_coords(d, p1)
555
+ pt2 = await _ensure_viewport_coords(d, p2)
556
+ if radius_pt is not None:
557
+ pt3 = await _ensure_viewport_coords(d, radius_pt)
558
+ else:
559
+ # Default arc bulge perpendicular to chord
560
+ mx, my = (pt1[0] + pt2[0]) / 2.0, (pt1[1] + pt2[1]) / 2.0
561
+ pt3 = (mx, my - 30.0)
562
+ await d.click(*pt1)
563
+ await asyncio.sleep(0.1)
564
+ await d.click(*pt2)
565
+ await asyncio.sleep(0.1)
566
+ await d.click(*pt3)
567
+ await asyncio.sleep(0.1)
568
+ await d.press_key("Escape")
569
+ shot = await d.screenshot("sketch_arc.png")
570
+ r = Result(True, f"arc {pt1} -> {pt2} through {pt3}", shot)
571
+ _record("sketch.arc", {"p1": list(pt1), "p2": list(pt2), "radius_pt": list(pt3)}, r)
572
+ return r
573
+
574
+
575
+ async def sketch_polygon(
576
+ d: OnshapeDriver,
577
+ center: tuple[float, float] | None = None,
578
+ radius: float = 60.0,
579
+ sides: int = 6,
580
+ circumscribed: bool = False,
581
+ ) -> Result:
582
+ """Draw an inscribed or circumscribed polygon."""
583
+ tool_name = "sketch.polygon_circumscribed" if circumscribed else "sketch.polygon_inscribed"
584
+ t = await _activate_sketch_tool(d, tool_name)
585
+ if not t.ok:
586
+ t = await _activate_sketch_tool(d, "sketch.polygon")
587
+ if not t.ok:
588
+ return t
589
+ cx, cy = await get_canvas_origin(d)
590
+ c = await _ensure_viewport_coords(d, center) if center is not None else (cx, cy)
591
+ r_px = _parse_dim_px(radius, default_px=60.0)
592
+ await d.click(*c)
593
+ await asyncio.sleep(0.15)
594
+ await d.click(c[0] + r_px, c[1])
595
+ await asyncio.sleep(0.2)
596
+ # Type number of sides
597
+ await d.type_text(str(int(sides)))
598
+ await asyncio.sleep(0.1)
599
+ await d.press_key("Enter")
600
+ await asyncio.sleep(0.2)
601
+ await d.press_key("Escape")
602
+ shot = await d.screenshot("sketch_polygon.png")
603
+ r = Result(True, f"{sides}-sided polygon at {c} r={r_px}", shot)
604
+ _record("sketch.polygon", {"center": list(c), "radius": r_px, "sides": sides}, r)
605
+ return r
606
+
607
+
608
+ async def sketch_spline(d: OnshapeDriver, points: list[tuple[float, float]]) -> Result:
609
+ """Draw a spline passing through a sequence of points."""
610
+ if len(points) < 2:
611
+ return Result(False, "sketch.spline requires at least 2 points")
612
+ t = await _activate_sketch_tool(d, "sketch.spline")
613
+ if not t.ok:
614
+ return t
615
+ pts = [await _ensure_viewport_coords(d, p) for p in points]
616
+ for pt in pts:
617
+ await d.click(*pt)
618
+ await asyncio.sleep(0.15)
619
+ await d.double_click(*pts[-1])
620
+ await asyncio.sleep(0.2)
621
+ await d.press_key("Escape")
622
+ shot = await d.screenshot("sketch_spline.png")
623
+ r = Result(True, f"spline through {len(pts)} points", shot)
624
+ _record("sketch.spline", {"points": [list(p) for p in pts]}, r)
625
+ return r
626
+
627
+
628
+ async def sketch_point(d: OnshapeDriver, pt: tuple[float, float]) -> Result:
629
+ """Place a single sketch point at coordinate pt."""
630
+ t = await _activate_sketch_tool(d, "sketch.point")
631
+ if not t.ok:
632
+ return t
633
+ p = await _ensure_viewport_coords(d, pt)
634
+ await d.click(*p)
635
+ await asyncio.sleep(0.1)
636
+ await d.press_key("Escape")
637
+ shot = await d.screenshot("sketch_point.png")
638
+ r = Result(True, f"point at {p}", shot)
639
+ _record("sketch.point", {"pt": list(p)}, r)
640
+ return r
641
+
642
+
643
+ async def sketch_text(
644
+ d: OnshapeDriver,
645
+ corner1: tuple[float, float],
646
+ corner2: tuple[float, float],
647
+ text: str,
648
+ ) -> Result:
649
+ """Draw a text box from corner1 to corner2 and enter text."""
650
+ t = await _activate_sketch_tool(d, "sketch.text")
651
+ if not t.ok:
652
+ return t
653
+ c1 = await _ensure_viewport_coords(d, corner1)
654
+ c2 = await _ensure_viewport_coords(d, corner2)
655
+ await d.click(*c1)
656
+ await asyncio.sleep(0.1)
657
+ await d.click(*c2)
658
+ await asyncio.sleep(0.3)
659
+ # Fill in the text in the dialog
660
+ textarea = d.page.locator("textarea.os-text-editor-input, textarea")
661
+ if await textarea.count() > 0:
662
+ await textarea.first.fill(text)
663
+ await asyncio.sleep(0.1)
664
+ ok_btn = d.page.locator(".ns-dialog-button-ok, .button-ok").first
665
+ if await ok_btn.count() > 0:
666
+ await ok_btn.click()
667
+ else:
668
+ await d.press_key("Enter")
669
+ else:
670
+ await d.type_text(text)
671
+ await d.press_key("Enter")
672
+ await asyncio.sleep(0.3)
673
+ await d.press_key("Escape")
674
+ shot = await d.screenshot("sketch_text.png")
675
+ r = Result(True, f"text '{text}' at {c1}->{c2}", shot)
676
+ _record("sketch.text", {"corner1": list(c1), "corner2": list(c2), "text": text}, r)
677
+ return r
678
+
679
+
680
+ async def sketch_use(d: OnshapeDriver, pt: tuple[float, float]) -> Result:
681
+ """Project existing 3D model geometry or sketch curve at pt onto sketch plane."""
682
+ t = await _activate_sketch_tool(d, "sketch.use")
683
+ if not t.ok:
684
+ return t
685
+ p = await _ensure_viewport_coords(d, pt)
686
+ await d.click(*p)
687
+ await asyncio.sleep(0.2)
688
+ await d.press_key("Escape")
689
+ shot = await d.screenshot("sketch_use.png")
690
+ r = Result(True, f"projected geometry at {p}", shot)
691
+ _record("sketch.use", {"pt": list(p)}, r)
692
+ return r
693
+
694
+
695
+ async def sketch_construction(d: OnshapeDriver, pt: tuple[float, float] | None = None) -> Result:
696
+ """Toggle construction mode or convert entity at pt to construction geometry ('q')."""
697
+ if pt is not None:
698
+ p = await _ensure_viewport_coords(d, pt)
699
+ await d.click(*p)
700
+ await asyncio.sleep(0.15)
701
+ await d.press_chord("q")
702
+ await asyncio.sleep(0.15)
703
+ await d.press_key("Escape")
704
+ shot = await d.screenshot("sketch_construction.png")
705
+ r = Result(True, f"converted entity at {p} to construction", shot)
706
+ _record("sketch.construction", {"pt": list(p)}, r)
707
+ return r
708
+ await d.press_chord("q")
709
+ await asyncio.sleep(0.15)
710
+ shot = await d.screenshot("sketch_construction.png")
711
+ r = Result(True, "toggled construction mode", shot)
712
+ _record("sketch.construction", {}, r)
713
+ return r
714
+
715
+
716
+ async def sketch_fillet(
717
+ d: OnshapeDriver,
718
+ vertex_xy: tuple[float, float],
719
+ radius_mm: float | str = 5.0,
720
+ ) -> Result:
721
+ """Create a 2D sketch fillet at vertex_xy with radius_mm."""
722
+ t = await _activate_sketch_tool(d, "sketch.fillet")
723
+ if not t.ok:
724
+ return t
725
+ v = await _ensure_viewport_coords(d, vertex_xy)
726
+ await d.click(*v)
727
+ await asyncio.sleep(0.3)
728
+ val_str = f"{radius_mm} mm" if isinstance(radius_mm, (int, float)) else str(radius_mm)
729
+ dim_input = d.page.locator("input.os-canvas-text-edit")
730
+ if await dim_input.count() > 0:
731
+ await dim_input.first.fill(val_str)
732
+ await asyncio.sleep(0.1)
733
+ await d.press_key("Enter")
734
+ else:
735
+ await d.type_text(val_str)
736
+ await d.press_key("Enter")
737
+ await asyncio.sleep(0.3)
738
+ await d.press_key("Escape")
739
+ shot = await d.screenshot("sketch_fillet.png")
740
+ r = Result(True, f"fillet {val_str} at {v}", shot)
741
+ _record("sketch.fillet", {"vertex": list(v), "radius": val_str}, r)
742
+ return r
743
+
744
+
745
+ async def sketch_chamfer(
746
+ d: OnshapeDriver,
747
+ vertex_xy: tuple[float, float],
748
+ distance_mm: float | str = 5.0,
749
+ ) -> Result:
750
+ """Create a 2D sketch chamfer at vertex_xy with distance_mm."""
751
+ t = await _activate_sketch_tool(d, "sketch.chamfer")
752
+ if not t.ok:
753
+ return t
754
+ v = await _ensure_viewport_coords(d, vertex_xy)
755
+ await d.click(*v)
756
+ await asyncio.sleep(0.3)
757
+ val_str = f"{distance_mm} mm" if isinstance(distance_mm, (int, float)) else str(distance_mm)
758
+ dim_input = d.page.locator("input.os-canvas-text-edit")
759
+ if await dim_input.count() > 0:
760
+ await dim_input.first.fill(val_str)
761
+ await asyncio.sleep(0.1)
762
+ await d.press_key("Enter")
763
+ else:
764
+ await d.type_text(val_str)
765
+ await d.press_key("Enter")
766
+ await asyncio.sleep(0.3)
767
+ await d.press_key("Escape")
768
+ shot = await d.screenshot("sketch_chamfer.png")
769
+ r = Result(True, f"chamfer {val_str} at {v}", shot)
770
+ _record("sketch.chamfer", {"vertex": list(v), "distance": val_str}, r)
771
+ return r
772
+
773
+
774
+ async def sketch_trim(d: OnshapeDriver, entity_xy: tuple[float, float]) -> Result:
775
+ """Trim a sketch curve back to intersections by clicking it."""
776
+ t = await _activate_sketch_tool(d, "sketch.trim")
777
+ if not t.ok:
778
+ return t
779
+ p = await _ensure_viewport_coords(d, entity_xy)
780
+ await d.click(*p)
781
+ await asyncio.sleep(0.2)
782
+ await d.press_key("Escape")
783
+ shot = await d.screenshot("sketch_trim.png")
784
+ r = Result(True, f"trimmed at {p}", shot)
785
+ _record("sketch.trim", {"entity": list(p)}, r)
786
+ return r
787
+
788
+
789
+ async def sketch_extend(d: OnshapeDriver, endpoint_xy: tuple[float, float]) -> Result:
790
+ """Extend a sketch curve to boundary by clicking its endpoint."""
791
+ t = await _activate_sketch_tool(d, "sketch.extend")
792
+ if not t.ok:
793
+ return t
794
+ p = await _ensure_viewport_coords(d, endpoint_xy)
795
+ await d.click(*p)
796
+ await asyncio.sleep(0.2)
797
+ await d.press_key("Escape")
798
+ shot = await d.screenshot("sketch_extend.png")
799
+ r = Result(True, f"extended at {p}", shot)
800
+ _record("sketch.extend", {"endpoint": list(p)}, r)
801
+ return r
802
+
803
+
804
+ async def sketch_split(d: OnshapeDriver, entity_xy: tuple[float, float]) -> Result:
805
+ """Split a sketch curve at coordinate."""
806
+ t = await _activate_sketch_tool(d, "sketch.split")
807
+ if not t.ok:
808
+ return t
809
+ p = await _ensure_viewport_coords(d, entity_xy)
810
+ await d.click(*p)
811
+ await asyncio.sleep(0.2)
812
+ await d.press_key("Escape")
813
+ shot = await d.screenshot("sketch_split.png")
814
+ r = Result(True, f"split at {p}", shot)
815
+ _record("sketch.split", {"entity": list(p)}, r)
816
+ return r
817
+
818
+
819
+ async def sketch_offset(
820
+ d: OnshapeDriver,
821
+ entity_xy: tuple[float, float],
822
+ distance_mm: float | str = 5.0,
823
+ side_xy: tuple[float, float] | None = None,
824
+ ) -> Result:
825
+ """Offset selected curve by distance_mm."""
826
+ t = await _activate_sketch_tool(d, "sketch.offset")
827
+ if not t.ok:
828
+ return t
829
+ p = await _ensure_viewport_coords(d, entity_xy)
830
+ await d.click(*p)
831
+ await asyncio.sleep(0.2)
832
+ if side_xy is not None:
833
+ s = await _ensure_viewport_coords(d, side_xy)
834
+ await d.click(*s)
835
+ else:
836
+ await d.click(p[0] + 20.0, p[1] + 20.0)
837
+ await asyncio.sleep(0.2)
838
+ val_str = f"{distance_mm} mm" if isinstance(distance_mm, (int, float)) else str(distance_mm)
839
+ dim_input = d.page.locator("input.os-canvas-text-edit")
840
+ if await dim_input.count() > 0:
841
+ await dim_input.first.fill(val_str)
842
+ await asyncio.sleep(0.1)
843
+ await d.press_key("Enter")
844
+ else:
845
+ await d.type_text(val_str)
846
+ await d.press_key("Enter")
847
+ await asyncio.sleep(0.3)
848
+ await d.press_key("Escape")
849
+ shot = await d.screenshot("sketch_offset.png")
850
+ r = Result(True, f"offset {val_str} at {p}", shot)
851
+ _record("sketch.offset", {"entity": list(p), "distance": val_str}, r)
852
+ return r
853
+
854
+
855
+ async def sketch_slot(
856
+ d: OnshapeDriver,
857
+ p1: tuple[float, float],
858
+ p2: tuple[float, float],
859
+ radius_px: float = 20.0,
860
+ ) -> Result:
861
+ """Create a sketch slot along line from p1 to p2."""
862
+ t = await _activate_sketch_tool(d, "sketch.slot")
863
+ if not t.ok:
864
+ return t
865
+ pt1 = await _ensure_viewport_coords(d, p1)
866
+ pt2 = await _ensure_viewport_coords(d, p2)
867
+ await d.click(*pt1)
868
+ await asyncio.sleep(0.1)
869
+ await d.click(*pt2)
870
+ await asyncio.sleep(0.1)
871
+ await d.click(pt2[0] + radius_px, pt2[1])
872
+ await asyncio.sleep(0.2)
873
+ await d.press_key("Escape")
874
+ shot = await d.screenshot("sketch_slot.png")
875
+ r = Result(True, f"slot {pt1} -> {pt2}", shot)
876
+ _record("sketch.slot", {"p1": list(pt1), "p2": list(pt2)}, r)
877
+ return r
878
+
879
+
880
+ async def sketch_mirror(
881
+ d: OnshapeDriver,
882
+ centerline_xy: tuple[float, float],
883
+ *entity_xys: tuple[float, float],
884
+ ) -> Result:
885
+ """Mirror entities across a centerline."""
886
+ if not entity_xys:
887
+ return Result(False, "sketch.mirror requires at least 1 entity to mirror")
888
+ t = await _activate_sketch_tool(d, "sketch.mirror")
889
+ if not t.ok:
890
+ return t
891
+ cl = await _ensure_viewport_coords(d, centerline_xy)
892
+ await d.click(*cl)
893
+ await asyncio.sleep(0.3)
894
+ for xy in entity_xys:
895
+ e = await _ensure_viewport_coords(d, xy)
896
+ await d.click(*e)
897
+ await asyncio.sleep(0.2)
898
+ await d.press_key("Escape")
899
+ shot = await d.screenshot("sketch_mirror.png")
900
+ r = Result(True, f"mirrored {len(entity_xys)} entities across {cl}", shot)
901
+ _record("sketch.mirror", {"centerline": list(cl), "entities": [list(e) for e in entity_xys]}, r)
902
+ return r
903
+
904
+
905
+ async def sketch_constrain(
906
+ d: OnshapeDriver,
907
+ constraint_type: str,
908
+ *entity_xys: tuple[float, float],
909
+ ) -> Result:
910
+ """Apply any geometric constraint across selected entities."""
911
+ c_type = constraint_type.lower().replace("constraint.", "").strip()
912
+ tool_key = f"constraint.{c_type}"
913
+ t = await _activate_sketch_tool(d, tool_key)
914
+ if not t.ok:
915
+ return t
916
+ await asyncio.sleep(0.2)
917
+ for xy in entity_xys:
918
+ pt = await _ensure_viewport_coords(d, xy)
919
+ await d.page.mouse.move(*pt)
920
+ await asyncio.sleep(0.1)
921
+ await d.page.mouse.click(*pt)
922
+ await asyncio.sleep(0.2)
923
+ await d.press_key("Escape")
924
+ shot = await d.screenshot(f"constraint_{c_type}.png")
925
+ r = Result(True, f"constraint {c_type} on {len(entity_xys)} entities", shot)
926
+ _record(tool_key, {"entities": [list(e) for e in entity_xys]}, r)
927
+ return r
928
+
929
+
930
+ async def sketch_exit(d: OnshapeDriver, commit: bool = True, **_kw) -> Result:
931
+ """Exit and accept (or cancel) the active sketch."""
932
+ before = await d.screenshot("sketch_exit_before.png")
933
+ if commit:
934
+ # Click green checkmark button on dialog
935
+ ok_btn = d.page.locator(".ns-dialog-button-ok, .button-ok").first
936
+ if await ok_btn.count() > 0:
937
+ await ok_btn.click()
938
+ else:
939
+ await d.click(424.0, 93.0)
940
+ await asyncio.sleep(0.5)
941
+ await d.press_chord("Shift", "Enter")
942
+ else:
943
+ cancel_btn = d.page.locator(".ns-dialog-button-cancel, .button-cancel").first
944
+ if await cancel_btn.count() > 0:
945
+ await cancel_btn.click()
946
+ else:
947
+ await d.press_key("Escape")
948
+ await asyncio.sleep(0.5)
949
+ await d.press_key("Escape")
950
+ await asyncio.sleep(0.5)
951
+ after = await d.screenshot("sketch_exit_after.png")
952
+ r = Result(
953
+ True, "exit sketch", after, {"before": str(before), "after": str(after), "commit": commit}
954
+ )
955
+ _record("sketch.exit", {"commit": commit}, r)
956
+ return r
957
+
958
+
959
+ # Feature tools
960
+
961
+
962
+ async def _select_latest_sketch(d: OnshapeDriver) -> bool:
963
+ """Click the most recent sketch in Onshape's feature tree so the
964
+ extrude tool knows what region to use. Returns True if a sketch was
965
+ selected, False otherwise.
966
+
967
+ When the doc has multiple sketches, pressing E with nothing selected
968
+ picks nothing and Onshape shows "Select face or sketch region to
969
+ extrude". Clicking the latest sketch in the tree pre-selects it.
970
+ """
971
+ try:
972
+ clicked = await d.page.evaluate(
973
+ """() => {
974
+ // Feature tree rows. Onshape uses a few different class
975
+ // names across versions; grab anything that looks like
976
+ // a tree node containing a Sketch label.
977
+ const candidates = document.querySelectorAll(
978
+ '[class*="feature-tree"] [class*="item"], ' +
979
+ '[class*="FeatureTree"] [class*="item"], ' +
980
+ '[data-test*="feature-tree"] [class*="row"]'
981
+ );
982
+ let best = null;
983
+ let bestNum = -1;
984
+ for (const el of candidates) {
985
+ const t = (el.textContent || '').trim();
986
+ const m = t.match(/^Sketch\\s*(\\d+)/i);
987
+ if (m) {
988
+ const n = parseInt(m[1], 10);
989
+ if (n > bestNum) { bestNum = n; best = el; }
990
+ }
991
+ }
992
+ if (best) { best.click(); return true; }
993
+ return false;
994
+ }"""
995
+ )
996
+ if clicked:
997
+ await asyncio.sleep(0.4)
998
+ return bool(clicked)
999
+ except Exception:
1000
+ return False
1001
+
1002
+
1003
+ async def feature_extrude(d: OnshapeDriver, depth_mm: float | None = None) -> Result:
1004
+ """Extrude a sketch region. depth_mm is a number in millimetres.
1005
+
1006
+ Workflow:
1007
+ 1. Click the most recent sketch in the feature tree so the
1008
+ extrude tool knows which region to use. Required when the
1009
+ doc has more than one sketch.
1010
+ 2. Activate the extrude tool (E or toolbar).
1011
+ 3. Wait for the dialog. Find the depth input, clear it, type the
1012
+ value with "mm" unit, press Enter.
1013
+ 4. Click the green checkmark to commit the feature.
1014
+ """
1015
+ # Step 1: pre-select the most recent sketch
1016
+ await _select_latest_sketch(d)
1017
+
1018
+ # Step 2: activate extrude
1019
+ b = binding_for("feature.extrude")
1020
+ if b.keys:
1021
+ await d.press_chord(*b.keys)
1022
+ elif b.toolbar_text:
1023
+ clicked = await d.click_text(b.toolbar_text, timeout_ms=3000)
1024
+ if not clicked:
1025
+ return Result(False, f"feature.extrude: toolbar {b.toolbar_text!r} not found")
1026
+ else:
1027
+ return Result(False, "feature.extrude: no binding")
1028
+ await asyncio.sleep(0.5) # dialog open animation
1029
+
1030
+ if depth_mm is not None:
1031
+ # Step 3: fill the depth input. Onshape renders the depth field
1032
+ # as an input.os-canvas-text-edit. .fill() clears the field
1033
+ # first, then types the value with the unit suffix so Onshape
1034
+ # doesn't reinterpret it as the default unit (cm).
1035
+ depth_str = f"{depth_mm:g} mm"
1036
+ try:
1037
+ depth_input = d.page.locator("input.os-canvas-text-edit").first
1038
+ if await depth_input.count() > 0:
1039
+ await depth_input.fill(depth_str)
1040
+ else:
1041
+ # Fallback: select all and type.
1042
+ await d.press_chord("Control", "a")
1043
+ await d.type_text(depth_str)
1044
+ except Exception:
1045
+ await d.press_chord("Control", "a")
1046
+ await d.type_text(depth_str)
1047
+ await asyncio.sleep(0.2)
1048
+ await d.press_key("Enter")
1049
+ await asyncio.sleep(0.4)
1050
+
1051
+ # Step 4: commit with the green checkmark. Try aria-label first,
1052
+ # fall back to the hardcoded toolbar position.
1053
+ try:
1054
+ check = d.page.locator(
1055
+ 'button[aria-label*="check" i], button[title*="check" i]'
1056
+ ).first
1057
+ if await check.count() > 0:
1058
+ await check.click()
1059
+ else:
1060
+ await d.click(294.0, 105.0)
1061
+ except Exception:
1062
+ await d.click(294.0, 105.0)
1063
+ await asyncio.sleep(0.3)
1064
+ # Some extrude paths need a second confirm. Shift+Enter
1065
+ # dismisses any tooltip without committing the wrong action.
1066
+ await d.press_chord("Shift", "Enter")
1067
+ await asyncio.sleep(0.5)
1068
+
1069
+ shot = await d.screenshot("feature_extrude.png")
1070
+ r = Result(True, f"extrude depth={depth_mm}mm", shot)
1071
+ _record("feature.extrude", {"depth_mm": depth_mm}, r)
1072
+ return r
1073
+
1074
+
1075
+ async def feature_fillet(d: OnshapeDriver, radius_mm: float | None = None) -> Result:
1076
+ b = binding_for("feature.fillet")
1077
+ if b.toolbar_text:
1078
+ clicked = await d.click_text(b.toolbar_text, timeout_ms=3000)
1079
+ if not clicked and b.keys:
1080
+ await d.press_chord(*b.keys)
1081
+ elif b.keys:
1082
+ await d.press_chord(*b.keys)
1083
+ else:
1084
+ return Result(False, "feature.fillet: no binding")
1085
+ await asyncio.sleep(0.3)
1086
+ if radius_mm is not None:
1087
+ await d.type_text(str(radius_mm))
1088
+ await d.press_key("Enter")
1089
+ await asyncio.sleep(0.3)
1090
+ shot = await d.screenshot("feature_fillet.png")
1091
+ r = Result(True, f"fillet r={radius_mm}", shot)
1092
+ _record("feature.fillet", {"radius_mm": radius_mm}, r)
1093
+ return r
1094
+
1095
+
1096
+ async def feature_chamfer(d: OnshapeDriver, distance_mm: float | None = None) -> Result:
1097
+ b = binding_for("feature.chamfer")
1098
+ if b.toolbar_text is None:
1099
+ return Result(False, "feature.chamfer: no binding")
1100
+ clicked = await d.click_text(b.toolbar_text, timeout_ms=3000)
1101
+ if not clicked:
1102
+ return Result(False, f"feature.chamfer: toolbar {b.toolbar_text!r} not found")
1103
+ await asyncio.sleep(0.3)
1104
+ if distance_mm is not None:
1105
+ await d.type_text(str(distance_mm))
1106
+ await d.press_key("Enter")
1107
+ await asyncio.sleep(0.3)
1108
+ shot = await d.screenshot("feature_chamfer.png")
1109
+ r = Result(True, f"chamfer d={distance_mm}", shot)
1110
+ _record("feature.chamfer", {"distance_mm": distance_mm}, r)
1111
+ return r
1112
+
1113
+
1114
+ # Selection
1115
+
1116
+
1117
+ async def select_face(d: OnshapeDriver, x: float, y: float) -> Result:
1118
+ """Click in the viewport to select a face. Best-effort: the face at
1119
+ that pixel may not be what was intended, so the LLM loop verifies via
1120
+ screenshot."""
1121
+ await d.click(x, y)
1122
+ await asyncio.sleep(0.2)
1123
+ shot = await d.screenshot("select_face.png")
1124
+ r = Result(True, f"clicked ({x},{y})", shot, {"click": [x, y]})
1125
+ _record("select.face", {"x": x, "y": y}, r)
1126
+ return r
1127
+
1128
+
1129
+ async def select_edge(d: OnshapeDriver, x: float, y: float) -> Result:
1130
+ await d.click(x, y)
1131
+ await asyncio.sleep(0.2)
1132
+ shot = await d.screenshot("select_edge.png")
1133
+ r = Result(True, f"clicked edge ({x},{y})", shot, {"click": [x, y]})
1134
+ _record("select.edge", {"x": x, "y": y}, r)
1135
+ return r
1136
+
1137
+
1138
+ # Global undo and redo
1139
+
1140
+
1141
+ async def undo(d: OnshapeDriver) -> Result:
1142
+ b = binding_for("ui.undo")
1143
+ await d.press_chord(*b.keys) # type: ignore[arg-type]
1144
+ await asyncio.sleep(0.2)
1145
+ shot = await d.screenshot("undo.png")
1146
+ r = Result(True, "undo", shot)
1147
+ _record("ui.undo", {}, r)
1148
+ return r
1149
+
1150
+
1151
+ async def redo(d: OnshapeDriver) -> Result:
1152
+ b = binding_for("ui.redo")
1153
+ await d.press_chord(*b.keys) # type: ignore[arg-type]
1154
+ await asyncio.sleep(0.2)
1155
+ shot = await d.screenshot("redo.png")
1156
+ r = Result(True, "redo", shot)
1157
+ _record("ui.redo", {}, r)
1158
+ return r
1159
+
1160
+
1161
+ async def wait(d: OnshapeDriver, seconds: float = 1.0) -> Result:
1162
+ """No-op pause. Useful when the LLM wants to let a dialog animation
1163
+ finish, or just look at the current state again on the next step."""
1164
+ await asyncio.sleep(max(0.0, seconds))
1165
+ shot = await d.screenshot("wait.png")
1166
+ r = Result(True, f"waited {seconds}s", shot)
1167
+ _record("ui.wait", {"seconds": seconds}, r)
1168
+ return r
1169
+
1170
+
1171
+ async def screenshot_only(d: OnshapeDriver, name: str = "agent.png") -> Result:
1172
+ """Bare screenshot without any other action. Lets the LLM re-observe
1173
+ the viewport without doing anything else."""
1174
+ shot = await d.screenshot(name)
1175
+ r = Result(True, f"screenshot {name}", shot)
1176
+ _record("screenshot", {"name": name}, r)
1177
+ return r
1178
+
1179
+
1180
+ async def doc_open(d: OnshapeDriver, url: str) -> Result:
1181
+ """Navigate to a specific Onshape document URL. Pass either a full
1182
+ URL or a `d/<docId>/e/<elementId>` path. Usually elementId is a
1183
+ Part Studio — if the document has one, deep-link to it directly.
1184
+ """
1185
+ if not url:
1186
+ return Result(False, "doc.open: missing url")
1187
+ full = url if url.startswith("http") else f"https://cad.onshape.com/{url.lstrip('/')}"
1188
+ await d.open(full)
1189
+ shot = await d.screenshot("doc_open.png")
1190
+ r = Result(True, f"opened {full}", shot, {"url": full})
1191
+ _record("doc.open", {"url": full}, r)
1192
+ return r
1193
+
1194
+
1195
+ async def doc_new(d: OnshapeDriver) -> Result:
1196
+ """Click the Onshape "Create" / new document button. Lands in a fresh
1197
+ Part Studio, which is where sketch + feature tools work.
1198
+ """
1199
+ try:
1200
+ # Click Create dropdown button at top-left
1201
+ await d.click(75, 70)
1202
+ await asyncio.sleep(0.5)
1203
+ # Click "Document..." item
1204
+ await d.click(75, 110)
1205
+ await asyncio.sleep(1.0)
1206
+ await d.type_text("PartStudio")
1207
+ await asyncio.sleep(0.2)
1208
+ await d.press_key("Enter")
1209
+ await d.page.wait_for_url("**/documents/**/e/**", timeout=30000)
1210
+ await d.wait_for_app()
1211
+ except Exception as e:
1212
+ return Result(False, f"doc.new failed: {e}")
1213
+ shot = await d.screenshot("doc_new.png")
1214
+ r = Result(True, "new document", shot)
1215
+ _record("doc.new", {}, r)
1216
+ return r
src/onshape_mcp/vision.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gemini web client. Cookie-auth, no API key. Cookie file is gitignored
2
+ (see SECURITY.md). Cookie file format: {"secure_1psid": ..., "secure_1psidts": ...}.
3
+
4
+ I tried to keep the wrapper thin. The `gemini-webapi` package handles the
5
+ auth dance but its surface shifts often, so pin and verify on every
6
+ install.
7
+
8
+ The library supports two cookie paths: pass them explicitly, or let it
9
+ use browser-cookie3 to read straight from Chrome. The latter is more
10
+ robust because it grabs the full session (PSID, PSIDTS, PSIDCC, plus
11
+ the third-party cookies) and uses the keyring to decrypt. Explicit
12
+ cookies can fail for the multimodal endpoint even when text chat works,
13
+ because the image-upload path validates against more cookies.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .config import settings
24
+
25
+
26
+ class GeminiWeb:
27
+ def __init__(
28
+ self,
29
+ cookie_file: Path | None = None,
30
+ model: str | None = None,
31
+ temporary: bool | None = None,
32
+ auto_cleanup: bool | None = None,
33
+ ) -> None:
34
+ self.cookie_file = (cookie_file or settings.gemini_cookie_file).resolve()
35
+ self.model = model or settings.gemini_model
36
+ self.temporary = settings.gemini_temporary_chat if temporary is None else temporary
37
+ self.auto_cleanup = settings.gemini_auto_cleanup if auto_cleanup is None else auto_cleanup
38
+ self._client: Any = None
39
+ self._active_session: Any = None
40
+ self.created_chat_ids: set[str] = set()
41
+
42
+ def _has_cookie_file(self) -> bool:
43
+ """True if the cookie file exists and has the two required values."""
44
+ if not self.cookie_file.exists():
45
+ return False
46
+ try:
47
+ data = json.loads(self.cookie_file.read_text(encoding="utf-8"))
48
+ except (json.JSONDecodeError, OSError):
49
+ return False
50
+ psid = data.get("secure_1psid") or data.get("__Secure-1PSID")
51
+ psidts = data.get("secure_1psidts") or data.get("__Secure-1PSIDTS")
52
+ return bool(psid and psidts)
53
+
54
+ async def _ensure_client(self) -> None:
55
+ if self._client is not None:
56
+ return
57
+ try:
58
+ from gemini_webapi import GeminiClient # type: ignore
59
+ except ImportError as e: # pragma: no cover
60
+ raise RuntimeError("Install gemini-webapi: `pip install gemini-webapi`") from e
61
+
62
+ # Prefer letting the library use browser-cookie3 directly. It
63
+ # grabs the full Chrome session (PSID, PSIDTS, PSIDCC, third-party
64
+ # cookies) and decrypts via the keyring, which is the path that
65
+ # works reliably for the multimodal (image upload) endpoint.
66
+ try:
67
+ client = GeminiClient(None, None, model=self.model)
68
+ await client.init(timeout=15)
69
+ self._client = client
70
+ return
71
+ except Exception as e:
72
+ print(f"[vision] live browser session init note ({e}); trying cookie file")
73
+
74
+ # Fallback to explicit cookie file if browser-cookie3 could not read Chrome
75
+ if self._has_cookie_file():
76
+ data = json.loads(self.cookie_file.read_text(encoding="utf-8"))
77
+ psid = data.get("secure_1psid") or data.get("__Secure-1PSID")
78
+ psidts = data.get("secure_1psidts") or data.get("__Secure-1PSIDTS")
79
+ self._client = GeminiClient(psid, psidts, model=self.model)
80
+ await self._client.init(timeout=30)
81
+ return
82
+
83
+ raise RuntimeError("No valid Gemini credentials found (neither live Chrome nor cookie file).")
84
+
85
+ async def _refresh_cookies_from_chrome(self) -> bool:
86
+ """Re-extract Gemini cookies from the user's real Chrome. The
87
+ gemini-webapi library doesn't refresh its session automatically
88
+ and the cookies can go stale between calls. This pulls fresh
89
+ ones and overwrites the cookie file.
90
+ """
91
+ try:
92
+ import browser_cookie3
93
+ except ImportError:
94
+ return False
95
+ try:
96
+ cj = browser_cookie3.chrome(domain_name="google.com")
97
+ except Exception as e:
98
+ print(f"[vision] could not read Chrome cookies: {e}")
99
+ return False
100
+ psid = psidts = None
101
+ for c in cj:
102
+ if c.name == "__Secure-1PSID":
103
+ psid = c.value
104
+ elif c.name == "__Secure-1PSIDTS":
105
+ psidts = c.value
106
+ if not (psid and psidts):
107
+ return False
108
+ self.cookie_file.parent.mkdir(parents=True, exist_ok=True)
109
+ self.cookie_file.write_text(
110
+ json.dumps(
111
+ {
112
+ "secure_1psid": psid,
113
+ "secure_1psidts": psidts,
114
+ "_note": "refreshed by vision.py",
115
+ },
116
+ indent=2,
117
+ ),
118
+ encoding="utf-8",
119
+ )
120
+ return True
121
+
122
+ async def new_session(self, temporary: bool | None = None) -> Any:
123
+ """Create and track a new ChatSession attached to this client."""
124
+ await self._ensure_client()
125
+ session = self._client.start_chat()
126
+ self._session_temporary = self.temporary if temporary is None else temporary
127
+ self._active_session = session
128
+ return session
129
+
130
+ def get_session(self) -> Any:
131
+ """Return the active ChatSession, if any."""
132
+ return self._active_session
133
+
134
+ def reset_session(self) -> None:
135
+ """Clear the active ChatSession."""
136
+ self._active_session = None
137
+
138
+ async def delete_chat(self, cid: str) -> bool:
139
+ """Delete a specific conversation by chat ID. Returns True if successful."""
140
+ await self._ensure_client()
141
+ try:
142
+ await self._client.delete_chat(cid)
143
+ self.created_chat_ids.discard(cid)
144
+ print(f"[vision] deleted chat {cid}")
145
+ return True
146
+ except Exception as e:
147
+ print(f"[vision] failed to delete chat {cid}: {e}")
148
+ return False
149
+
150
+ async def list_chats(self) -> list[Any]:
151
+ """List all conversations in the user's Gemini account."""
152
+ await self._ensure_client()
153
+ try:
154
+ res = self._client.list_chats()
155
+ return res or []
156
+ except Exception as e:
157
+ print(f"[vision] failed to list chats: {e}")
158
+ return []
159
+
160
+ async def cleanup_created_chats(self) -> int:
161
+ """Delete all non-temporary chats created and tracked by this instance."""
162
+ if not self.created_chat_ids or self._client is None:
163
+ return 0
164
+ to_delete = list(self.created_chat_ids)
165
+ deleted = 0
166
+ for cid in to_delete:
167
+ if await self.delete_chat(cid):
168
+ deleted += 1
169
+ return deleted
170
+
171
+ async def ask_with_image(
172
+ self,
173
+ prompt: str,
174
+ image_path: Path,
175
+ session: Any = None,
176
+ temporary: bool | None = None,
177
+ timeout: int = 120,
178
+ ) -> str:
179
+ # Try up to 2 times. If the first call hits an "UNAUTHENTICATED"
180
+ # error, re-extract cookies from Chrome (they go stale) and
181
+ # re-initialise the client.
182
+ last_err: Exception | None = None
183
+ use_temp = (
184
+ getattr(self, "_session_temporary", self.temporary) if temporary is None else temporary
185
+ )
186
+
187
+ for attempt in range(2):
188
+ await self._ensure_client()
189
+ if attempt > 0:
190
+ chat = self._client.start_chat()
191
+ self._active_session = chat
192
+ else:
193
+ chat = session or self._active_session
194
+ if chat is None:
195
+ chat = self._client.start_chat()
196
+ self._active_session = chat
197
+
198
+ temp_flag = use_temp
199
+ try:
200
+ response = await asyncio.wait_for(
201
+ chat.send_message(prompt, files=[str(image_path)], temporary=temp_flag),
202
+ timeout=timeout,
203
+ )
204
+ cid = getattr(chat, "cid", None)
205
+ if cid and not temp_flag:
206
+ self.created_chat_ids.add(cid)
207
+ return getattr(response, "text", str(response))
208
+ except Exception as e:
209
+ last_err = e
210
+ if "UNAUTHENTICATED" in str(e) or "Permission" in str(e):
211
+ print(f"[vision] auth error on attempt {attempt + 1}: {e}")
212
+ try:
213
+ await self._client.close()
214
+ except Exception:
215
+ pass
216
+ self._client = None
217
+ self._active_session = None
218
+ if attempt == 0:
219
+ print("[vision] re-extracting cookies from Chrome")
220
+ if not await self._refresh_cookies_from_chrome():
221
+ print(
222
+ "[vision] cookie refresh failed; will retry with whatever we have"
223
+ )
224
+ continue
225
+ raise
226
+ raise last_err if last_err else RuntimeError("vision: unknown failure")
227
+
228
+ async def close(self) -> None:
229
+ if self.auto_cleanup and self.created_chat_ids:
230
+ try:
231
+ await self.cleanup_created_chats()
232
+ except Exception as e:
233
+ print(f"[vision] error during auto-cleanup: {e}")
234
+ self._active_session = None
235
+ if self._client is not None:
236
+ try:
237
+ await self._client.close()
238
+ except Exception:
239
+ pass
240
+ self._client = None