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,367 @@
1
+ """Template Management tools for SolidWorks MCP Server.
2
+
3
+ Provides tools for managing SolidWorks templates including extraction, application,
4
+ comparison, and library management.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from fastmcp import FastMCP
10
+ from pydantic import BaseModel, Field
11
+
12
+ from ..adapters.base import SolidWorksAdapter
13
+ from .file_facts import file_fact_snapshot
14
+ from .input_compat import CompatInput
15
+ from .tool_boundary import (
16
+ ToolOperationError,
17
+ UnsupportedOperationError,
18
+ require_capability,
19
+ require_success,
20
+ with_tool_boundary,
21
+ )
22
+
23
+ # Input schemas for template management
24
+
25
+
26
+ class TemplateExtractionInput(BaseModel):
27
+ """Input schema for extracting template from model.
28
+
29
+ Attributes:
30
+ include_custom_properties (bool): The include custom properties value.
31
+ include_dimensions (bool): The include dimensions value.
32
+ save_path (str): The save path value.
33
+ source_model (str): The source model value.
34
+ template_name (str): The template name value.
35
+ template_type (str): The template type value.
36
+ """
37
+
38
+ source_model: str = Field(description="Path to source model file")
39
+ template_name: str = Field(description="Name for the extracted template")
40
+ template_type: str = Field(description="Template type (part, assembly, drawing)")
41
+ save_path: str = Field(description="Path to save the template file")
42
+ include_custom_properties: bool = Field(
43
+ default=True, description="Include custom properties"
44
+ )
45
+ include_dimensions: bool = Field(default=True, description="Include dimensions")
46
+
47
+
48
+ class TemplateApplicationInput(BaseModel):
49
+ """Input schema for applying template to model.
50
+
51
+ Attributes:
52
+ apply_dimensions (bool): The apply dimensions value.
53
+ apply_materials (bool): The apply materials value.
54
+ overwrite_existing (bool): The overwrite existing value.
55
+ target_model (str): The target model value.
56
+ template_path (str): The template path value.
57
+ """
58
+
59
+ template_path: str = Field(description="Path to template file")
60
+ target_model: str = Field(description="Path to target model")
61
+ overwrite_existing: bool = Field(
62
+ default=False, description="Overwrite existing properties"
63
+ )
64
+ apply_dimensions: bool = Field(
65
+ default=True, description="Apply dimension formatting"
66
+ )
67
+ apply_materials: bool = Field(default=True, description="Apply material settings")
68
+
69
+
70
+ class TemplateBatchInput(BaseModel):
71
+ """Input schema for batch template operations.
72
+
73
+ Attributes:
74
+ backup_originals (bool): The backup originals value.
75
+ file_pattern (str): The file pattern value.
76
+ recursive (bool): The recursive value.
77
+ source_folder (str): The source folder value.
78
+ template_path (str): The template path value.
79
+ """
80
+
81
+ template_path: str = Field(description="Path to template file")
82
+ source_folder: str = Field(description="Folder containing target models")
83
+ file_pattern: str = Field(default="*.sldprt", description="File pattern to match")
84
+ recursive: bool = Field(default=True, description="Process subfolders")
85
+ backup_originals: bool = Field(default=True, description="Create backup copies")
86
+
87
+
88
+ class TemplateComparisonInput(CompatInput):
89
+ """Input schema for comparing templates.
90
+
91
+ Attributes:
92
+ comparison_depth (str): The comparison depth value.
93
+ comparison_type (str): The comparison type value.
94
+ generate_report (bool): The generate report value.
95
+ include_dimensions (bool): The include dimensions value.
96
+ include_materials (bool): The include materials value.
97
+ include_properties (bool): The include properties value.
98
+ template1_path (str): The template1 path value.
99
+ template2_path (str): The template2 path value.
100
+ """
101
+
102
+ template1_path: str = Field(description="Path to first template")
103
+ template2_path: str = Field(description="Path to second template")
104
+ comparison_type: str = Field(
105
+ default="full", description="Comparison type (full, properties, dimensions)"
106
+ )
107
+ comparison_depth: str = Field(default="full", description="Comparison depth alias")
108
+ include_properties: bool = Field(default=True, description="Include properties")
109
+ include_dimensions: bool = Field(default=True, description="Include dimensions")
110
+ include_materials: bool = Field(default=True, description="Include materials")
111
+ generate_report: bool = Field(
112
+ default=True, description="Generate comparison report"
113
+ )
114
+
115
+
116
+ async def register_template_management_tools(
117
+ mcp: FastMCP, adapter: SolidWorksAdapter, config: Any
118
+ ) -> int:
119
+ """Register template management tools with FastMCP.
120
+
121
+ Args:
122
+ mcp (FastMCP): The mcp value.
123
+ adapter (SolidWorksAdapter): Adapter instance used for the operation.
124
+ config (Any): Configuration values for the operation.
125
+
126
+ Returns:
127
+ int: The computed numeric result.
128
+
129
+ Example:
130
+ >>> tool_count = await register_template_management_tools(mcp, adapter, config)
131
+ """
132
+ mcp = with_tool_boundary(mcp)
133
+ tool_count = 0
134
+
135
+ @mcp.tool()
136
+ async def extract_template(input_data: TemplateExtractionInput) -> dict[str, Any]:
137
+ """Extract template from existing SolidWorks model.
138
+
139
+ Args:
140
+ input_data (TemplateExtractionInput): The input data value.
141
+
142
+ Returns:
143
+ dict[str, Any]: A dictionary containing the resulting values.
144
+
145
+ Example:
146
+ >>> result = await extract_template(extraction_input)
147
+ """
148
+ require_capability(
149
+ adapter,
150
+ "extract_template",
151
+ "Active adapter does not support extract_template; no "
152
+ f"template was extracted from {input_data.source_model}.",
153
+ )
154
+ result = await adapter.extract_template(input_data.model_dump())
155
+ require_success(result, "Failed to extract template")
156
+ return {
157
+ "status": "success",
158
+ "message": f"Template '{input_data.template_name}' extracted from {input_data.source_model}",
159
+ "data": result.data,
160
+ "execution_time": result.execution_time,
161
+ }
162
+
163
+ @mcp.tool()
164
+ async def apply_template(input_data: TemplateApplicationInput) -> dict[str, Any]:
165
+ """Apply a template to an existing SolidWorks model.
166
+
167
+ This tool applies saved template settings including properties, dimensions, and
168
+ formatting to the target model.
169
+
170
+ Args:
171
+ input_data (TemplateApplicationInput): The input data value.
172
+
173
+ Returns:
174
+ dict[str, Any]: A dictionary containing the resulting values.
175
+
176
+ Example:
177
+ >>> result = await apply_template(application_input)
178
+ """
179
+ require_capability(
180
+ adapter,
181
+ "apply_template",
182
+ "Active adapter does not support apply_template; "
183
+ f"{input_data.target_model} was not changed.",
184
+ )
185
+ result = await adapter.apply_template(input_data.model_dump())
186
+ require_success(result, "Failed to apply template")
187
+ return {
188
+ "status": "success",
189
+ "message": f"Template applied to {input_data.target_model}",
190
+ "data": result.data,
191
+ "execution_time": result.execution_time,
192
+ }
193
+
194
+ @mcp.tool()
195
+ async def batch_apply_template(input_data: TemplateBatchInput) -> dict[str, Any]:
196
+ """Apply template to multiple models in batch.
197
+
198
+ This tool processes multiple SolidWorks files and applies the same template
199
+ configuration to all matching files.
200
+
201
+ Args:
202
+ input_data (TemplateBatchInput): The input data value.
203
+
204
+ Returns:
205
+ dict[str, Any]: A dictionary containing the resulting values.
206
+
207
+ Example:
208
+ >>> result = await batch_apply_template(batch_input)
209
+ """
210
+ require_capability(
211
+ adapter,
212
+ "batch_apply_template",
213
+ "Active adapter does not support batch_apply_template; "
214
+ f"no files in {input_data.source_folder} were changed.",
215
+ )
216
+ result = await adapter.batch_apply_template(input_data.model_dump())
217
+ require_success(result, "Failed batch template application")
218
+ return {
219
+ "status": "success",
220
+ "message": "Batch template application completed",
221
+ "data": result.data,
222
+ "execution_time": result.execution_time,
223
+ }
224
+
225
+ @mcp.tool()
226
+ async def compare_templates(input_data: TemplateComparisonInput) -> dict[str, Any]:
227
+ """Compare two templates and generate difference report.
228
+
229
+ This tool analyzes differences between templates to help understand variations in
230
+ formatting and properties.
231
+
232
+ Args:
233
+ input_data (TemplateComparisonInput): The input data value.
234
+
235
+ Returns:
236
+ dict[str, Any]: A dictionary containing the resulting values.
237
+
238
+ Example:
239
+ >>> result = await compare_templates(comparison_input)
240
+
241
+ Note:
242
+ When no adapter can parse template internals, this falls back to
243
+ a filesystem-level comparison only (existence, size, modified
244
+ time, byte-identical check) — it does not report property,
245
+ dimension, or formatting differences it cannot actually read.
246
+ """
247
+ if hasattr(adapter, "compare_templates"):
248
+ result = await adapter.compare_templates(input_data.model_dump())
249
+ require_success(result, "Failed to compare templates")
250
+ return {
251
+ "status": "success",
252
+ "message": "Template comparison completed",
253
+ "data": result.data,
254
+ "execution_time": result.execution_time,
255
+ }
256
+
257
+ file_1 = file_fact_snapshot(input_data.template1_path)
258
+ file_2 = file_fact_snapshot(input_data.template2_path)
259
+
260
+ missing = [
261
+ path
262
+ for path, snapshot in (
263
+ (input_data.template1_path, file_1),
264
+ (input_data.template2_path, file_2),
265
+ )
266
+ if snapshot is None
267
+ ]
268
+ if missing:
269
+ raise ToolOperationError(
270
+ "Cannot compare templates; file(s) not found on "
271
+ f"disk: {', '.join(missing)}. No template-property "
272
+ "diff was performed."
273
+ )
274
+
275
+ assert file_1 is not None
276
+ assert file_2 is not None
277
+ identical = file_1["sha256"] == file_2["sha256"]
278
+
279
+ return {
280
+ "status": "success",
281
+ "message": (
282
+ "Template files are byte-identical"
283
+ if identical
284
+ else "Template files differ on disk"
285
+ ),
286
+ "comparison": {
287
+ "file_1": file_1,
288
+ "file_2": file_2,
289
+ "identical": identical,
290
+ "size_delta_bytes": file_2["size_bytes"] - file_1["size_bytes"],
291
+ },
292
+ "note": (
293
+ "This is a filesystem-level comparison only; template "
294
+ "properties, dimensions, and formatting were not "
295
+ "analyzed."
296
+ ),
297
+ }
298
+
299
+ @mcp.tool()
300
+ async def save_to_template_library(input_data: dict[str, Any]) -> dict[str, Any]:
301
+ """Save template to the organization's template library.
302
+
303
+ This tool manages a centralized template library with categorization and version
304
+ control.
305
+
306
+ Args:
307
+ input_data (dict[str, Any]): The input data value.
308
+
309
+ Returns:
310
+ dict[str, Any]: A dictionary containing the resulting values.
311
+
312
+ Example:
313
+ >>> result = await save_to_template_library(library_input)
314
+ """
315
+
316
+ if hasattr(adapter, "save_to_template_library"):
317
+ result = await adapter.save_to_template_library(input_data)
318
+ require_success(result, "Failed to save to library")
319
+ return {
320
+ "status": "success",
321
+ "message": "Template saved to library",
322
+ "data": result.data,
323
+ "execution_time": result.execution_time,
324
+ }
325
+
326
+ template_name = input_data.get("template_name", "")
327
+
328
+ raise UnsupportedOperationError(
329
+ "Active adapter does not support save_to_template_library; "
330
+ f"'{template_name}' was not saved to any library. There is "
331
+ "no local template registry — this tool needs a real "
332
+ "adapter-backed library service."
333
+ )
334
+
335
+ @mcp.tool()
336
+ async def list_template_library(input_data: dict[str, Any]) -> dict[str, Any]:
337
+ """List available templates from the template library.
338
+
339
+ This tool provides browsing and searching capabilities for the organization's template
340
+ library.
341
+
342
+ Args:
343
+ input_data (dict[str, Any]): The input data value.
344
+
345
+ Returns:
346
+ dict[str, Any]: A dictionary containing the resulting values.
347
+
348
+ Example:
349
+ >>> result = await list_template_library(list_input)
350
+ """
351
+ require_capability(
352
+ adapter,
353
+ "list_template_library",
354
+ "Active adapter does not support list_template_library; "
355
+ "there is no local template registry to list.",
356
+ )
357
+ result = await adapter.list_template_library(input_data)
358
+ require_success(result, "Failed to list template library")
359
+ return {
360
+ "status": "success",
361
+ "message": "Template library listed successfully",
362
+ "data": result.data,
363
+ "execution_time": result.execution_time,
364
+ }
365
+
366
+ tool_count = 6 # Template management tools
367
+ return tool_count
@@ -0,0 +1,280 @@
1
+ """The single error boundary every MCP tool runs inside (spec: tools/error-boundary).
2
+
3
+ Tools contain business logic only. They:
4
+
5
+ * parse input with :func:`normalize_input` (raises :class:`InvalidInputError`),
6
+ * check adapter results with :func:`require_success` (raises
7
+ :class:`ToolOperationError`), and
8
+ * return their success payload.
9
+
10
+ :func:`tool_boundary` turns anything raised into the uniform error payload -
11
+ ``status``, ``message``, ``error_type``, ``hint`` and ``execution_time`` (the
12
+ adapter's timing, else the tool's own wall time) - using :class:`ErrorClassifier` to name the failure.
13
+ :class:`ToolRegistrar` applies the boundary to every tool at registration, so no
14
+ tool can forget it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import time
21
+ from collections.abc import Awaitable, Callable
22
+ from typing import Any, cast
23
+
24
+ from loguru import logger
25
+ from pydantic import BaseModel, ValidationError
26
+
27
+ from ..adapters.base import AdapterResult
28
+ from .error_classifier import (
29
+ INTERNAL,
30
+ INVALID_INPUT,
31
+ NOT_SUPPORTED,
32
+ NOT_SUPPORTED_HINT,
33
+ Classification,
34
+ ErrorClassifier,
35
+ )
36
+
37
+ _INVALID_INPUT_HINT = (
38
+ "Fix the named field(s) and call the tool again; see the tool's input "
39
+ "schema for the expected types and units."
40
+ )
41
+ _CLASSIFIER = ErrorClassifier()
42
+
43
+
44
+ class InvalidInputError(ValueError):
45
+ """The tool's input failed validation; the adapter was not called."""
46
+
47
+
48
+ class ToolOperationError(Exception):
49
+ """An adapter operation reported failure.
50
+
51
+ Attributes:
52
+ execution_time: The adapter's timing for the failed call, if any.
53
+ data: Diagnostic data that explains the failure (e.g. rebuild errors),
54
+ returned to the caller under ``data``.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ message: str,
60
+ execution_time: float | None = None,
61
+ data: Any = None,
62
+ ) -> None:
63
+ super().__init__(message)
64
+ self.execution_time = execution_time
65
+ self.data = data
66
+
67
+
68
+ class UnsupportedOperationError(Exception):
69
+ """The active adapter has no capability for the requested operation."""
70
+
71
+
72
+ def require_capability(adapter: Any, method: str, message: str) -> None:
73
+ """Raise :class:`UnsupportedOperationError` unless ``adapter`` has ``method``.
74
+
75
+ Args:
76
+ adapter: The active SolidWorks adapter.
77
+ method: The adapter method the tool needs.
78
+ message: What was not done, and what to do instead.
79
+ """
80
+ if not hasattr(adapter, method):
81
+ raise UnsupportedOperationError(message)
82
+
83
+
84
+ def normalize_input[TModel: BaseModel](
85
+ input_data: Any, model_type: type[TModel]
86
+ ) -> TModel:
87
+ """Validate ``input_data`` as ``model_type``.
88
+
89
+ Args:
90
+ input_data: A ``model_type`` instance, a mapping of its fields, another
91
+ pydantic model (converted through ``model_dump``), or ``None`` for
92
+ a tool whose fields all have defaults.
93
+ model_type: The tool's pydantic input model.
94
+
95
+ Returns:
96
+ TModel: The validated model.
97
+
98
+ Raises:
99
+ InvalidInputError: Naming the offending field(s).
100
+ """
101
+ if isinstance(input_data, model_type):
102
+ return input_data
103
+ if input_data is None:
104
+ input_data = {}
105
+ elif not isinstance(input_data, dict) and hasattr(input_data, "model_dump"):
106
+ input_data = input_data.model_dump()
107
+ try:
108
+ return model_type.model_validate(input_data)
109
+ except ValidationError as exc: # also wraps ValueErrors from validators
110
+ raise InvalidInputError(describe_validation_error(exc)) from exc
111
+
112
+
113
+ def describe_validation_error(exc: ValidationError) -> str:
114
+ """Render a pydantic error as ``field: problem; field: problem``."""
115
+ parts = []
116
+ for error in exc.errors():
117
+ field = ".".join(str(p) for p in error.get("loc", ())) or "input"
118
+ parts.append(f"{field}: {error.get('msg', 'invalid')}")
119
+ return "; ".join(parts)
120
+
121
+
122
+ def require_success[TResult](
123
+ result: AdapterResult[TResult],
124
+ failure_message: str = "Operation failed",
125
+ *,
126
+ context: str | None = None,
127
+ with_data: bool = False,
128
+ ) -> AdapterResult[TResult]:
129
+ """Return ``result`` when it succeeded; raise otherwise.
130
+
131
+ Mirrors the ``if result.is_success: ... else: error`` the tools used before
132
+ the boundary, so anything that is not a success - including a warning - is
133
+ reported as a failure. Tools that treat a warning as success check
134
+ ``result.status`` themselves before calling this.
135
+
136
+ Args:
137
+ result: The adapter result to check.
138
+ failure_message: Message used when the adapter gave none.
139
+ context: Prefix naming what failed, e.g. ``"Failed to create part"``,
140
+ giving ``"<context>: <adapter error>"``.
141
+ with_data: Return the adapter's ``data`` with the error, for tools whose
142
+ failure data is the diagnosis (e.g. ``rebuild_model``).
143
+
144
+ Raises:
145
+ ToolOperationError: Carrying the adapter's message and timing.
146
+ """
147
+ if not result.is_success:
148
+ message = str(result.error or failure_message)
149
+ raise ToolOperationError(
150
+ f"{context}: {message}" if context else message,
151
+ result.execution_time,
152
+ result.data if with_data else None,
153
+ )
154
+ return result
155
+
156
+
157
+ def error_payload(
158
+ message: str,
159
+ classification: Classification,
160
+ execution_time: float | None = None,
161
+ data: Any = None,
162
+ ) -> dict[str, Any]:
163
+ """Build the spec's error payload."""
164
+ payload: dict[str, Any] = {
165
+ "status": "error",
166
+ "message": message,
167
+ "error_type": classification.error_type,
168
+ "hint": classification.hint,
169
+ }
170
+ payload["execution_time"] = execution_time
171
+ if data is not None:
172
+ payload["data"] = data
173
+ return payload
174
+
175
+
176
+ def tool_boundary(
177
+ fn: Callable[..., Awaitable[dict[str, Any]]],
178
+ classifier: ErrorClassifier = _CLASSIFIER,
179
+ ) -> Callable[..., Awaitable[dict[str, Any]]]:
180
+ """Wrap an async tool so every failure becomes an error payload.
181
+
182
+ ``functools.wraps`` keeps the signature, so FastMCP builds the same input
183
+ schema for the wrapped tool.
184
+ """
185
+ tool_name = fn.__name__
186
+
187
+ @functools.wraps(fn)
188
+ async def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
189
+ started = time.perf_counter()
190
+
191
+ def elapsed() -> float:
192
+ return time.perf_counter() - started
193
+
194
+ try:
195
+ return await fn(*args, **kwargs)
196
+ except InvalidInputError as exc:
197
+ return error_payload(
198
+ f"Invalid input: {exc}",
199
+ Classification(INVALID_INPUT, _INVALID_INPUT_HINT),
200
+ elapsed(),
201
+ )
202
+ except ValidationError as exc:
203
+ return error_payload(
204
+ f"Invalid input: {describe_validation_error(exc)}",
205
+ Classification(INVALID_INPUT, _INVALID_INPUT_HINT),
206
+ elapsed(),
207
+ )
208
+ except UnsupportedOperationError as exc:
209
+ return error_payload(
210
+ str(exc),
211
+ Classification(NOT_SUPPORTED, NOT_SUPPORTED_HINT),
212
+ elapsed(),
213
+ )
214
+ except ToolOperationError as exc:
215
+ message = str(exc)
216
+ # Adapter guards (wrong document type, no active model) reject a
217
+ # call before timing it; fall back to the tool's own wall time.
218
+ timing = exc.execution_time if exc.execution_time is not None else elapsed()
219
+ return error_payload(
220
+ message, classifier.classify(message), timing, exc.data
221
+ )
222
+ except Exception as exc:
223
+ logger.exception("Tool {} raised", tool_name)
224
+ message = f"{tool_name} failed: {type(exc).__name__}: {exc}"
225
+ return error_payload(
226
+ message,
227
+ classifier.classify(str(exc), exc, fallback=INTERNAL),
228
+ elapsed(),
229
+ )
230
+
231
+ wrapper.__tool_boundary__ = True # type: ignore[attr-defined]
232
+ return wrapper
233
+
234
+
235
+ def has_tool_boundary(fn: Any) -> bool:
236
+ """True when ``fn`` was wrapped by :func:`tool_boundary`."""
237
+ return bool(getattr(fn, "__tool_boundary__", False))
238
+
239
+
240
+ class ToolRegistrar:
241
+ """A FastMCP stand-in whose ``tool()`` applies :func:`tool_boundary` first.
242
+
243
+ Everything else is forwarded to the wrapped server unchanged.
244
+ """
245
+
246
+ def __init__(self, mcp: Any, classifier: ErrorClassifier = _CLASSIFIER) -> None:
247
+ self._mcp = mcp
248
+ self._classifier = classifier
249
+
250
+ @classmethod
251
+ def of(cls, mcp: Any) -> ToolRegistrar:
252
+ """Wrap ``mcp`` unless it already is a registrar (idempotent).
253
+
254
+ Every ``register_<area>_tools`` starts with ``mcp = ToolRegistrar.of(mcp)``
255
+ so its tools get the boundary however the module is registered.
256
+ """
257
+ return mcp if isinstance(mcp, cls) else cls(mcp)
258
+
259
+ def tool(self, *args: Any, **kwargs: Any) -> Any:
260
+ """Same contract as ``FastMCP.tool``, with the boundary applied."""
261
+ if args and callable(args[0]) and len(args) == 1 and not kwargs:
262
+ return self._mcp.tool()(tool_boundary(args[0], self._classifier))
263
+ register = self._mcp.tool(*args, **kwargs)
264
+
265
+ def decorator(fn: Callable[..., Awaitable[dict[str, Any]]]) -> Any:
266
+ return register(tool_boundary(fn, self._classifier))
267
+
268
+ return decorator
269
+
270
+ def __getattr__(self, name: str) -> Any:
271
+ return getattr(self._mcp, name)
272
+
273
+
274
+ def with_tool_boundary[TServer](mcp: TServer) -> TServer:
275
+ """Return ``mcp`` wrapped in a :class:`ToolRegistrar` (typed as ``mcp``).
276
+
277
+ The registrar duck-types the FastMCP surface the tool modules use
278
+ (``tool()`` plus attribute forwarding), so modules keep their annotations.
279
+ """
280
+ return cast(TServer, ToolRegistrar.of(mcp))