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.
Files changed (115) hide show
  1. solidworks_mcp/__init__.py +57 -0
  2. solidworks_mcp/adapters/__init__.py +40 -0
  3. solidworks_mcp/adapters/base.py +1882 -0
  4. solidworks_mcp/adapters/circuit_breaker.py +2052 -0
  5. solidworks_mcp/adapters/com_executor.py +252 -0
  6. solidworks_mcp/adapters/complexity_analyzer.py +272 -0
  7. solidworks_mcp/adapters/connection_pool.py +1884 -0
  8. solidworks_mcp/adapters/factory.py +366 -0
  9. solidworks_mcp/adapters/intelligent_router.py +218 -0
  10. solidworks_mcp/adapters/mock_adapter.py +4016 -0
  11. solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
  12. solidworks_mcp/adapters/solidworks/__init__.py +17 -0
  13. solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
  14. solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
  15. solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
  16. solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
  17. solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
  18. solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
  19. solidworks_mcp/adapters/solidworks/features.py +3362 -0
  20. solidworks_mcp/adapters/solidworks/io.py +3649 -0
  21. solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
  22. solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
  23. solidworks_mcp/adapters/solidworks/selection.py +308 -0
  24. solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
  25. solidworks_mcp/adapters/sw_feature_errors.py +66 -0
  26. solidworks_mcp/adapters/sw_type_info.py +317 -0
  27. solidworks_mcp/adapters/vba_adapter.py +318 -0
  28. solidworks_mcp/adapters/vba_macro_executor.py +215 -0
  29. solidworks_mcp/agents/__init__.py +68 -0
  30. solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
  31. solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
  32. solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
  33. solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
  34. solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
  35. solidworks_mcp/agents/harness.py +216 -0
  36. solidworks_mcp/agents/history_db.py +1508 -0
  37. solidworks_mcp/agents/retrieval_index.py +164 -0
  38. solidworks_mcp/agents/schemas.py +165 -0
  39. solidworks_mcp/agents/smoke_test.py +285 -0
  40. solidworks_mcp/agents/soc_exporter.py +650 -0
  41. solidworks_mcp/agents/soc_pickup.py +332 -0
  42. solidworks_mcp/agents/soc_rewind.py +221 -0
  43. solidworks_mcp/agents/vector_rag.py +665 -0
  44. solidworks_mcp/cache/__init__.py +5 -0
  45. solidworks_mcp/cache/response_cache.py +164 -0
  46. solidworks_mcp/config.py +729 -0
  47. solidworks_mcp/exceptions.py +37 -0
  48. solidworks_mcp/security/__init__.py +68 -0
  49. solidworks_mcp/security/auth.py +128 -0
  50. solidworks_mcp/security/cors.py +27 -0
  51. solidworks_mcp/security/rate_limiting.py +90 -0
  52. solidworks_mcp/security/runtime.py +155 -0
  53. solidworks_mcp/server.py +777 -0
  54. solidworks_mcp/server_cli_fixed.py +120 -0
  55. solidworks_mcp/setup/__init__.py +1 -0
  56. solidworks_mcp/setup/cli.py +399 -0
  57. solidworks_mcp/setup/clients.py +157 -0
  58. solidworks_mcp/setup/config_file.py +230 -0
  59. solidworks_mcp/setup/handshake.py +202 -0
  60. solidworks_mcp/setup/launcher.py +102 -0
  61. solidworks_mcp/setup/legacy.py +100 -0
  62. solidworks_mcp/setup/registration.py +193 -0
  63. solidworks_mcp/setup/solidworks.py +49 -0
  64. solidworks_mcp/tools/__init__.py +64 -0
  65. solidworks_mcp/tools/analysis.py +723 -0
  66. solidworks_mcp/tools/automation.py +578 -0
  67. solidworks_mcp/tools/docs_discovery.py +1476 -0
  68. solidworks_mcp/tools/drawing.py +1015 -0
  69. solidworks_mcp/tools/drawing_analysis.py +456 -0
  70. solidworks_mcp/tools/drawing_automation.py +627 -0
  71. solidworks_mcp/tools/engineering_data.py +466 -0
  72. solidworks_mcp/tools/error_classifier.py +232 -0
  73. solidworks_mcp/tools/export.py +833 -0
  74. solidworks_mcp/tools/file_facts.py +40 -0
  75. solidworks_mcp/tools/file_management.py +1473 -0
  76. solidworks_mcp/tools/input_compat.py +17 -0
  77. solidworks_mcp/tools/macro_recording.py +464 -0
  78. solidworks_mcp/tools/modeling.py +2360 -0
  79. solidworks_mcp/tools/sketching.py +1422 -0
  80. solidworks_mcp/tools/template_management.py +367 -0
  81. solidworks_mcp/tools/tool_boundary.py +280 -0
  82. solidworks_mcp/tools/vba_generation.py +1145 -0
  83. solidworks_mcp/ui/__init__.py +9 -0
  84. solidworks_mcp/ui/local_llm.py +725 -0
  85. solidworks_mcp/ui/routers/__init__.py +10 -0
  86. solidworks_mcp/ui/routers/checkpoint.py +24 -0
  87. solidworks_mcp/ui/routers/docs.py +47 -0
  88. solidworks_mcp/ui/routers/llm.py +83 -0
  89. solidworks_mcp/ui/routers/local_model.py +69 -0
  90. solidworks_mcp/ui/routers/model.py +75 -0
  91. solidworks_mcp/ui/routers/preview.py +38 -0
  92. solidworks_mcp/ui/routers/session.py +161 -0
  93. solidworks_mcp/ui/routers/viewer.py +154 -0
  94. solidworks_mcp/ui/schemas.py +502 -0
  95. solidworks_mcp/ui/server.py +298 -0
  96. solidworks_mcp/ui/service.py +481 -0
  97. solidworks_mcp/ui/services/__init__.py +136 -0
  98. solidworks_mcp/ui/services/_utils.py +958 -0
  99. solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
  100. solidworks_mcp/ui/services/docs_service.py +282 -0
  101. solidworks_mcp/ui/services/llm_service.py +956 -0
  102. solidworks_mcp/ui/services/model_service.py +459 -0
  103. solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
  104. solidworks_mcp/ui/services/preview_service.py +489 -0
  105. solidworks_mcp/ui/services/session_service.py +1389 -0
  106. solidworks_mcp/utils/__init__.py +9 -0
  107. solidworks_mcp/utils/feature_tree_classifier.py +342 -0
  108. solidworks_mcp/utils/logging.py +76 -0
  109. solidworks_mcp/utils/validation.py +77 -0
  110. solidworks_mcp/version.py +8 -0
  111. solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
  112. solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
  113. solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
  114. solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
  115. 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
+ ]