solidworks-mcp-python 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solidworks_mcp/__init__.py +57 -0
- solidworks_mcp/adapters/__init__.py +40 -0
- solidworks_mcp/adapters/base.py +1882 -0
- solidworks_mcp/adapters/circuit_breaker.py +2052 -0
- solidworks_mcp/adapters/com_executor.py +252 -0
- solidworks_mcp/adapters/complexity_analyzer.py +272 -0
- solidworks_mcp/adapters/connection_pool.py +1884 -0
- solidworks_mcp/adapters/factory.py +366 -0
- solidworks_mcp/adapters/intelligent_router.py +218 -0
- solidworks_mcp/adapters/mock_adapter.py +4016 -0
- solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
- solidworks_mcp/adapters/solidworks/__init__.py +17 -0
- solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
- solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
- solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
- solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
- solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
- solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
- solidworks_mcp/adapters/solidworks/features.py +3362 -0
- solidworks_mcp/adapters/solidworks/io.py +3649 -0
- solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
- solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
- solidworks_mcp/adapters/solidworks/selection.py +308 -0
- solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
- solidworks_mcp/adapters/sw_feature_errors.py +66 -0
- solidworks_mcp/adapters/sw_type_info.py +317 -0
- solidworks_mcp/adapters/vba_adapter.py +318 -0
- solidworks_mcp/adapters/vba_macro_executor.py +215 -0
- solidworks_mcp/agents/__init__.py +68 -0
- solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
- solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
- solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
- solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
- solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
- solidworks_mcp/agents/harness.py +216 -0
- solidworks_mcp/agents/history_db.py +1508 -0
- solidworks_mcp/agents/retrieval_index.py +164 -0
- solidworks_mcp/agents/schemas.py +165 -0
- solidworks_mcp/agents/smoke_test.py +285 -0
- solidworks_mcp/agents/soc_exporter.py +650 -0
- solidworks_mcp/agents/soc_pickup.py +332 -0
- solidworks_mcp/agents/soc_rewind.py +221 -0
- solidworks_mcp/agents/vector_rag.py +665 -0
- solidworks_mcp/cache/__init__.py +5 -0
- solidworks_mcp/cache/response_cache.py +164 -0
- solidworks_mcp/config.py +729 -0
- solidworks_mcp/exceptions.py +37 -0
- solidworks_mcp/security/__init__.py +68 -0
- solidworks_mcp/security/auth.py +128 -0
- solidworks_mcp/security/cors.py +27 -0
- solidworks_mcp/security/rate_limiting.py +90 -0
- solidworks_mcp/security/runtime.py +155 -0
- solidworks_mcp/server.py +777 -0
- solidworks_mcp/server_cli_fixed.py +120 -0
- solidworks_mcp/setup/__init__.py +1 -0
- solidworks_mcp/setup/cli.py +399 -0
- solidworks_mcp/setup/clients.py +157 -0
- solidworks_mcp/setup/config_file.py +230 -0
- solidworks_mcp/setup/handshake.py +202 -0
- solidworks_mcp/setup/launcher.py +102 -0
- solidworks_mcp/setup/legacy.py +100 -0
- solidworks_mcp/setup/registration.py +193 -0
- solidworks_mcp/setup/solidworks.py +49 -0
- solidworks_mcp/tools/__init__.py +64 -0
- solidworks_mcp/tools/analysis.py +723 -0
- solidworks_mcp/tools/automation.py +578 -0
- solidworks_mcp/tools/docs_discovery.py +1476 -0
- solidworks_mcp/tools/drawing.py +1015 -0
- solidworks_mcp/tools/drawing_analysis.py +456 -0
- solidworks_mcp/tools/drawing_automation.py +627 -0
- solidworks_mcp/tools/engineering_data.py +466 -0
- solidworks_mcp/tools/error_classifier.py +232 -0
- solidworks_mcp/tools/export.py +833 -0
- solidworks_mcp/tools/file_facts.py +40 -0
- solidworks_mcp/tools/file_management.py +1473 -0
- solidworks_mcp/tools/input_compat.py +17 -0
- solidworks_mcp/tools/macro_recording.py +464 -0
- solidworks_mcp/tools/modeling.py +2360 -0
- solidworks_mcp/tools/sketching.py +1422 -0
- solidworks_mcp/tools/template_management.py +367 -0
- solidworks_mcp/tools/tool_boundary.py +280 -0
- solidworks_mcp/tools/vba_generation.py +1145 -0
- solidworks_mcp/ui/__init__.py +9 -0
- solidworks_mcp/ui/local_llm.py +725 -0
- solidworks_mcp/ui/routers/__init__.py +10 -0
- solidworks_mcp/ui/routers/checkpoint.py +24 -0
- solidworks_mcp/ui/routers/docs.py +47 -0
- solidworks_mcp/ui/routers/llm.py +83 -0
- solidworks_mcp/ui/routers/local_model.py +69 -0
- solidworks_mcp/ui/routers/model.py +75 -0
- solidworks_mcp/ui/routers/preview.py +38 -0
- solidworks_mcp/ui/routers/session.py +161 -0
- solidworks_mcp/ui/routers/viewer.py +154 -0
- solidworks_mcp/ui/schemas.py +502 -0
- solidworks_mcp/ui/server.py +298 -0
- solidworks_mcp/ui/service.py +481 -0
- solidworks_mcp/ui/services/__init__.py +136 -0
- solidworks_mcp/ui/services/_utils.py +958 -0
- solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
- solidworks_mcp/ui/services/docs_service.py +282 -0
- solidworks_mcp/ui/services/llm_service.py +956 -0
- solidworks_mcp/ui/services/model_service.py +459 -0
- solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
- solidworks_mcp/ui/services/preview_service.py +489 -0
- solidworks_mcp/ui/services/session_service.py +1389 -0
- solidworks_mcp/utils/__init__.py +9 -0
- solidworks_mcp/utils/feature_tree_classifier.py +342 -0
- solidworks_mcp/utils/logging.py +76 -0
- solidworks_mcp/utils/validation.py +77 -0
- solidworks_mcp/version.py +8 -0
- solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
- solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
- solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
- solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
- solidworks_mcp_python-1.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Change one server entry in an MCP client's JSON config, and nothing else.
|
|
2
|
+
|
|
3
|
+
Client config files often hold other servers' secrets (API keys, OAuth
|
|
4
|
+
tokens). Everything here works on the parsed JSON: no function returns,
|
|
5
|
+
prints or logs the file's text or any entry other than the one it was asked
|
|
6
|
+
to change, and an error names only the file, line and column.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigFileError(Exception):
|
|
21
|
+
"""A client config file can't be read or safely changed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class EntryChange:
|
|
26
|
+
"""What happened to the server entry in one client's config.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
location: Where the entry lives (a file path, or a description).
|
|
30
|
+
action: ``added``, ``updated``, ``removed`` or ``unchanged``.
|
|
31
|
+
backup: The copy made before writing, if a file was overwritten.
|
|
32
|
+
previous: For an ``updated`` entry, the ``command`` it ran before,
|
|
33
|
+
so the user can see which copy it pointed at. Only the command
|
|
34
|
+
is kept, never the rest of the entry.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
location: str
|
|
38
|
+
action: str
|
|
39
|
+
backup: Path | None = None
|
|
40
|
+
previous: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_config(path: Path) -> dict[str, Any]:
|
|
44
|
+
"""Parse a client config file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: The JSON config file.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The parsed object; ``{}`` for a missing or empty file.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ConfigFileError: The file can't be read, isn't valid JSON, or isn't a
|
|
54
|
+
JSON object. The message never quotes the file's content.
|
|
55
|
+
"""
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
# utf-8-sig accepts a byte-order mark, which some editors add.
|
|
60
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
61
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
62
|
+
raise ConfigFileError(f"Can't read {path}: {type(exc).__name__}.") from None
|
|
63
|
+
if not text.strip():
|
|
64
|
+
return {}
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(text)
|
|
67
|
+
except json.JSONDecodeError as exc:
|
|
68
|
+
# `from None`: the original exception carries the document text.
|
|
69
|
+
raise ConfigFileError(
|
|
70
|
+
f"{path} is not valid JSON (line {exc.lineno}, column {exc.colno}). "
|
|
71
|
+
"It was left untouched: fix it, or move it aside, and run solidworks-mcp setup again."
|
|
72
|
+
) from None
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
raise ConfigFileError(f"{path} does not hold a JSON object; left untouched.")
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def server_entries(path: Path, section: str) -> dict[str, Any]:
|
|
79
|
+
"""Read every server entry in a config, for inspection in code only.
|
|
80
|
+
|
|
81
|
+
Callers must not print the entries: they may hold other servers' secrets.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
path: The JSON config file.
|
|
85
|
+
section: The object that holds the servers (``mcpServers``/``servers``).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The entries by name; ``{}`` when the file or section is absent or the
|
|
89
|
+
section isn't an object.
|
|
90
|
+
"""
|
|
91
|
+
servers = read_config(path).get(section)
|
|
92
|
+
return servers if isinstance(servers, dict) else {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def current_entry(path: Path, section: str, name: str) -> Any:
|
|
96
|
+
"""Read one server entry.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
path: The JSON config file.
|
|
100
|
+
section: The object that holds the servers (``mcpServers``/``servers``).
|
|
101
|
+
name: The server's key in that object.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The entry, or ``None`` when the file, section or key is absent.
|
|
105
|
+
"""
|
|
106
|
+
return server_entries(path, section).get(name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def set_entry(
|
|
110
|
+
path: Path,
|
|
111
|
+
section: str,
|
|
112
|
+
name: str,
|
|
113
|
+
entry: dict[str, Any] | None,
|
|
114
|
+
*,
|
|
115
|
+
dry_run: bool = False,
|
|
116
|
+
) -> EntryChange:
|
|
117
|
+
"""Set ``section.name`` to ``entry``, or delete it when ``entry`` is None.
|
|
118
|
+
|
|
119
|
+
Every other key in the file keeps its value and order. Before writing,
|
|
120
|
+
the file is backed up; it is then written atomically as UTF-8 without a
|
|
121
|
+
BOM, and read back to confirm it parses and holds the new entry.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
path: The JSON config file; created, with its folder, if missing.
|
|
125
|
+
section: The object that holds the servers.
|
|
126
|
+
name: The server's key in that object.
|
|
127
|
+
entry: The new entry, or ``None`` to remove it.
|
|
128
|
+
dry_run: Work out the action but write nothing.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
What happened (or, with ``dry_run``, what would happen).
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ConfigFileError: The file can't be parsed, the section isn't an
|
|
135
|
+
object, or the written file doesn't hold the entry.
|
|
136
|
+
"""
|
|
137
|
+
data = read_config(path)
|
|
138
|
+
servers = data.get(section, {})
|
|
139
|
+
if not isinstance(servers, dict):
|
|
140
|
+
raise ConfigFileError(
|
|
141
|
+
f'"{section}" in {path} is not an object; left untouched.'
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
action = _action(servers, name, entry)
|
|
145
|
+
previous = _command_of(servers.get(name)) if action == "updated" else None
|
|
146
|
+
if action == "unchanged" or dry_run:
|
|
147
|
+
return EntryChange(str(path), action, previous=previous)
|
|
148
|
+
|
|
149
|
+
if entry is None:
|
|
150
|
+
del servers[name]
|
|
151
|
+
else:
|
|
152
|
+
servers[name] = entry
|
|
153
|
+
data[section] = servers
|
|
154
|
+
|
|
155
|
+
backup = _backup(path)
|
|
156
|
+
_write_atomically(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
157
|
+
if current_entry(path, section, name) != entry:
|
|
158
|
+
raise ConfigFileError(
|
|
159
|
+
f"{path} did not hold the new entry after writing; "
|
|
160
|
+
f"restore it from {backup}."
|
|
161
|
+
)
|
|
162
|
+
return EntryChange(str(path), action, backup, previous)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _command_of(entry: Any) -> str | None:
|
|
166
|
+
"""An entry's ``command``, if it is a string.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
entry: A server entry.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The command, or ``None``.
|
|
173
|
+
"""
|
|
174
|
+
command = entry.get("command") if isinstance(entry, dict) else None
|
|
175
|
+
return command if isinstance(command, str) else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _action(servers: dict[str, Any], name: str, entry: dict[str, Any] | None) -> str:
|
|
179
|
+
"""Name the change that setting ``servers[name] = entry`` would make.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
servers: The current server entries.
|
|
183
|
+
name: The server's key.
|
|
184
|
+
entry: The new entry, or ``None`` for a removal.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
``added``, ``updated``, ``removed`` or ``unchanged``.
|
|
188
|
+
"""
|
|
189
|
+
if entry is None:
|
|
190
|
+
return "removed" if name in servers else "unchanged"
|
|
191
|
+
if servers.get(name) == entry:
|
|
192
|
+
return "unchanged"
|
|
193
|
+
return "updated" if name in servers else "added"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _backup(path: Path) -> Path | None:
|
|
197
|
+
"""Copy ``path`` byte for byte to a timestamped file beside it.
|
|
198
|
+
|
|
199
|
+
The timestamp means a second run never overwrites the first backup,
|
|
200
|
+
which holds the user's original file.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
path: The file about to be overwritten.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The backup's path, or ``None`` if there was no file to back up.
|
|
207
|
+
"""
|
|
208
|
+
if not path.exists():
|
|
209
|
+
return None
|
|
210
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
211
|
+
backup = path.with_name(f"{path.name}.bak-{stamp}")
|
|
212
|
+
shutil.copy2(path, backup)
|
|
213
|
+
return backup
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _write_atomically(path: Path, text: str) -> None:
|
|
217
|
+
"""Replace ``path`` with ``text`` so a reader never sees half a file.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
path: The file to write; its folder is created if missing.
|
|
221
|
+
text: The new content, written as UTF-8 without a BOM.
|
|
222
|
+
"""
|
|
223
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
temp = path.with_name(f"{path.name}.tmp")
|
|
225
|
+
try:
|
|
226
|
+
temp.write_text(text, encoding="utf-8")
|
|
227
|
+
# os.replace is atomic on one volume: the old file or the new, never neither.
|
|
228
|
+
os.replace(temp, path)
|
|
229
|
+
finally:
|
|
230
|
+
temp.unlink(missing_ok=True)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Start the server the way a client will and confirm it lists its tools.
|
|
2
|
+
|
|
3
|
+
This is the MCP stdio handshake a client performs: ``initialize``, the
|
|
4
|
+
``initialized`` notification, then ``tools/list``. Setup runs it before
|
|
5
|
+
writing any config, so a broken launcher is caught here rather than as a
|
|
6
|
+
silent failure inside the client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import queue
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import IO, Any
|
|
19
|
+
|
|
20
|
+
_INITIALIZE = {
|
|
21
|
+
"jsonrpc": "2.0",
|
|
22
|
+
"id": 1,
|
|
23
|
+
"method": "initialize",
|
|
24
|
+
"params": {
|
|
25
|
+
"protocolVersion": "2025-06-18",
|
|
26
|
+
"capabilities": {},
|
|
27
|
+
"clientInfo": {"name": "solidworks-mcp-setup", "version": "1"},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
_INITIALIZED = {"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
31
|
+
_LIST_TOOLS = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
|
|
32
|
+
|
|
33
|
+
# stdout lines read by the pump thread; None marks the end of the stream.
|
|
34
|
+
_Lines = queue.Queue[bytes | None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class HandshakeResult:
|
|
39
|
+
"""The outcome of the start-up check.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
ok: Whether the server answered and listed at least one tool.
|
|
43
|
+
tools: How many tools it listed.
|
|
44
|
+
message: One line for the user.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
ok: bool
|
|
48
|
+
tools: int
|
|
49
|
+
message: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_server(
|
|
53
|
+
command: str, args: list[str], timeout: float = 90.0
|
|
54
|
+
) -> HandshakeResult:
|
|
55
|
+
"""Start ``command args`` and run the MCP handshake over its stdio.
|
|
56
|
+
|
|
57
|
+
The process is always killed afterwards. Its stderr goes to a temporary
|
|
58
|
+
file: an unread pipe can fill up and stall the server.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
command: The launcher to start.
|
|
62
|
+
args: Its arguments (setup passes ``--mock``).
|
|
63
|
+
timeout: Seconds to wait for both replies, in total.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Whether it worked, the tool count and a message.
|
|
67
|
+
"""
|
|
68
|
+
with tempfile.TemporaryFile() as stderr:
|
|
69
|
+
try:
|
|
70
|
+
process = subprocess.Popen(
|
|
71
|
+
[command, *args],
|
|
72
|
+
stdin=subprocess.PIPE,
|
|
73
|
+
stdout=subprocess.PIPE,
|
|
74
|
+
stderr=stderr,
|
|
75
|
+
)
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
return HandshakeResult(
|
|
78
|
+
False, 0, f"Could not start {command}: {exc.strerror or exc}."
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
return _converse(process, time.monotonic() + timeout)
|
|
82
|
+
finally:
|
|
83
|
+
process.kill()
|
|
84
|
+
process.wait()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _converse(process: subprocess.Popen[bytes], deadline: float) -> HandshakeResult:
|
|
88
|
+
"""Send the handshake and read the two replies.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
process: The started server, with piped stdin and stdout.
|
|
92
|
+
deadline: ``time.monotonic()`` value to give up at.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The check's outcome.
|
|
96
|
+
"""
|
|
97
|
+
lines = _line_reader(process.stdout)
|
|
98
|
+
_send(process, _INITIALIZE)
|
|
99
|
+
if _response(lines, 1, deadline) is None:
|
|
100
|
+
return _failed(process, "did not answer the MCP initialize request")
|
|
101
|
+
_send(process, _INITIALIZED)
|
|
102
|
+
_send(process, _LIST_TOOLS)
|
|
103
|
+
reply = _response(lines, 2, deadline)
|
|
104
|
+
if reply is None:
|
|
105
|
+
return _failed(process, "did not answer the tools/list request")
|
|
106
|
+
tools = reply.get("result", {}).get("tools")
|
|
107
|
+
if not isinstance(tools, list) or not tools:
|
|
108
|
+
return HandshakeResult(False, 0, "The server answered but listed no tools.")
|
|
109
|
+
return HandshakeResult(
|
|
110
|
+
True, len(tools), f"The server started and listed {len(tools)} tools."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _failed(process: subprocess.Popen[bytes], what: str) -> HandshakeResult:
|
|
115
|
+
"""Describe a missing reply, with the exit code if the server has exited.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
process: The server process.
|
|
119
|
+
what: What it failed to do.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
A failed result.
|
|
123
|
+
"""
|
|
124
|
+
# stdout can close a moment before the process has finished exiting, so
|
|
125
|
+
# give it a second to report its exit code.
|
|
126
|
+
try:
|
|
127
|
+
code: int | None = process.wait(timeout=1)
|
|
128
|
+
except subprocess.TimeoutExpired:
|
|
129
|
+
code = None
|
|
130
|
+
exited = f" (it exited with code {code})" if code is not None else ""
|
|
131
|
+
return HandshakeResult(False, 0, f"The server {what}{exited}.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _send(process: subprocess.Popen[bytes], message: dict[str, Any]) -> None:
|
|
135
|
+
"""Write one JSON-RPC message as a line on the server's stdin.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
process: The server process.
|
|
139
|
+
message: The JSON-RPC message.
|
|
140
|
+
"""
|
|
141
|
+
assert process.stdin is not None
|
|
142
|
+
try:
|
|
143
|
+
process.stdin.write((json.dumps(message) + "\n").encode())
|
|
144
|
+
process.stdin.flush()
|
|
145
|
+
except OSError:
|
|
146
|
+
# The server has exited; the missing reply reports it.
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _line_reader(stream: IO[bytes] | None) -> _Lines:
|
|
151
|
+
"""Read ``stream`` line by line on a daemon thread.
|
|
152
|
+
|
|
153
|
+
A thread lets the reader wait with a timeout: ``readline`` on a pipe
|
|
154
|
+
blocks with no timeout of its own on Windows.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
stream: The server's stdout.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
A queue of lines, ending with ``None`` when the stream closes.
|
|
161
|
+
"""
|
|
162
|
+
lines: _Lines = queue.Queue()
|
|
163
|
+
|
|
164
|
+
def pump() -> None:
|
|
165
|
+
"""Move each line into the queue, then the end marker."""
|
|
166
|
+
assert stream is not None
|
|
167
|
+
for line in iter(stream.readline, b""):
|
|
168
|
+
lines.put(line)
|
|
169
|
+
lines.put(None)
|
|
170
|
+
|
|
171
|
+
threading.Thread(target=pump, daemon=True).start()
|
|
172
|
+
return lines
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _response(lines: _Lines, request_id: int, deadline: float) -> dict[str, Any] | None:
|
|
176
|
+
"""Wait for the JSON-RPC reply to ``request_id``.
|
|
177
|
+
|
|
178
|
+
Lines that aren't JSON (a stray banner) and other messages (log
|
|
179
|
+
notifications) are skipped.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
lines: The server's stdout lines.
|
|
183
|
+
request_id: The ``id`` of the request being answered.
|
|
184
|
+
deadline: ``time.monotonic()`` value to give up at.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The reply, or ``None`` on timeout or when the stream ends.
|
|
188
|
+
"""
|
|
189
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
190
|
+
try:
|
|
191
|
+
line = lines.get(timeout=remaining)
|
|
192
|
+
except queue.Empty:
|
|
193
|
+
return None
|
|
194
|
+
if line is None:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
message = json.loads(line)
|
|
198
|
+
except ValueError:
|
|
199
|
+
continue
|
|
200
|
+
if isinstance(message, dict) and message.get("id") == request_id:
|
|
201
|
+
return message
|
|
202
|
+
return None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The command a client config runs to start this server.
|
|
2
|
+
|
|
3
|
+
It is always an absolute path, so the config works no matter where the
|
|
4
|
+
client starts it or what its PATH is. Usually it is the launcher in the
|
|
5
|
+
environment setup itself runs from. When that environment is a temporary
|
|
6
|
+
one in uv's cache (``uvx solidworks-mcp-python setup``), it is ``uvx``
|
|
7
|
+
instead, because the cache folder disappears when uv cleans it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# The PyPI name; `uvx solidworks-mcp` would install a different project.
|
|
20
|
+
PACKAGE = "solidworks-mcp-python"
|
|
21
|
+
|
|
22
|
+
# uvx runs a tool from `<uv cache>/archive-v<n>/<hash>/`.
|
|
23
|
+
_UV_ARCHIVE = re.compile(r"archive-v\d+", re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def in_uv_cache(python: str) -> bool:
|
|
27
|
+
"""Whether an interpreter lives in a temporary environment in uv's cache.
|
|
28
|
+
|
|
29
|
+
``uv tool install`` environments (under ``uv\\tools``) and project
|
|
30
|
+
``.venv`` folders are persistent and don't count.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
python: The interpreter's path.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True when a ``cache`` folder holds an ``archive-v<n>`` folder on the
|
|
37
|
+
path to it.
|
|
38
|
+
"""
|
|
39
|
+
parts = [part.lower() for part in Path(python).parts]
|
|
40
|
+
return any(
|
|
41
|
+
_UV_ARCHIVE.fullmatch(part) and "cache" in parts[:index]
|
|
42
|
+
for index, part in enumerate(parts)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_uvx(
|
|
47
|
+
uv: str | None = None, which: Callable[[str], str | None] = shutil.which
|
|
48
|
+
) -> str | None:
|
|
49
|
+
"""Find the ``uvx`` launcher, by absolute path.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
uv: The uv executable; defaults to the ``UV`` variable uv sets for
|
|
53
|
+
the programs it runs.
|
|
54
|
+
which: PATH lookup, replaceable in tests.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
``uvx`` beside uv if it is there, else on the PATH, else ``None``.
|
|
58
|
+
"""
|
|
59
|
+
uv = uv if uv is not None else os.environ.get("UV")
|
|
60
|
+
if uv:
|
|
61
|
+
for name in ("uvx.exe", "uvx"):
|
|
62
|
+
beside = Path(uv).parent / name
|
|
63
|
+
if beside.is_file():
|
|
64
|
+
return str(beside)
|
|
65
|
+
return which("uvx")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def server_command(
|
|
69
|
+
mock: bool = False,
|
|
70
|
+
python: str | None = None,
|
|
71
|
+
lasting: bool = True,
|
|
72
|
+
uvx_finder: Callable[[], str | None] = find_uvx,
|
|
73
|
+
) -> tuple[str, list[str]]:
|
|
74
|
+
"""The command and arguments that start this server.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
mock: Add ``--mock``, so the server answers without SolidWorks.
|
|
78
|
+
python: The interpreter to look beside; defaults to ``sys.executable``.
|
|
79
|
+
lasting: Return a command that outlives a temporary uv environment.
|
|
80
|
+
``False`` gives the launcher that runs right now, which is what
|
|
81
|
+
the start-up check needs.
|
|
82
|
+
uvx_finder: Finds ``uvx``, replaceable in tests.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
``(command, args)``: ``uvx`` running this package when ``lasting`` and
|
|
86
|
+
the interpreter is in uv's cache; otherwise the ``solidworks-mcp``
|
|
87
|
+
console script in the environment's scripts folder or, without one,
|
|
88
|
+
this Python running the server module.
|
|
89
|
+
"""
|
|
90
|
+
python = python or sys.executable
|
|
91
|
+
flags = ["--mock"] if mock else []
|
|
92
|
+
if lasting and in_uv_cache(python):
|
|
93
|
+
uvx = uvx_finder()
|
|
94
|
+
if uvx:
|
|
95
|
+
return uvx, [PACKAGE, *flags]
|
|
96
|
+
scripts = Path(python).parent
|
|
97
|
+
# `.exe` on Windows; the bare name elsewhere.
|
|
98
|
+
for name in ("solidworks-mcp.exe", "solidworks-mcp"):
|
|
99
|
+
launcher = scripts / name
|
|
100
|
+
if launcher.is_file():
|
|
101
|
+
return str(launcher), flags
|
|
102
|
+
return python, ["-m", "solidworks_mcp.server", *flags]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Recognise older, hand-made config entries that start this same server.
|
|
2
|
+
|
|
3
|
+
Before ``solidworks-mcp setup`` existed, the README had people add entries
|
|
4
|
+
under names of their choosing, launched through ``run-mcp.ps1``,
|
|
5
|
+
``start_local_server_claude.py`` and similar. Left in place beside the new
|
|
6
|
+
``solidworks`` entry, they load every tool twice, and the PowerShell ones
|
|
7
|
+
can break the stdio connection.
|
|
8
|
+
|
|
9
|
+
The rule reads only an entry's ``command`` and ``args``. Secrets live in
|
|
10
|
+
``env`` and ``headers``, which are never looked at. It matches this
|
|
11
|
+
project's own launcher names exactly, so another vendor's SolidWorks server
|
|
12
|
+
is never mistaken for this one.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import PureWindowsPath
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .clients import SERVER_NAME
|
|
21
|
+
|
|
22
|
+
# Script files that only this project ships.
|
|
23
|
+
_OLD_SCRIPTS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"run-mcp.ps1",
|
|
26
|
+
"run-mcp-claude.ps1",
|
|
27
|
+
"start_local_server.py",
|
|
28
|
+
"start_local_server_claude.py",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
# This package's console scripts, with or without `.exe`.
|
|
32
|
+
_OUR_LAUNCHERS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"solidworks-mcp",
|
|
35
|
+
"solidworks-mcp.exe",
|
|
36
|
+
"solidworks-mcp-python",
|
|
37
|
+
"solidworks-mcp-python.exe",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
# `python -m <module>` forms that start this server.
|
|
41
|
+
_OUR_MODULES = frozenset({"solidworks_mcp.server", "solidworks_mcp"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def launcher_name(entry: Any) -> str:
|
|
45
|
+
"""The program file an entry starts, for showing to the user.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
entry: A server entry from a client config.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The file name of its ``command`` (e.g. ``powershell.exe``), without the
|
|
52
|
+
folder; ``?`` if it has none.
|
|
53
|
+
"""
|
|
54
|
+
command = entry.get("command") if isinstance(entry, dict) else None
|
|
55
|
+
return (
|
|
56
|
+
PureWindowsPath(command).name if isinstance(command, str) and command else "?"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def starts_this_server(entry: Any) -> bool:
|
|
61
|
+
"""Whether a config entry launches this project's server.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
entry: A server entry from a client config.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True when its command is one of this package's launchers, or its
|
|
68
|
+
arguments name one of this project's launch scripts or modules.
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(entry, dict):
|
|
71
|
+
return False
|
|
72
|
+
command = entry.get("command")
|
|
73
|
+
args = entry.get("args")
|
|
74
|
+
words = [command] if isinstance(command, str) else []
|
|
75
|
+
if isinstance(args, list):
|
|
76
|
+
words += [a for a in args if isinstance(a, str)]
|
|
77
|
+
|
|
78
|
+
# PureWindowsPath splits on both / and \, whatever OS this runs on.
|
|
79
|
+
names = {PureWindowsPath(word).name.lower() for word in words}
|
|
80
|
+
if names & _OLD_SCRIPTS or names & _OUR_LAUNCHERS:
|
|
81
|
+
return True
|
|
82
|
+
# `-m solidworks_mcp.server`: the word after each -m is a module name.
|
|
83
|
+
modules = {words[i + 1] for i, word in enumerate(words[:-1]) if word == "-m"}
|
|
84
|
+
return bool(modules & _OUR_MODULES)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def old_entries(servers: dict[str, Any]) -> list[str]:
|
|
88
|
+
"""Name every entry, besides ``solidworks``, that starts this server.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
servers: A config's server entries (``mcpServers`` or ``servers``).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Their names, in the config's order.
|
|
95
|
+
"""
|
|
96
|
+
return [
|
|
97
|
+
name
|
|
98
|
+
for name, entry in servers.items()
|
|
99
|
+
if name != SERVER_NAME and starts_this_server(entry)
|
|
100
|
+
]
|