diff --git a/CHANGELOG.md b/CHANGELOG.md
index c47cb0c3..357c8dbf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Tools the U-joint needed** (#209), each checked on SolidWorks 2026:
+ - `add_mate` takes a face as a point on it (`face_a`, `face_b`) in that
+ part's own coordinates, so a pin mates concentric in its hole;
+ - `set_component_state` (new) fixes or floats a component and makes a
+ sub-assembly rigid or flexible, keeping its other settings;
+ - `create_reference_plane` makes planes at an angle about an `axis`, and
+ from a face (`face_point`), sloped or not; `create_axis` now names the
+ axis it made;
+ - `add_chamfer` takes an `angle` and `flip`;
+ - `create_extrusion` takes `thin_side` for thin walls, and an open
+ outline makes a bent plate.
+- **Every tool accepts a plain dict** (#209). A script calling a tool
+ function with a dict gets the same validation as an MCP client; the
+ export file tools no longer need `format_type`.
+
- **Support levels for every tool** (#79). Each tool is `stable` (checked
live, no known fault), `beta` (a known fault or partial COM coverage),
`experimental` (not yet checked live) or `planned` (not implemented),
@@ -79,6 +94,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **`create_extrusion`'s reply says what was built** (#209): the end
+ condition SolidWorks used (`MidPlane` for `both_directions`), and the
+ thin wall. It used to echo `direction: blind`, so an agent concluded
+ `both_directions` was ignored.
+
- **One failed call could pause a tool for good** (#208). The server's router
fell back to the VBA adapter, which called back into the router, so one
failed `create_extrusion` counted as many failures and opened the circuit
diff --git a/README.md b/README.md
index c05e0575..54c4f4d3 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[](https://www.solidworks.com/)
[](https://codecov.io/gh/andrewbartels1/SolidworksMCP-python)
-Python MCP server for SolidWorks automation with 155 tools, plus an optional agent/prompt-testing layer for AI-assisted workflows.
+Python MCP server for SolidWorks automation with 156 tools, plus an optional agent/prompt-testing layer for AI-assisted workflows.
[](https://andrewbartels1.github.io/SolidworksMCP-python/progress/)
@@ -33,7 +33,7 @@ Python MCP server for SolidWorks automation with 155 tools, plus an optional age
await adapter.create_extrusion(ExtrusionParameters(depth=10))
```
-- **An MCP tool server on top of it.** The same calls, exposed as 155 MCP tools, so an agent such as Claude Code can build parts, assemblies and drawings from a description. Each tool carries a support level (stable, beta, experimental or planned), so the agent knows how far to trust it.
+- **An MCP tool server on top of it.** The same calls, exposed as 156 MCP tools, so an agent such as Claude Code can build parts, assemblies and drawings from a description. Each tool carries a support level (stable, beta, experimental or planned), so the agent knows how far to trust it.
## See it work
@@ -186,6 +186,8 @@ Each GIF is a live test driving SolidWorks 2026 through the MCP tools, with the
 Custom properties |
+ A pin seated in its hole by two face mates |
+ A bent plate from an open outline |
@@ -300,7 +302,7 @@ Add this to `%APPDATA%\Code\User\mcp.json`:
}
```
-Run **MCP: List Servers** from the Command Palette and start `solidworks`. Its output should end with `Discovered 155 tools` (the count grows as tools are added).
+Run **MCP: List Servers** from the Command Palette and start `solidworks`. Its output should end with `Discovered 156 tools` (the count grows as tools are added).
diff --git a/docs/assets/demos/tool-gaps/test_a_60_degree_chamfer_and_flip_swaps_its_legs-motion.gif b/docs/assets/demos/tool-gaps/test_a_60_degree_chamfer_and_flip_swaps_its_legs-motion.gif
new file mode 100644
index 00000000..28a9cebe
Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_60_degree_chamfer_and_flip_swaps_its_legs-motion.gif differ
diff --git a/docs/assets/demos/tool-gaps/test_a_pin_mates_concentric_in_its_hole_by_two_faces-motion.gif b/docs/assets/demos/tool-gaps/test_a_pin_mates_concentric_in_its_hole_by_two_faces-motion.gif
new file mode 100644
index 00000000..ab564260
Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_pin_mates_concentric_in_its_hole_by_two_faces-motion.gif differ
diff --git a/docs/assets/demos/tool-gaps/test_a_plane_at_40_degrees_about_an_axis-motion.gif b/docs/assets/demos/tool-gaps/test_a_plane_at_40_degrees_about_an_axis-motion.gif
new file mode 100644
index 00000000..b1b9c553
Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_plane_at_40_degrees_about_an_axis-motion.gif differ
diff --git a/docs/assets/demos/tool-gaps/test_a_plane_offset_from_a_sloped_face-motion.gif b/docs/assets/demos/tool-gaps/test_a_plane_offset_from_a_sloped_face-motion.gif
new file mode 100644
index 00000000..a90e9a31
Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_plane_offset_from_a_sloped_face-motion.gif differ
diff --git a/docs/assets/demos/tool-gaps/test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate-motion.gif b/docs/assets/demos/tool-gaps/test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate-motion.gif
new file mode 100644
index 00000000..9e106b6b
Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate-motion.gif differ
diff --git a/docs/assets/demos/tool-gaps/test_both_directions_is_symmetric_and_says_mid_plane-motion.gif b/docs/assets/demos/tool-gaps/test_both_directions_is_symmetric_and_says_mid_plane-motion.gif
new file mode 100644
index 00000000..fa6aa6f5
Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_both_directions_is_symmetric_and_says_mid_plane-motion.gif differ
diff --git a/docs/assets/demos/tool-gaps/test_components_fix_float_and_a_sub_assembly_turns_flexible-motion.gif b/docs/assets/demos/tool-gaps/test_components_fix_float_and_a_sub_assembly_turns_flexible-motion.gif
new file mode 100644
index 00000000..69852953
Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_components_fix_float_and_a_sub_assembly_turns_flexible-motion.gif differ
diff --git a/docs/assets/demos/tool-gaps/test_interference_is_fresh_after_a_mate-motion.gif b/docs/assets/demos/tool-gaps/test_interference_is_fresh_after_a_mate-motion.gif
new file mode 100644
index 00000000..152188fd
Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_interference_is_fresh_after_a_mate-motion.gif differ
diff --git a/docs/assets/progress/progress.json b/docs/assets/progress/progress.json
index 4ee5efa7..ce1d5a7d 100644
--- a/docs/assets/progress/progress.json
+++ b/docs/assets/progress/progress.json
@@ -1,9 +1,9 @@
{
"tools": {
- "total": 155,
+ "total": 156,
"by_level": {
- "stable": 81,
- "beta": 24,
+ "stable": 83,
+ "beta": 23,
"experimental": 10,
"planned": 40
},
@@ -41,7 +41,7 @@
"family": "modeling",
"level": "stable",
"reason": "",
- "summary": "Bevel edges of the active part with an equal-distance chamfer at 45 degrees."
+ "summary": "Bevel edges of the active part with a chamfer: `distance` along one face at `angle` (45 by default)."
},
{
"name": "add_circle",
@@ -102,8 +102,8 @@
{
"name": "add_mate",
"family": "modeling",
- "level": "beta",
- "reason": "mates only reference planes and axes, by name, not faces.",
+ "level": "stable",
+ "reason": "",
"summary": "Mate two components of the active assembly: coincident, concentric, parallel, perpendicular, tangent, distance or angle."
},
{
@@ -412,7 +412,7 @@
"family": "modeling",
"level": "stable",
"reason": "",
- "summary": "Add a reference plane parallel to a plane or flat face, offset by a distance in mm."
+ "summary": "Add a reference plane offset from, at an angle to, or on a plane or flat face."
},
{
"name": "create_reference_point",
@@ -502,7 +502,7 @@
"name": "export_dwg",
"family": "export",
"level": "beta",
- "reason": "the file extension sets the format; `format_type` and `options` are ignored.",
+ "reason": "`options` is ignored; the file extension sets the format.",
"summary": "Export the active document to DWG, like File > Save As with the DWG type."
},
{
@@ -516,7 +516,7 @@
"name": "export_iges",
"family": "export",
"level": "beta",
- "reason": "the file extension sets the format; `format_type` and `options` are ignored.",
+ "reason": "`options` is ignored; the file extension sets the format.",
"summary": "Export the active document to IGES, like File > Save As with the IGES type."
},
{
@@ -530,21 +530,21 @@
"name": "export_pdf",
"family": "export",
"level": "beta",
- "reason": "the file extension sets the format; `format_type` and `options` are ignored.",
+ "reason": "`options` is ignored; the file extension sets the format.",
"summary": "Export the active document to PDF, like File > Save As with the PDF type."
},
{
"name": "export_step",
"family": "export",
"level": "beta",
- "reason": "the file extension sets the format; `format_type` and `options` are ignored.",
+ "reason": "`options` is ignored; the file extension sets the format.",
"summary": "Export the active document to STEP, like File > Save As with the STEP type."
},
{
"name": "export_stl",
"family": "export",
"level": "beta",
- "reason": "the file extension sets the format; `format_type` and `options` are ignored.",
+ "reason": "`options` is ignored; the file extension sets the format.",
"summary": "Export the active document to STL, like File > Save As with the STL type."
},
{
@@ -960,6 +960,13 @@
"reason": "",
"summary": "Select a feature, sketch, plane or component by name in the active document."
},
+ {
+ "name": "set_component_state",
+ "family": "modeling",
+ "level": "stable",
+ "reason": "",
+ "summary": "Fix or float a component of the active assembly, or make a sub-assembly rigid or flexible."
+ },
{
"name": "set_custom_property",
"family": "engineering_data",
@@ -1098,11 +1105,11 @@
"com": {
"type_library": "83A33D31-27C5-11CE-BFD4-00400513BB57x0x34x0",
"core": {
- "covered": 208,
+ "covered": 214,
"total": 2838
},
"api": {
- "covered": 232,
+ "covered": 238,
"total": 5985
},
"interfaces": [
@@ -1256,11 +1263,14 @@
"AddComponent4",
"AddComponent5",
"AddMate5",
+ "CompConfigProperties5",
"FeatureByName",
+ "FixComponent",
"GetBox",
"GetComponentByName",
"GetComponentCount",
- "GetComponents"
+ "GetComponents",
+ "UnfixComponent"
],
"total": 198
},
@@ -1345,7 +1355,10 @@
"GetID",
"GetModelDoc2",
"GetPathName",
+ "GetSuppression2",
"GetType",
+ "IsEnvelope",
+ "IsFixed",
"IsSuppressed",
"Select",
"Select2",
diff --git a/docs/assets/progress/progress.svg b/docs/assets/progress/progress.svg
index 1cd0a7c4..73d5a656 100644
--- a/docs/assets/progress/progress.svg
+++ b/docs/assets/progress/progress.svg
@@ -21,33 +21,33 @@
-MCP tools: 155
+MCP tools: 156
68% work on SolidWorks today
-
-
-
-
-
+
+
+
+
+
-stable 81
+stable 83
-beta 24
+beta 23
experimental 10
planned 40
Core SolidWorks COM API (12 interfaces)
-208 of 2,838 methods · 7.3%
+214 of 2,838 methods · 7.5%
-
-
-
+
+
+
-Whole API: 232 of 5,985 method names (3.9%), counted by a text scan of the adapters.
+Whole API: 238 of 5,985 method names (4.0%), counted by a text scan of the adapters.
diff --git a/docs/assets/progress/tables.html b/docs/assets/progress/tables.html
index 35b8fa6e..259c7a49 100644
--- a/docs/assets/progress/tables.html
+++ b/docs/assets/progress/tables.html
@@ -6,7 +6,7 @@
add_annotation | drawing | planned no adapter implements it yet. | Add an annotation (a note, balloon or surface-finish symbol) to the active drawing. |
add_arc | sketching | stable | Draw a circular arc in the open sketch from its centre, start point and end point, in mm. |
add_centerline | sketching | stable | Draw a centreline (construction line) in the open sketch, in mm. |
-add_chamfer | modeling | stable | Bevel edges of the active part with an equal-distance chamfer at 45 degrees. |
+add_chamfer | modeling | stable | Bevel edges of the active part with a chamfer: `distance` along one face at `angle` (45 by default). |
add_circle | sketching | stable | Draw a circle in the open sketch from its centre and radius, in mm. |
add_datum_tag | drawing_automation | stable | Add a datum feature symbol (A, B, C, ...) on an edge of the active drawing. |
add_dimension | drawing | planned no adapter implements it yet. | Add one dimension between drawing entities at a position on the sheet. |
@@ -15,7 +15,7 @@
add_fillet | modeling | stable | Round edges of the active part with a constant-radius fillet. |
add_gtol | drawing_automation | stable | Add a GD&T feature control frame to the active drawing, e.g. position Ø0.2 (M) to A|B|C. |
add_line | sketching | stable | Draw a straight line between two points in the open sketch, in mm. |
-add_mate | modeling | beta mates only reference planes and axes, by name, not faces. | Mate two components of the active assembly: coincident, concentric, parallel, perpendicular, tangent, distance or angle. |
+add_mate | modeling | stable | Mate two components of the active assembly: coincident, concentric, parallel, perpendicular, tangent, distance or angle. |
add_note | drawing | stable | Place a text note on the active drawing sheet at a position in millimetres. |
add_polygon | sketching | stable | Draw a regular polygon (e.g. a hexagon) in the open sketch from its centre, radius and number of sides. |
add_rectangle | sketching | stable | Draw a closed, axis-aligned rectangle in the open sketch from two opposite corners, in mm. |
@@ -59,7 +59,7 @@
create_loft | modeling | stable | Loft a solid through two or more closed profile sketches, in order (Insert > Boss/Base > Loft). |
create_macro_library | macro_recording | planned no adapter implements it yet. | Set up a shared folder of categorised, documented macros. |
create_part | modeling | beta `material` is ignored; use `set_material`. | Create a new, empty part document and make it active, ready for `create_sketch`. |
-create_reference_plane | modeling | stable | Add a reference plane parallel to a plane or flat face, offset by a distance in mm. |
+create_reference_plane | modeling | stable | Add a reference plane offset from, at an angle to, or on a plane or flat face. |
create_reference_point | modeling | stable | Add a reference point on an edge (at a distance or percentage along it) or at the centre of a face. |
create_revolve | modeling | beta `sketch_name` and `axis_entity` are ignored; it revolves the sketch closed last around its centreline. | Revolve the sketch you just closed around the centreline drawn in it, adding a solid to the active part. |
create_section_view | drawing | planned no adapter implements it yet. | Cut a section view along a line across a drawing view. |
@@ -72,13 +72,13 @@
execute_macro | macro_recording | beta runs the macro once whatever `repeat_count` says, and only binary .swp files. | Run a binary SolidWorks macro (.swp, or legacy .swb) in the running SolidWorks, calling its `main` procedure once. |
execute_workflow | automation | planned no adapter implements it yet. | Run a named sequence of tool steps, in order or in parallel, with an error policy. |
exit_sketch | sketching | stable | Close the open sketch so a feature can use it. |
-export_dwg | export | beta the file extension sets the format; `format_type` and `options` are ignored. | Export the active document to DWG, like File > Save As with the DWG type. |
+export_dwg | export | beta `options` is ignored; the file extension sets the format. | Export the active document to DWG, like File > Save As with the DWG type. |
export_flat_pattern | engineering_data | stable | Export the active sheet-metal part's flat pattern to DXF or DWG, for laser or waterjet cutting. |
-export_iges | export | beta the file extension sets the format; `format_type` and `options` are ignored. | Export the active document to IGES, like File > Save As with the IGES type. |
+export_iges | export | beta `options` is ignored; the file extension sets the format. | Export the active document to IGES, like File > Save As with the IGES type. |
export_image | export | stable | Save a picture of the active model in a standard view, as PNG or JPG, at a chosen size. |
-export_pdf | export | beta the file extension sets the format; `format_type` and `options` are ignored. | Export the active document to PDF, like File > Save As with the PDF type. |
-export_step | export | beta the file extension sets the format; `format_type` and `options` are ignored. | Export the active document to STEP, like File > Save As with the STEP type. |
-export_stl | export | beta the file extension sets the format; `format_type` and `options` are ignored. | Export the active document to STL, like File > Save As with the STL type. |
+export_pdf | export | beta `options` is ignored; the file extension sets the format. | Export the active document to PDF, like File > Save As with the PDF type. |
+export_step | export | beta `options` is ignored; the file extension sets the format. | Export the active document to STEP, like File > Save As with the STEP type. |
+export_stl | export | beta `options` is ignored; the file extension sets the format. | Export the active document to STL, like File > Save As with the STL type. |
extract_template | template_management | planned no adapter implements it yet. | Capture a model's settings (custom properties, dimensions, units) as a reusable template file. |
find_related_api_members | docs_discovery | stable | Find SolidWorks API members related to an interface or member name. |
generate_drawing_package | drawing_automation | stable | Create a complete A3 drawing of a part or assembly in one call, and save it with a PDF. |
@@ -138,6 +138,7 @@
search_solidworks_api_help | docs_discovery | stable | Search SolidWorks API interface and member names by keyword. |
select_face_edges | modeling | stable | Select every edge of one face of the active part, ready for `add_fillet` or `add_chamfer` with no edge names. |
select_feature | modeling | stable | Select a feature, sketch, plane or component by name in the active document. |
+set_component_state | modeling | stable | Fix or float a component of the active assembly, or make a sub-assembly rigid or flexible. |
set_custom_property | engineering_data | stable | Create or overwrite one text custom property on the active document, e.g. PartNo or DrawnBy. |
set_dimension | modeling | stable | Change one length dimension of the active model, in mm, and rebuild. |
set_material | engineering_data | stable | Assign a library material to the active part, e.g. "AISI 1020" or "6061-T6 (SS)". |
@@ -169,12 +170,12 @@
IModelDoc2 | 66 | 758 | 8.7%AddDiameterDimension2, AddDimension, AddDimension2, AddRadialDimension2, AddRelation, ClearSelection2, CreateArc, CreateCenterLine, CreateCircleByRadius, CreateCircularSketchStepAndRepeat, CreateEllipse, CreateLine, CreateLinearSketchStepAndRepeat, EditDelete, EditRebuild3, EditSuppress2, EditUndo, EditUndo2, EditUnsuppress2, FeatureByPositionReverse, FeatureChamferType, FeatureCut3, FeatureCut4, FeatureFillet3, FeatureRevolve2, FirstFeature, ForceRebuild3, GetActiveConfiguration, GetActiveSketch2, GetConfigurationNames, GetFeatureCount, GetLineCount, GetMassProperties, GetNext, GetPathName, GetSaveFlag, GetTitle, GetType, GetUserPreferenceStringValue, GetUserPreferenceToggle, GraphicsRedraw2, InsertAxis2, InsertDatumTag2, InsertGtol, InsertNote, InsertProtrusionBlend2, InsertProtrusionSwept4, InsertSketch, Lock, Parameter, Save, Save3, SaveAs, SaveAs2, SaveAs3, SaveBMP, Select, SetSaveFlag, SetUserPreferenceIntegerValue, SetUserPreferenceToggle, ShowNamedView2, SketchAddConstraints, SketchMirror, SketchOffset2, ViewZoomtofit, ViewZoomtofit2 |
IModelDocExtension | 20 | 337 | 5.9%AddDimension, CreateMassProperty, CreateMeasure, CustomPropertyManager, DeleteSelection2, GetAnnotations, GetMassProperties, GetPackAndGo, GetUserPreferenceInteger, GetUserPreferenceToggle, GetWhatsWrong, GetWhatsWrongCount, InsertBomTable4, SaveAs, SaveAs2, SaveAs3, SavePackAndGo, SelectByID2, SetUserPreferenceInteger, SetUserPreferenceToggle |
IPartDoc | 12 | 167 | 7.2%ExportToDWG2, FeatureByName, FeatureExtrusion2, FeatureExtrusion3, FeatureExtrusionThin2, FeatureRevolve2, FirstFeature, GetBodies2, GetMaterialPropertyName2, GetPartBox, InsertMirrorFeature, SetMaterialPropertyName2 |
-IAssemblyDoc | 8 | 198 | 4.0%AddComponent4, AddComponent5, AddMate5, FeatureByName, GetBox, GetComponentByName, GetComponentCount, GetComponents |
+IAssemblyDoc | 11 | 198 | 5.6%AddComponent4, AddComponent5, AddMate5, CompConfigProperties5, FeatureByName, FixComponent, GetBox, GetComponentByName, GetComponentCount, GetComponents, UnfixComponent |
IDrawingDoc | 13 | 272 | 4.8%ActivateView, AutoDimension, Create1stAngleViews2, Create3rdAngleViews2, CreateDrawViewFromModelView3, EditSheet, EditTemplate, FeatureByName, GetCurrentSheet, GetFirstView, GetViews, InsertModelAnnotations3, SetupSheet5 |
IFeatureManager | 16 | 268 | 6.0%CreateSaveBodyFeature, FeatureCircularPattern5, FeatureCut3, FeatureCut4, FeatureExtrusion2, FeatureExtrusion3, FeatureExtrusionThin2, FeatureFillet3, FeatureRevolve2, GetFeatureCount, InsertFeatureChamfer, InsertMirrorFeature, InsertProtrusionBlend2, InsertProtrusionSwept4, InsertRefPlane, InsertReferencePoint |
ISketchManager | 12 | 83 | 14.5%CreateArc, CreateCenterLine, CreateCircleByRadius, CreateCircularSketchStepAndRepeat, CreateCornerRectangle, CreateEllipse, CreateLine, CreateLinearSketchStepAndRepeat, CreatePolygon, CreateSpline2, InsertSketch, SketchOffset2 |
ISelectionMgr | 4 | 61 | 6.6%CreateSelectData, GetSelectedObject6, GetSelectedObjectCount2, GetSelectedObjectType3 |
-IComponent2 | 14 | 147 | 9.5%CustomPropertyManager, FeatureByName, FirstFeature, GetBodies2, GetBox, GetConfigurationNames, GetID, GetModelDoc2, GetPathName, GetType, IsSuppressed, Select, Select2, Select4 |
+IComponent2 | 17 | 147 | 11.6%CustomPropertyManager, FeatureByName, FirstFeature, GetBodies2, GetBox, GetConfigurationNames, GetID, GetModelDoc2, GetPathName, GetSuppression2, GetType, IsEnvelope, IsFixed, IsSuppressed, Select, Select2, Select4 |
IFeature | 18 | 115 | 15.7%GetBox, GetErrorCode2, GetFaces, GetFirstDisplayDimension, GetFirstSubFeature, GetID, GetNameForSelection, GetNextDisplayDimension, GetNextFeature, GetNextSubFeature, GetParents, GetSpecificFeature2, GetTypeName2, IsSuppressed, IsSuppressed2, Parameter, Select, Select2 |
ISketch | 3 | 102 | 2.9%AutoDimension, GetLineCount, GetSketchSegments |
diff --git a/docs/demos.md b/docs/demos.md
index 52d7fc0e..7352b474 100644
--- a/docs/demos.md
+++ b/docs/demos.md
@@ -559,6 +559,145 @@ and reports the assembly's size.
1. `verify_model({"component_count": 2, "mate_count": 2, "no_interference": true})`
+## The tools the U-joint was missing {#tool-gaps}
+
+Building the [U-joint](#ujoint) from a prompt, both agent sessions hit the
+same walls: no face mates, no way to let a part move, no angled planes or
+chamfers, and an extrusion reply that hid what it built (#209). Each demo
+below is a live test of the fix on SolidWorks 2026, calling the MCP tools
+with plain dicts, as a script would.
+
+Source: [`tests/live/test_live_sw_ujoint_tool_gaps.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/tests/live/test_live_sw_ujoint_tool_gaps.py).
+
+### A bent plate from an open outline {#thin-plate}
+
+
+
+> *Example prompt:* Make a bent plate: an 80 mm wall and a 40 mm foot,
+> 6.35 mm thick, 38.1 mm wide, centred on the Right plane.
+
+**What it shows.** Two lines, not a closed outline, extruded as a 6.35 mm
+thin wall on both sides of the line and 19.05 mm each way. One body of
+29,032.2 mm³, exactly (80 + 40) x 6.35 x 38.1, and the reply says
+`MidPlane` and the wall it built.
+
+**Tool calls**
+
+1. `create_extrusion({"sketch_name": "Sketch1", "depth": 38.1, "both_directions": true, "thin_feature": true, "thin_thickness": 6.35, "thin_side": "mid_plane"})`
+
+### Both directions, and the reply says so {#mid-plane}
+
+
+
+> *Example prompt:* Extrude this rectangle 20 mm, centred on its sketch plane.
+
+**What it shows.** The body spans -10 to 10 mm about the sketch plane, and
+the reply's `end_condition` is `MidPlane`. It used to echo `direction:
+blind`, so an agent thought `both_directions` was ignored.
+
+**Tool calls**
+
+1. `create_extrusion({"sketch_name": "Sketch1", "depth": 20, "both_directions": true})`
+
+### A 60° chamfer, and flipping it {#angled-chamfer}
+
+
+
+> *Example prompt:* Chamfer the top front edge 2 mm at 60°, and the back
+> edge the other way round.
+
+**What it shows.** Each chamfer removes 138.56 mm³ (½ x 2 x 2·tan 60° x
+40 mm). Unflipped, the chamfer face slopes 60° from the top; flipped, 30°:
+`flip` swaps which face the 2 mm is measured on.
+
+**Tool calls**
+
+1. `add_chamfer({"distance": 2, "angle": 60, "edge_names": ["0,0.01,0.01"]})`
+2. `add_chamfer({"distance": 2, "angle": 60, "flip": true, "edge_names": ["0,0.01,-0.01"]})`
+
+### A plane at 40° about an axis {#angled-plane}
+
+
+
+> *Example prompt:* Add a plane at 40° to the Top Plane, tilted about the x
+> axis.
+
+**What it shows.** `create_axis` names the axis it makes, and the new plane
+is exactly 40° from the Top Plane about it.
+
+**Tool calls**
+
+1. `create_axis({"reference": "x"})`
+2. `create_reference_plane({"reference": "Top Plane", "angle": 40, "axis": "Axis1"})`
+
+### A plane off a sloped face {#face-plane}
+
+
+
+> *Example prompt:* Put a plane 5 mm above the sloped face of this wedge.
+
+**What it shows.** A point on the 3-4-5 slope picks the face. The plane's
+normal is (0.6, 0.8, 0), parallel to the slope, and it sits 5.000 mm off it.
+
+**Tool calls**
+
+1. `create_reference_plane({"face_point": [20, 15, 5], "offset": 5})`
+
+### A pin in its hole, by two face mates {#face-mate}
+
+
+
+> *Example prompt:* Put the pin through the plate's hole, flush with the
+> plate's bottom.
+
+**What it shows.** Each face is a point in its own part's coordinates, as
+the part was modelled. A concentric mate between the hole's wall (25, 5, 0)
+and the pin's round face (4.75, 15, 0) puts the pin's axis exactly on the
+hole's, 20 mm from the plate's origin. A coincident mate between the two
+bottom faces stops it sliding along that axis: the pin runs from 0 to 30 mm
+above the plate's bottom, through the hole.
+
+**Tool calls**
+
+1. `add_mate({"component_a": "mate_plate-1", "component_b": "mate_pin-1", "face_a": [25, 5, 0], "face_b": [4.75, 15, 0], "mate_type": "concentric"})`
+2. `add_mate({"component_a": "mate_plate-1", "component_b": "mate_pin-1", "face_a": [-20, 0, 0], "face_b": [0, 0, 0], "mate_type": "coincident", "alignment": "aligned"})`
+
+### Fixed, floating and flexible {#component-state}
+
+
+
+> *Example prompt:* Let the crank sub-assembly turn inside the main
+> assembly.
+
+**What it shows.** The first part in, which SolidWorks fixes, floats and is
+fixed again. A sub-assembly turns flexible, so its own mates can move in the
+main assembly, and its suppression, visibility, configuration and BOM
+setting are unchanged.
+
+**Tool calls**
+
+1. `set_component_state({"component": "state_plate-1", "fixed": false})`
+2. `set_component_state({"component": "state_plate-1", "fixed": true})`
+3. `set_component_state({"component": "gap_pin_plate-1", "flexible": true})`
+
+### Interference, before and after {#fresh-interference}
+
+
+
+> *Example prompt:* Does anything overlap? Now put the pin in the hole and
+> check again.
+
+**What it shows.** The pin, inserted through solid plate, overlaps it
+once. Seated in the Ø10 hole by the two face mates above, the Ø9.5 pin
+overlaps nothing: it passes through the plate with 0.25 mm clearance. The
+second check is fresh, not the first one's cached answer.
+
+**Tool calls**
+
+1. `check_interference({"components": ["clash_plate-1", "clash_pin-1"]})`
+2. `add_mate` concentric and coincident, as in the demo above
+3. `check_interference({"components": ["clash_plate-1", "clash_pin-1"]})`
+
## Drawing packages {#drawing-package}
The drawing-package tools make a complete drawing from a model, and set the
diff --git a/docs/index.md b/docs/index.md
index 2f03a60a..5d1df192 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -111,7 +111,7 @@ cd SolidworksMCP-python
Open SolidWorks, restart your client, and ask *"What model is open in SolidWorks?"*. Healthy server logs include:
- `SolidWorks COM interface is available`
-- `Registered 155 SolidWorks tools` (the count grows as tools are added)
+- `Registered 156 SolidWorks tools` (the count grows as tools are added)
- `Connected to SolidWorks`
### Linux / WSL only
diff --git a/docs/user-guide/tools-overview.md b/docs/user-guide/tools-overview.md
index f8332277..ec8f964a 100644
--- a/docs/user-guide/tools-overview.md
+++ b/docs/user-guide/tools-overview.md
@@ -1,6 +1,6 @@
# Tools Overview
-The SolidWorks MCP Server provides 155 specialized tools for CAD automation. This page is a navigation map, not a tutorial.
+The SolidWorks MCP Server provides 156 specialized tools for CAD automation. This page is a navigation map, not a tutorial.
## Read this first
@@ -21,7 +21,7 @@ The SolidWorks MCP Server provides 155 specialized tools for CAD automation. Thi
Core 3D modeling operations including part creation, features, assemblies, and configurations.
- [**30 tools available** :octicons-arrow-right-24:](#modeling-tools)
+ [**31 tools available** :octicons-arrow-right-24:](#modeling-tools)
- :material-pencil:{ .lg .middle } **Sketching Tools**
@@ -133,7 +133,7 @@ The SolidWorks MCP Server provides 155 specialized tools for CAD automation. Thi
| Category | Tool Count | Complexity Level | Primary Use Case |
|----------|------------|------------------|------------------|
-| **Modeling** | 30 | High | Part/assembly creation and modification |
+| **Modeling** | 31 | High | Part/assembly creation and modification |
| **Sketching** | 19 | Medium | 2D geometry and constraint management |
| **Drawing** | 14 | Medium | Technical documentation creation |
| **Drawing Analysis** | 8 | Medium | Quality assurance and compliance |
@@ -148,7 +148,7 @@ The SolidWorks MCP Server provides 155 specialized tools for CAD automation. Thi
| **Macro Recording** | 7 | High | Workflow capture and optimization |
| **Docs Discovery** | 2 | Medium | API and COM discovery workflows |
-**Total: 155 Tools**
+**Total: 156 Tools**
## Tool Design Principles
diff --git a/openspec/changes/tool-support-levels-and-progress/.openspec.yaml b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/.openspec.yaml
similarity index 100%
rename from openspec/changes/tool-support-levels-and-progress/.openspec.yaml
rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/.openspec.yaml
diff --git a/openspec/changes/tool-support-levels-and-progress/design.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/design.md
similarity index 100%
rename from openspec/changes/tool-support-levels-and-progress/design.md
rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/design.md
diff --git a/openspec/changes/tool-support-levels-and-progress/proposal.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/proposal.md
similarity index 100%
rename from openspec/changes/tool-support-levels-and-progress/proposal.md
rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/proposal.md
diff --git a/openspec/changes/tool-support-levels-and-progress/specs/docs/coverage-progress/spec.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/docs/coverage-progress/spec.md
similarity index 100%
rename from openspec/changes/tool-support-levels-and-progress/specs/docs/coverage-progress/spec.md
rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/docs/coverage-progress/spec.md
diff --git a/openspec/changes/tool-support-levels-and-progress/specs/tools/support-levels/spec.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/tools/support-levels/spec.md
similarity index 100%
rename from openspec/changes/tool-support-levels-and-progress/specs/tools/support-levels/spec.md
rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/tools/support-levels/spec.md
diff --git a/openspec/changes/tool-support-levels-and-progress/specs/tools/tool-descriptions/spec.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/tools/tool-descriptions/spec.md
similarity index 100%
rename from openspec/changes/tool-support-levels-and-progress/specs/tools/tool-descriptions/spec.md
rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/tools/tool-descriptions/spec.md
diff --git a/openspec/changes/tool-support-levels-and-progress/tasks.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/tasks.md
similarity index 100%
rename from openspec/changes/tool-support-levels-and-progress/tasks.md
rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/tasks.md
diff --git a/openspec/changes/ujoint-tool-gaps/.openspec.yaml b/openspec/changes/ujoint-tool-gaps/.openspec.yaml
new file mode 100644
index 00000000..8a94d769
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-11-06
diff --git a/openspec/changes/ujoint-tool-gaps/design.md b/openspec/changes/ujoint-tool-gaps/design.md
new file mode 100644
index 00000000..b4502062
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/design.md
@@ -0,0 +1,142 @@
+## Context
+
+Gaps from #209, checked against the code and the two U-joint transcripts on
+2026-11-06:
+
+- `add_mate` selects each entity with `IComponent2::FeatureByName` +
+ `IFeature::Select2`, so only named tree features (planes, axes) can be
+ mated.
+- Nothing calls `IAssemblyDoc::FixComponent` / `UnfixComponent` or sets a
+ component's solving (rigid / flexible).
+- `create_reference_plane` refuses `angle`: measured on SW 2025, an angle
+ constraint with one reference adds nothing. A face can only be named, and
+ faces have no names.
+- `add_chamfer` calls `InsertFeatureChamfer(1, 1, width, pi/4, ...)`. In
+ `swChamferType_e` 1 is **angle-distance**, not equal distance (16): it has
+ always made an angle-distance chamfer at a fixed 45°. Options = 1 is
+ `swFeatureChamferFlipDirection`.
+- `create_extrusion` already maps `both_directions` to `swEndCondMidPlane`
+ (#191) and builds thin walls with `FeatureExtrusionThin2`. In the
+ transcripts the bodies came out mid-plane (symmetric bounding boxes), but
+ the reply echoed the input `direction: "blind"`, so the agent believed the
+ flag was ignored. The thin call's `RevThinDir` is fixed at 0 (one side).
+- Over MCP, arguments arrive validated as the tool's model; a script calling
+ the function with a dict hits `'dict' object has no attribute ...` on most
+ tools. The export tools fixed this one by one (6ae900f).
+
+COM members, checked with the project's API lookup (type library 34.0) and
+`swconst.tlb`:
+
+| Member | Signature / values |
+| --- | --- |
+| `IAssemblyDoc::AddMate5` | (type, align, flip, distance, upper, lower, gear num, gear den, angle, upper, lower, positioning only, lock rotation, width option, out error) |
+| `IAssemblyDoc::FixComponent`, `UnfixComponent` | act on the selected component |
+| `IAssemblyDoc::CompConfigProperties6` | (suppression, solving, visibility, use named config, config name, exclude from BOM, is envelope, save-as-part option): sets all of them for the selected component |
+| `IComponent2` | `IsFixed()`, `Solving` (read), `GetSuppression2`, `Visible`, `ReferencedConfiguration`, `ExcludeFromBOM`, `IsEnvelope`, `GetBodies2(type)`, `Transform2`, `Select4(append, data, popup)` |
+| `IFeatureManager::InsertRefPlane` | (first constraint, value, second, value, third, value) |
+| `IFeatureManager::InsertFeatureChamfer` | (options, type, width, angle, other distance, vertex 1, 2, 3) |
+| `IFeatureManager::FeatureExtrusionThin2` | ... `Thk1, Thk2, EndThk, RevThinDir, CapEnds` ... |
+| `swRefPlaneReferenceConstraints_e` | Parallel 1, Coincident 4, Distance 8, Angle 16, OptionFlip 256 |
+| `swChamferType_e` | AngleDistance 1, DistanceDistance 2, EqualDistance 16 |
+| `swComponentSolvingOption_e` | Rigid 0, Flexible 1 |
+| `swThinWallType_e` | OneDirection 0, OppDirection 1, MidPlane 2, TwoDirection 3 |
+| `swMateType_e` / `swMateAlign_e` | as in `_MATE_TYPES` / `_MATE_ALIGNMENTS` |
+
+## Goals / Non-Goals
+
+**Goals:** every gap in #209 closed and checked live on SolidWorks 2026; the
+U-joint's pin-in-hole and spinning knob possible with the tools; replies that
+say what SolidWorks built.
+
+**Non-Goals:** the queue's other `create_extrusion` faults (`sketch_name`,
+`direction`), gear/cam/limit mates, rebuilding the U-joint demo, edge mates.
+
+## Decisions
+
+### A face is a point on it, in the part's own coordinates
+
+`face_a` / `face_b` are `[x, y, z]` mm in the component's part coordinates,
+which is what an agent knows from building the part. The adapter maps the
+point into the assembly with the component's `Transform2` (an `IMathPoint`
+from `IMathUtility::CreatePoint`, `MultiplyTransform`). It searches only that
+component's faces (`GetBodies2`, the face closest to the point within
+0.01 mm, as `select_face_edges` does) and selects the face with
+`IEntity::Select4`, the first under mark 1 and the second appended. Whether
+component faces report assembly or part coordinates is measured live first
+(a read-only probe); the transform step is kept only if needed.
+
+**Measured 2026-11-06 (SW 2026, read-only probe):** a 100 x 50 x 10 block
+inserted 50 mm along x had `Transform2` translation (0.05, -0.005, 0) m, yet
+every face from `IComponent2::GetBodies2(0)` reported its box in **part**
+coordinates (x from -50 to 50 mm), and `IEntity::GetComponent` on such a face
+returned the component. So the point is matched against the component's faces
+in part coordinates directly, and no transform is needed.
+
+Alternative, `SelectByID2("", "FACE", x, y, z)` at an assembly point:
+rejected. A pin's surface and its hole's surface are at the same points, so
+the pick could land on the wrong component.
+
+Each side takes exactly one of `entity_*` or `face_*`. The default stays
+`"Front Plane"` only when neither is given, so existing calls are unchanged.
+
+### `set_component_state` is its own tool
+
+Input: `component`, and any of `fixed` (true fixes, false floats) and
+`flexible` (true flexible, false rigid). Fix and float select the component
+and call `FixComponent` / `UnfixComponent`. Solving goes through
+`CompConfigProperties5`, which also sets suppression, visibility,
+configuration, BOM and envelope: the adapter reads each one from the
+component first and passes it back, so only solving changes. (Version 6
+adds a save-as-part option the component doesn't report, so it would set
+something unread.) The reply is
+the state read back (`fixed`, `flexible`). Alternative, more fields on
+`add_mate`: rejected, a component's state isn't a mate.
+
+### Reference planes: angle about an axis; a face by a point
+
+- `angle` needs `axis`: a named axis (`"Axis1"`) or a temporary axis name,
+ selected under mark 1, with constraints `Angle | flip` on the plane (mark 0)
+ and `Coincident` on the axis. This is what the SW 2025 measurement said was
+ missing.
+- `face_point` (`[x, y, z]` mm) replaces `reference` with the planar face at
+ that point, found as `select_face_edges` finds it, and selected under
+ mark 0. With `offset` it makes a parallel plane; with `offset` 0 a plane on
+ the face.
+
+### Chamfer: angle and flip on the call it already makes
+
+`angle` (degrees, 0 < angle < 90, default 45) and `flip` (measure the
+distance on the other face). The call stays `InsertFeatureChamfer`, type 1
+(angle-distance), `Options = 1 if flip else 0`. The comment that called
+type 1 "equal distance" is corrected. Equal-distance (16) is not used: at 45°
+angle-distance gives the same chamfer and has been verified live.
+
+### Extrusion reply
+
+`extrusion` reports `end_condition` (as SolidWorks applied it), `both_directions`,
+and, for thin walls, `thin_thickness` and `thin_side`; `direction` is no longer
+echoed. `thin_side` (`one_side`, `other_side`, `mid_plane`) maps to
+`RevThinDir` 0, 1, 2.
+
+### Plain dicts at the boundary
+
+At registration, `tool_boundary` finds the parameters annotated with a
+pydantic model (alone or `| None`). At call time a dict in one of them is
+validated with `normalize_input`, so a bad dict is an `invalid_input` error
+like any other. The export tools' `_file_input` keeps its default
+`format_type` and stays.
+
+## Risks / Trade-offs
+
+- [Component face coordinates differ by build] → measured live before the
+ code depends on it; the live test mates a pin in a hole away from the
+ origin, so a wrong space fails.
+- [CompConfigProperties6 resets something we didn't read] → the live test
+ reads suppression, visibility and configuration before and after.
+- [Boundary conversion changes a tool that took a raw dict on purpose] → only
+ parameters annotated with a model are converted; the full suite runs.
+
+## Migration Plan
+
+Additive: new optional fields and one new tool. `add_chamfer` without
+`angle` behaves as before.
diff --git a/openspec/changes/ujoint-tool-gaps/proposal.md b/openspec/changes/ujoint-tool-gaps/proposal.md
new file mode 100644
index 00000000..b049219f
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/proposal.md
@@ -0,0 +1,61 @@
+## Why
+
+Building SolidWorks' U-Joint sample from a prompt (the README's top demo),
+both agent sessions hit the same walls (#209). The pin can't be mated
+concentric to its hole, because `add_mate` only takes named planes and axes.
+The knob can't spin, because nothing fixes, floats or flexes a component.
+There are no planes at an angle or on a sloped face. Every chamfer is 45°.
+The bent bracket had to be drawn as a closed outline, because the agent
+didn't know thin extrudes work. And the agent concluded `both_directions` was
+ignored, because the reply echoed `direction: blind`. Scripts that call a
+tool function with a plain dict still fail on most tools.
+
+## What Changes
+
+- **Face mates.** `add_mate` takes `face_a` / `face_b` in place of
+ `entity_a` / `entity_b`: a point on the face in the component's own part
+ coordinates (mm). The face is found among that component's faces only, so
+ a pin and the hole it sits in can't be confused. Concentric, coincident,
+ parallel, distance and angle mates all work on faces.
+- **`set_component_state`, a new tool.** Fix or float a component, and make
+ a sub-assembly rigid or flexible (so its own mates move in the parent).
+ The reply reads the state back.
+- **Angled and face-based reference planes.** `create_reference_plane` takes
+ `angle` with an `axis` to rotate about (a named axis or a straight edge's
+ point), and `face_point` to use a planar face, sloped or not, as the
+ reference.
+- **Angled chamfers.** `add_chamfer` takes `angle` (default 45°) and `flip`,
+ using SolidWorks' angle-distance chamfer, which it already called with a
+ fixed 45°.
+- **Extrusion replies say what was built.** `create_extrusion` reports the
+ end condition SolidWorks used (`MidPlane` for `both_directions`) and the
+ thin wall, instead of echoing `direction`. Thin extrudes from an open
+ profile are checked live and documented, with `thin_side` (one side, the
+ other side, or mid-plane).
+- **Plain-dict input for every tool.** The tool boundary validates a dict
+ argument as the tool's input model, so a script can call any tool function
+ with a dict. The export tools' own fix (6ae900f) is folded into it.
+- The stale `check_interference` result in #209 was the router's cache,
+ removed by #213; a live check confirms it.
+
+## Capabilities
+
+### New Capabilities
+- `tools/assembly-mates`: face mates and component state (fixed, floating,
+ rigid, flexible).
+- `tools/reference-plane`: planes offset from, angled to, or on a face.
+- `tools/extrusion`: what an extrusion reply reports; thin extrudes.
+
+### Modified Capabilities
+- `tools/edge-treatment`: chamfers at an angle.
+- `tools/error-boundary`: a plain dict is validated as the tool's input.
+
+## Impact
+
+- Adapter: `add_mate`, `create_reference_plane` and `add_chamfer` gain
+ parameters, and `set_component_state` is new. Each goes through base, mock,
+ COM, circuit breaker and connection pool.
+- Tools: `modeling.py` inputs and descriptions; `tool_boundary.py`; the
+ support levels and hints for the new tool; the progress files regenerate.
+- Live tests for every changed tool, recorded as GIFs on the Demos page.
+- Closes #209.
diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/assembly-mates/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/assembly-mates/spec.md
new file mode 100644
index 00000000..d7c4afad
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/specs/tools/assembly-mates/spec.md
@@ -0,0 +1,29 @@
+## ADDED Requirements
+
+### Requirement: Mate a face by a point on it
+
+`add_mate` SHALL accept, for each side, either a named entity or a point on a face in that component's own part coordinates (mm), and SHALL pick the face among that component's faces only.
+
+#### Scenario: A pin concentric in a hole
+- **WHEN** an agent mates a pin's cylindrical face (a point on it) concentric with a bracket's hole face (a point on it)
+- **THEN** the mate is added, the pin's axis lies on the hole's axis, and the reply names the faces used
+
+#### Scenario: No face at the point
+- **WHEN** the point is not on any face of the named component
+- **THEN** the call fails with `not_found`, names the component and the point, and adds no mate
+
+#### Scenario: Both kinds for one side
+- **WHEN** a side gives both a named entity and a face point
+- **THEN** the call fails with `invalid_input`
+
+### Requirement: Fix, float, rigid and flexible components
+
+A `set_component_state` tool SHALL fix or float a component of the active assembly, and make a sub-assembly rigid or flexible, changing nothing else about it.
+
+#### Scenario: Float a component
+- **WHEN** a fixed component is set `fixed: false`
+- **THEN** it is floating, and the reply reads back `fixed: false`
+
+#### Scenario: Flexible sub-assembly
+- **WHEN** a sub-assembly is set `flexible: true`
+- **THEN** its solving is flexible, and its suppression, visibility and referenced configuration are as before
diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/edge-treatment/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/edge-treatment/spec.md
new file mode 100644
index 00000000..3d90c974
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/specs/tools/edge-treatment/spec.md
@@ -0,0 +1,13 @@
+## ADDED Requirements
+
+### Requirement: Chamfers at an angle
+
+`add_chamfer` SHALL take an `angle` in degrees (default 45) and `flip`, making an angle-distance chamfer with `distance` along one face.
+
+#### Scenario: A 60 degree chamfer
+- **WHEN** an agent chamfers a block's edge 2 mm at 60°
+- **THEN** the volume removed is 0.5 x 2 x 2·tan(60°) x the edge length, within 1 %, or with the legs swapped when `flip` is set
+
+#### Scenario: Default unchanged
+- **WHEN** no angle is given
+- **THEN** the chamfer is the same 45° chamfer as before
diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/error-boundary/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/error-boundary/spec.md
new file mode 100644
index 00000000..5bdb0898
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/specs/tools/error-boundary/spec.md
@@ -0,0 +1,13 @@
+## ADDED Requirements
+
+### Requirement: A plain dict is accepted as a tool's input
+
+The tool boundary SHALL validate a dict passed where a tool expects its input model, so every tool can be called from a script with a dict.
+
+#### Scenario: A script passes a dict
+- **WHEN** a script calls `check_interference` with `{"components": ["a-1", "b-1"]}`
+- **THEN** the tool runs as it would over MCP
+
+#### Scenario: A bad dict
+- **WHEN** the dict fails validation
+- **THEN** the tool returns `error_type: invalid_input`, naming the field
diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/extrusion/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/extrusion/spec.md
new file mode 100644
index 00000000..2d1a2ab0
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/specs/tools/extrusion/spec.md
@@ -0,0 +1,17 @@
+## ADDED Requirements
+
+### Requirement: The reply says what was extruded
+
+`create_extrusion` SHALL report the end condition SolidWorks applied and, for a thin wall, its thickness and side, and SHALL NOT echo inputs it didn't apply.
+
+#### Scenario: Both directions
+- **WHEN** an agent extrudes with `both_directions: true`
+- **THEN** the body is symmetric about the sketch plane and the reply's `end_condition` is `MidPlane`
+
+### Requirement: Thin extrudes from an open profile
+
+`create_extrusion` with `thin_feature` SHALL extrude an open sketch into a wall of `thin_thickness`, on the side `thin_side` names.
+
+#### Scenario: A bent plate
+- **WHEN** an agent extrudes an open L-shaped chain 38.1 mm with a 6.35 mm thin wall
+- **THEN** one solid body is made whose volume is the chain's length times 6.35 times 38.1 mm, within 1 %
diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/reference-plane/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/reference-plane/spec.md
new file mode 100644
index 00000000..d03283df
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/specs/tools/reference-plane/spec.md
@@ -0,0 +1,21 @@
+## ADDED Requirements
+
+### Requirement: A plane at an angle about an axis
+
+`create_reference_plane` SHALL make a plane at `angle` degrees to a reference plane or face, rotated about a named `axis`.
+
+#### Scenario: 40 degrees about an axis
+- **WHEN** an agent asks for a plane at 40° to the Top Plane about an axis lying in it
+- **THEN** a new plane is added at 40° to the Top Plane, and its name comes back
+
+#### Scenario: An angle with no axis
+- **WHEN** `angle` is given without `axis`
+- **THEN** the call fails with `invalid_input` and says an axis is needed
+
+### Requirement: A plane from a face
+
+`create_reference_plane` SHALL accept `face_point`, a point on a planar face in mm, as the reference in place of a named plane.
+
+#### Scenario: Parallel to a sloped face
+- **WHEN** an agent gives a point on a sloped face and an offset
+- **THEN** a plane parallel to that face, offset by that distance, is added
diff --git a/openspec/changes/ujoint-tool-gaps/tasks.md b/openspec/changes/ujoint-tool-gaps/tasks.md
new file mode 100644
index 00000000..109f1b5d
--- /dev/null
+++ b/openspec/changes/ujoint-tool-gaps/tasks.md
@@ -0,0 +1,26 @@
+## 1. Plain dicts at the boundary
+
+- [x] 1.1 `tool_boundary.py`: validate a dict argument as its annotated model; tests beside it, and one calling every tool with a dict on the mock
+
+## 2. Part features
+
+- [x] 2.1 `create_extrusion`: reply reports the applied end condition, `both_directions`, thin thickness and `thin_side`; `thin_side` input mapped to `RevThinDir`; tests
+- [x] 2.2 `add_chamfer`: `angle` and `flip` through every adapter layer; correct the chamfer-type comment; tests
+- [x] 2.3 `create_reference_plane`: `axis` with `angle`, and `face_point`; every adapter layer; tests
+
+## 3. Assemblies
+
+- [x] 3.1 Live read-only probe: in which space a component's faces report coordinates; record it in design.md
+- [x] 3.2 `add_mate`: `face_a` / `face_b` through every adapter layer; tests
+- [x] 3.3 `set_component_state` tool and adapter method (base, mock, COM, breaker, pool), hints, support level; tests
+
+## 4. Docs and levels
+
+- [x] 4.1 Tool descriptions; skill's errors-and-limits (remove the `add_mate` and fixed-45° limits); support levels (`add_mate` stable once live); regenerate progress
+- [x] 4.2 CHANGELOG; queue
+
+## 5. Live
+
+- [x] 5.1 `tests/live/test_live_sw_ujoint_tool_gaps.py`: one test per tool (thin bent plate, mid-plane extrusion, 60° chamfer, angled plane, face plane, pin-in-hole face mate, fix/float/flexible, a dict call, a repeated interference check)
+- [x] 5.2 Run them live (paced), record GIFs, add them to the Demos page
+- [x] 5.3 Full mock suite, ruff, 100 % coverage, `mkdocs build --strict`
diff --git a/openspec/queue.md b/openspec/queue.md
index 59fb34af..94e0ff2c 100644
--- a/openspec/queue.md
+++ b/openspec/queue.md
@@ -13,8 +13,8 @@ Last updated 2026-11-06.
| 1 | Tool descriptions agents can act on: one description standard, MCP hints and family tags, and a check that keeps them | #82 (docstring half) | `tool-descriptions-for-agents` (archived) | Done 2026-11-05 (#202, #203, #204). Left: after the next release, ask Glama for a rescan and record the score in the internal assessment |
| 2 | Guidance through MCP and an installable skill: the server's `instructions`, the skill served as `skill://` resources with FastMCP 3.4.9's `SkillProvider`, and the same skill installable into an agent | #175 | `agent-skill-and-instructions` (archived) | Done 2026-11-06 (#205, #211; #147 closed as superseded) |
| 3 | The circuit breaker locks a tool out after a success and never reopens; a reset tool | #208 | `circuit-breaker-recovery` (archived) | Done 2026-11-06 (#213) |
-| 4 | Support levels for every tool, and COM coverage progress bars at the top of the README with an interactive docs page | #79 (coverage half) | `tool-support-levels-and-progress` | Implemented 2026-11-06, in review. #79's coverage half is done; its licence-tier half is item 8 |
-| 5 | Tool gaps found building the U-joint: face mates, fixed parts and flexible sub-assemblies, angled planes, angled chamfers, `both_directions`, a stale interference result, thin extrudes | #209 | not yet written | |
+| 4 | Support levels for every tool, and COM coverage progress bars at the top of the README with an interactive docs page | #79 (coverage half) | `tool-support-levels-and-progress` (archived) | Done 2026-11-06 (#214). #79's licence-tier half is item 8 |
+| 5 | Tool gaps found building the U-joint: face mates, fixed parts and flexible sub-assemblies, angled planes, angled chamfers, `both_directions`, a stale interference result, thin extrudes | #209 | `ujoint-tool-gaps` | Implemented 2026-11-06 on `feat/ujoint-tool-gaps`; live-checked; awaiting push approval |
| 6 | `--selftest` command and a compatibility-report issue template | #179 | not yet written | |
| 7 | Skills that improve the agent's results, measured on the U-joint prompt (Opus 5.5 at medium effort: the first skill changed how the agent worked, not what it built) | #207 | not yet written | |
| 8 | README: licence-tier tags | #79 (other half) | not yet written | |
@@ -88,8 +88,8 @@ their reason), then run `python -m solidworks_mcp.progress.generate`.
- `sketch_tutorial_simple_hole` suggests a negative-depth extrusion for the
hole instead of `create_cut_extrude`.
- `save_as` ignores `overwrite` (an existing file is replaced) and
- `save_file` ignores `force_save`; the `export_*` tools require a
- `format_type` they ignore (the extension decides) and ignore `options`;
+ `save_file` ignores `force_save`; the `export_*` file tools ignore
+ `options` (`format_type` is optional since #209: the extension decides);
`ExportSTEPInput`, `ExportIGESInput` and `ExportSTLInput` are unused.
- `check_interference` passes `include_hidden` where the adapter reads
`ignore_hidden`, so it has no effect.
diff --git a/openspec/specs/docs/coverage-progress/spec.md b/openspec/specs/docs/coverage-progress/spec.md
new file mode 100644
index 00000000..b4f69e9b
--- /dev/null
+++ b/openspec/specs/docs/coverage-progress/spec.md
@@ -0,0 +1,41 @@
+# docs/coverage-progress Specification
+
+## Purpose
+TBD - created by archiving change tool-support-levels-and-progress. Update Purpose after archive.
+## Requirements
+### Requirement: COM coverage is counted from a committed snapshot
+
+The project SHALL keep a snapshot of the SolidWorks type library's interface and method names, and count coverage by scanning the adapter source for those method names, without needing SolidWorks.
+
+#### Scenario: Counting coverage
+- **WHEN** the generator runs on any platform
+- **THEN** it reports, for each core interface, the methods the adapters call out of its total, and the same for the whole API, from the snapshot and the adapter source only
+
+#### Scenario: Refreshing the snapshot
+- **WHEN** the snapshot script runs on Windows with SolidWorks installed
+- **THEN** it rewrites the snapshot from the installed type library and records the SolidWorks version it came from
+
+### Requirement: Progress bars at the top of the README
+
+The README SHALL open with a generated progress image: the tools by support level and the core COM coverage, with the whole-API figure, linking to the interactive page.
+
+#### Scenario: The image matches the code
+- **WHEN** a tool, a level or the adapter's COM calls change and the image and data are not regenerated
+- **THEN** the progress check fails and says which command regenerates them
+
+#### Scenario: Readable in both themes
+- **WHEN** the README is viewed in GitHub's light or dark theme
+- **THEN** the image draws its own dark panel, so the bars and their text look the same
+
+### Requirement: An interactive progress page
+
+The docs site SHALL have a page with the same bars and every tool and core interface, filterable by level and family.
+
+#### Scenario: Filtering
+- **WHEN** a reader picks a level or a family
+- **THEN** only the matching tools are shown, each coloured by its level, with its description on hover
+
+#### Scenario: Without JavaScript
+- **WHEN** the page is read without scripts
+- **THEN** the bars and a plain table of every tool and its level still show
+
diff --git a/openspec/specs/tools/support-levels/spec.md b/openspec/specs/tools/support-levels/spec.md
new file mode 100644
index 00000000..e1287ebb
--- /dev/null
+++ b/openspec/specs/tools/support-levels/spec.md
@@ -0,0 +1,45 @@
+# tools/support-levels Specification
+
+## Purpose
+TBD - created by archiving change tool-support-levels-and-progress. Update Purpose after archive.
+## Requirements
+### Requirement: One support level per tool
+
+Every registered tool SHALL have exactly one support level, `stable`, `beta`, `experimental` or `planned`, recorded in one table, with a one-line reason for every level below `stable`.
+
+#### Scenario: A new tool without a level
+- **WHEN** a tool is registered that the table does not list
+- **THEN** the support-level check fails and names the tool
+
+#### Scenario: A listed tool that no longer exists
+- **WHEN** the table lists a name that no registered tool has
+- **THEN** the check fails and names it
+
+### Requirement: Levels are consistent with the code
+
+The check SHALL fail when a level contradicts what the code shows.
+
+#### Scenario: Planned means not implemented
+- **WHEN** a tool is `planned`
+- **THEN** its description says "Not available yet", and no tool outside `planned` says so
+
+#### Scenario: Stable means checked live
+- **WHEN** a tool is `stable`
+- **THEN** at least one test under `tests/live/` calls it by name
+
+### Requirement: The level is visible to clients
+
+A tool's level SHALL be shown in its tags and, below `stable`, in its results.
+
+#### Scenario: Tags
+- **WHEN** a client lists the tools
+- **THEN** each tool's tags include `level:` beside its family tag
+
+#### Scenario: A beta tool succeeds
+- **WHEN** a `beta` or `experimental` tool returns `status: success`
+- **THEN** the result also carries `warning`, a sentence giving the level and its reason, and every other key is unchanged
+
+#### Scenario: A stable tool succeeds
+- **WHEN** a `stable` tool returns `status: success`
+- **THEN** the result has no `warning` key
+
diff --git a/openspec/specs/tools/tool-descriptions/spec.md b/openspec/specs/tools/tool-descriptions/spec.md
index d373c715..aea21986 100644
--- a/openspec/specs/tools/tool-descriptions/spec.md
+++ b/openspec/specs/tools/tool-descriptions/spec.md
@@ -71,3 +71,15 @@ The test suite SHALL fail when a registered tool:
"Handle new thing."
- **THEN** the description check fails and names the tool
+### Requirement: A description states a level below stable
+
+A `beta` or `experimental` tool's description SHALL open with its level and the reason, in one short sentence, before what the tool does.
+
+#### Scenario: A beta tool
+- **WHEN** an agent reads a `beta` tool's description
+- **THEN** its first sentence starts "Beta:" and names the gap, e.g. "Beta: reads the material's name and library, not its properties."
+
+#### Scenario: The level changes
+- **WHEN** a tool's level changes in the table
+- **THEN** its description's opening sentence changes with it, with no edit to the tool, because the server adds that sentence from the table at registration; the check fails if a tool's description doesn't open with its level
+
diff --git a/src/solidworks_mcp/__init__.py b/src/solidworks_mcp/__init__.py
index 5bfa4aea..b1bf0108 100644
--- a/src/solidworks_mcp/__init__.py
+++ b/src/solidworks_mcp/__init__.py
@@ -1,7 +1,7 @@
"""SolidWorks MCP Server - Python Implementation with FastMCP and PydanticAI.
This is a comprehensive Python implementation of the SolidWorks MCP server, providing
-155 tools for SolidWorks automation with enhanced security, configurability, and modern
+156 tools for SolidWorks automation with enhanced security, configurability, and modern
Python architectures.
Original TypeScript implementation rights and IP remain with the original author. This
diff --git a/src/solidworks_mcp/adapters/base.py b/src/solidworks_mcp/adapters/base.py
index 6002de1e..66b3ef32 100644
--- a/src/solidworks_mcp/adapters/base.py
+++ b/src/solidworks_mcp/adapters/base.py
@@ -214,6 +214,8 @@ class ExtrusionParameters(BaseModel):
than silently falling back to some other sketch.
thin_feature (bool): The thin feature value.
thin_thickness (float | None): The thin thickness value.
+ thin_side (str): Which side of the sketch a thin wall grows on:
+ ``one_side``, ``other_side`` or ``mid_plane``.
up_to_surface (str | None): The up to surface value.
"""
@@ -223,6 +225,7 @@ class ExtrusionParameters(BaseModel):
both_directions: bool = False
thin_feature: bool = False
thin_thickness: float | None = None
+ thin_side: str = "one_side"
auto_fillet_corners: bool = False
fillet_corners_radius: float = 0.0
end_condition: str = "Blind"
@@ -975,16 +978,21 @@ async def add_fillet(
)
async def add_chamfer(
- self, distance: float, edge_names: list[str]
+ self,
+ distance: float,
+ edge_names: list[str],
+ angle: float = 45.0,
+ flip: bool = False,
) -> AdapterResult[Any]:
- """Add an equal-distance chamfer feature to selected edges.
+ """Add an angle-distance chamfer to the named or selected edges.
- Chamfers the selected edges of the current solid body by the given
- distance.
+ At the default 45 degrees both legs equal ``distance``.
Args:
- distance (float): Chamfer distance in millimeters.
+ distance (float): Chamfer distance in millimeters, along one face.
edge_names (list[str]): List of edge names to chamfer.
+ angle (float): Chamfer angle in degrees. Defaults to 45.
+ flip (bool): Measure ``distance`` along the other face.
Returns:
AdapterResult: Feature result or error.
@@ -1090,14 +1098,19 @@ async def create_reference_plane(
offset: float = 0.0,
angle: float = 0.0,
flip: bool = False,
+ axis: str | None = None,
+ face_point: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
- """Create a reference plane offset from, or angled to, an existing plane.
+ """Create a reference plane offset from, angled to, or on a plane or face.
Args:
- reference (str): Name of the reference plane or planar face.
+ reference (str): Name of the reference plane.
offset (float): Offset distance in millimetres. Defaults to 0.0.
- angle (float): Angle in degrees. Defaults to 0.0.
+ angle (float): Angle in degrees about ``axis``. Defaults to 0.0.
flip (bool): Reverse the offset or angle direction. Defaults to False.
+ axis (str | None): Named axis to rotate about; needed with ``angle``.
+ face_point (list[float] | None): ``[x, y, z]`` mm on a flat face to
+ use as the reference instead of ``reference``.
Returns:
AdapterResult[dict[str, Any]]: The new plane's name and parameters, or error.
@@ -1266,6 +1279,27 @@ async def get_model_geometry_summary(self) -> AdapterResult[dict[str, Any]]:
error="get_model_geometry_summary is not implemented by this adapter",
)
+ async def set_component_state(
+ self,
+ component: str,
+ fixed: bool | None = None,
+ flexible: bool | None = None,
+ ) -> AdapterResult[dict[str, Any]]:
+ """Fix or float a component, and make a sub-assembly rigid or flexible.
+
+ Args:
+ component (str): Instance name, as ``list_components`` reports it.
+ fixed (bool | None): True fixes it, False floats it.
+ flexible (bool | None): True flexible, False rigid.
+
+ Returns:
+ AdapterResult[dict[str, Any]]: The state before and after, or error.
+ """
+ return AdapterResult(
+ status=AdapterResultStatus.ERROR,
+ error="set_component_state is not implemented by this adapter",
+ )
+
async def add_mate(
self,
component_a: str,
@@ -1276,6 +1310,8 @@ async def add_mate(
alignment: str = "aligned",
distance: float = 0.0,
angle: float = 0.0,
+ face_a: list[float] | None = None,
+ face_b: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
"""Mate two components together in the active assembly.
@@ -1293,6 +1329,10 @@ async def add_mate(
distance (float): Distance in millimetres, for a distance mate.
Defaults to 0.0.
angle (float): Angle in degrees, for an angle mate. Defaults to 0.0.
+ face_a (list[float] | None): A point on a face of the first
+ component, in its part coordinates (mm), used instead of
+ ``entity_a``.
+ face_b (list[float] | None): The same for the second component.
Returns:
AdapterResult[dict[str, Any]]: Mate details, or error.
diff --git a/src/solidworks_mcp/adapters/circuit_breaker.py b/src/solidworks_mcp/adapters/circuit_breaker.py
index 10a56baf..d4374f6d 100644
--- a/src/solidworks_mcp/adapters/circuit_breaker.py
+++ b/src/solidworks_mcp/adapters/circuit_breaker.py
@@ -687,13 +687,22 @@ async def add_fillet(
)
async def add_chamfer(
- self, distance: float, edge_names: list[str]
+ self,
+ distance: float,
+ edge_names: list[str],
+ angle: float = 45.0,
+ flip: bool = False,
) -> AdapterResult[SolidWorksFeature]:
"""Add chamfer through circuit breaker."""
return await self._execute_with_circuit_breaker(
"add_chamfer",
- lambda: self.adapter.add_chamfer(distance, edge_names),
- input_dict={"distance": distance, "edge_names": edge_names},
+ lambda: self.adapter.add_chamfer(distance, edge_names, angle, flip),
+ input_dict={
+ "distance": distance,
+ "edge_names": edge_names,
+ "angle": angle,
+ "flip": flip,
+ },
)
async def get_selected_entities(self) -> AdapterResult[list[dict[str, Any]]]:
@@ -756,16 +765,22 @@ async def create_reference_plane(
offset: float = 0.0,
angle: float = 0.0,
flip: bool = False,
+ axis: str | None = None,
+ face_point: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
"""Create reference plane through circuit breaker."""
return await self._execute_with_circuit_breaker(
"create_reference_plane",
- lambda: self.adapter.create_reference_plane(reference, offset, angle, flip),
+ lambda: self.adapter.create_reference_plane(
+ reference, offset, angle, flip, axis, face_point
+ ),
input_dict={
"reference": reference,
"offset": offset,
"angle": angle,
"flip": flip,
+ "axis": axis,
+ "face_point": face_point,
},
)
@@ -1625,6 +1640,19 @@ async def undo(self, count: int = 1) -> AdapterResult[dict[str, Any]]:
input_dict={"count": count},
)
+ async def set_component_state(
+ self,
+ component: str,
+ fixed: bool | None = None,
+ flexible: bool | None = None,
+ ) -> AdapterResult[dict[str, Any]]:
+ """Set a component's state through circuit breaker."""
+ return await self._execute_with_circuit_breaker(
+ "set_component_state",
+ lambda: self.adapter.set_component_state(component, fixed, flexible),
+ input_dict={"component": component, "fixed": fixed, "flexible": flexible},
+ )
+
async def add_mate(
self,
component_a: str,
@@ -1635,6 +1663,8 @@ async def add_mate(
alignment: str = "aligned",
distance: float = 0.0,
angle: float = 0.0,
+ face_a: list[float] | None = None,
+ face_b: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
"""Add mate through circuit breaker.
@@ -1649,6 +1679,9 @@ async def add_mate(
alignment (str): Mate alignment. Defaults to "aligned".
distance (float): Distance in millimetres. Defaults to 0.0.
angle (float): Angle in degrees. Defaults to 0.0.
+ face_a (list[float] | None): A point on a face of the first
+ component, in its part coordinates (mm), instead of entity_a.
+ face_b (list[float] | None): The same for the second component.
Returns:
AdapterResult[dict[str, Any]]: The result produced by the operation.
@@ -1664,6 +1697,8 @@ async def add_mate(
alignment,
distance,
angle,
+ face_a,
+ face_b,
),
input_dict={
"component_a": component_a,
@@ -1674,6 +1709,8 @@ async def add_mate(
"alignment": alignment,
"distance": distance,
"angle": angle,
+ "face_a": face_a,
+ "face_b": face_b,
},
)
diff --git a/src/solidworks_mcp/adapters/connection_pool.py b/src/solidworks_mcp/adapters/connection_pool.py
index f38e8687..5ad99d18 100644
--- a/src/solidworks_mcp/adapters/connection_pool.py
+++ b/src/solidworks_mcp/adapters/connection_pool.py
@@ -657,11 +657,16 @@ async def add_fillet(
)
async def add_chamfer(
- self, distance: float, edge_names: list[str]
+ self,
+ distance: float,
+ edge_names: list[str],
+ angle: float = 45.0,
+ flip: bool = False,
) -> AdapterResult[SolidWorksFeature]:
"""Add chamfer using pool."""
return await self._execute_with_pool(
- "add_chamfer", lambda adapter: adapter.add_chamfer(distance, edge_names)
+ "add_chamfer",
+ lambda adapter: adapter.add_chamfer(distance, edge_names, angle, flip),
)
async def get_selected_entities(self) -> AdapterResult[list[dict[str, Any]]]:
@@ -709,12 +714,14 @@ async def create_reference_plane(
offset: float = 0.0,
angle: float = 0.0,
flip: bool = False,
+ axis: str | None = None,
+ face_point: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
"""Create reference plane using pool."""
return await self._execute_with_pool(
"create_reference_plane",
lambda adapter: adapter.create_reference_plane(
- reference, offset, angle, flip
+ reference, offset, angle, flip, axis, face_point
),
)
@@ -1516,6 +1523,18 @@ async def undo(self, count: int = 1) -> AdapterResult[dict[str, Any]]:
"undo", lambda adapter: adapter.undo(count)
)
+ async def set_component_state(
+ self,
+ component: str,
+ fixed: bool | None = None,
+ flexible: bool | None = None,
+ ) -> AdapterResult[dict[str, Any]]:
+ """Set a component's state using pool."""
+ return await self._execute_with_pool(
+ "set_component_state",
+ lambda adapter: adapter.set_component_state(component, fixed, flexible),
+ )
+
async def add_mate(
self,
component_a: str,
@@ -1526,6 +1545,8 @@ async def add_mate(
alignment: str = "aligned",
distance: float = 0.0,
angle: float = 0.0,
+ face_a: list[float] | None = None,
+ face_b: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
"""Add mate using pool.
@@ -1540,6 +1561,9 @@ async def add_mate(
alignment (str): Mate alignment. Defaults to "aligned".
distance (float): Distance in millimetres. Defaults to 0.0.
angle (float): Angle in degrees. Defaults to 0.0.
+ face_a (list[float] | None): A point on a face of the first
+ component, in its part coordinates (mm), instead of entity_a.
+ face_b (list[float] | None): The same for the second component.
Returns:
AdapterResult[dict[str, Any]]: The result produced by the operation.
@@ -1555,6 +1579,8 @@ async def add_mate(
alignment,
distance,
angle,
+ face_a,
+ face_b,
),
)
diff --git a/src/solidworks_mcp/adapters/mock_adapter.py b/src/solidworks_mcp/adapters/mock_adapter.py
index ec9ca4ef..cbd3d672 100644
--- a/src/solidworks_mcp/adapters/mock_adapter.py
+++ b/src/solidworks_mcp/adapters/mock_adapter.py
@@ -202,6 +202,7 @@ def __init__(self, config: object | None = None) -> None:
# Mates added via add_mate, in order, so the geometry summary can
# report a mate count without real geometry.
self._mates: list[dict[str, Any]] = []
+ self._component_states: dict[str, dict[str, bool]] = {}
# Drawing views placed via create_drawing_view / add_drawing_view /
# create_technical_drawing, in sheet order. See list_drawing_views.
self._drawing_views: list[str] = []
@@ -856,10 +857,17 @@ async def add_fillet(
return await self._mock_edge_feature("Fillet", radius, edge_names)
async def add_chamfer(
- self, distance: float, edge_names: list[str]
+ self,
+ distance: float,
+ edge_names: list[str],
+ angle: float = 45.0,
+ flip: bool = False,
) -> AdapterResult[SolidWorksFeature]:
- """Mock equal-distance chamfer (see :meth:`_mock_edge_feature`)."""
- return await self._mock_edge_feature("Chamfer", distance, edge_names)
+ """Mock angle-distance chamfer (see :meth:`_mock_edge_feature`)."""
+ result = await self._mock_edge_feature("Chamfer", distance, edge_names)
+ if result.is_success:
+ result.data.parameters.update({"angle": angle, "flip": flip})
+ return result
async def _mock_edge_feature(
self, kind: str, size: float, edge_names: list[str]
@@ -1160,8 +1168,12 @@ async def create_extrusion(
"draft_angle": params.draft_angle,
"reverse_direction": params.reverse_direction,
"both_directions": params.both_directions,
+ "end_condition": "MidPlane"
+ if params.both_directions
+ else params.end_condition,
"thin_feature": params.thin_feature,
"thin_thickness": params.thin_thickness,
+ "thin_side": params.thin_side if params.thin_feature else None,
},
properties={"created": datetime.now().isoformat(), "mock": True},
)
@@ -3036,6 +3048,42 @@ async def undo(self, count: int = 1) -> AdapterResult[dict[str, Any]]:
execution_time=self._delays["model_operation"],
)
+ async def set_component_state(
+ self,
+ component: str,
+ fixed: bool | None = None,
+ flexible: bool | None = None,
+ ) -> AdapterResult[dict[str, Any]]:
+ """Mock fixing, floating and flexing a component.
+
+ Tracks each component's state on ``self._component_states``.
+
+ Args:
+ component (str): Instance name.
+ fixed (bool | None): True fixes it, False floats it.
+ flexible (bool | None): True flexible, False rigid.
+
+ Returns:
+ AdapterResult[dict[str, Any]]: The state before and after.
+ """
+ if fixed is None and flexible is None:
+ return AdapterResult(
+ status=AdapterResultStatus.ERROR,
+ error="Nothing to change: give fixed, flexible or both.",
+ )
+ states = self._component_states
+ before = dict(states.get(component, {"fixed": False, "flexible": False}))
+ after = dict(before)
+ if fixed is not None:
+ after["fixed"] = fixed
+ if flexible is not None:
+ after["flexible"] = flexible
+ states[component] = after
+ return AdapterResult(
+ status=AdapterResultStatus.SUCCESS,
+ data={"component": component, "before": before, "after": after},
+ )
+
async def add_mate(
self,
component_a: str,
@@ -3046,6 +3094,8 @@ async def add_mate(
alignment: str = "aligned",
distance: float = 0.0,
angle: float = 0.0,
+ face_a: list[float] | None = None,
+ face_b: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
"""Mock mating two components in the active assembly.
@@ -3061,10 +3111,15 @@ async def add_mate(
distance (float): Distance in millimetres, for a distance mate.
Defaults to 0.0.
angle (float): Angle in degrees, for an angle mate. Defaults to 0.0.
+ face_a (list[float] | None): A point on a face of the first
+ component (part coordinates, mm), used instead of entity_a.
+ face_b (list[float] | None): The same for the second component.
Returns:
AdapterResult[dict[str, Any]]: The result produced by the operation.
"""
+ from .solidworks.face_selection import mate_entity_label
+
known_mate_types = {
"coincident",
"concentric",
@@ -3095,7 +3150,10 @@ async def add_mate(
"mate_type": mate_type,
"alignment": alignment,
"components": [component_a, component_b],
- "entities": [entity_a, entity_b],
+ "entities": [
+ mate_entity_label(entity_a, face_a),
+ mate_entity_label(entity_b, face_b),
+ ],
"distance": distance or None,
"angle": angle or None,
# The real adapter fills these by comparing every component's
@@ -3117,51 +3175,36 @@ async def create_reference_plane(
offset: float = 0.0,
angle: float = 0.0,
flip: bool = False,
+ axis: str | None = None,
+ face_point: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
- """Mock creating a reference plane offset from an existing plane.
-
- Mirrors the live adapter's refusals rather than fabricating a
- result it cannot know: an angled plane needs a second reference this
- signature cannot supply, and an offset of zero would be coincident
- with its reference. See ``_create_reference_plane_impl`` in
- ``solidworks/features.py`` for the live contract this mirrors.
+ """Mock creating a reference plane offset from, angled to, or on a face.
- On success, invents a sequential ``PlaneN`` name tracked on
+ Refuses exactly what the live adapter refuses (the shared
+ ``reference_plane_refusal`` in ``solidworks/features.py``). On
+ success, invents a sequential ``PlaneN`` name tracked on
``self._reference_planes`` and advances the shared mock feature-tree
counter so ``features_before``/``features_after`` differ by exactly
one, the same signal the live adapter derives from
``IFeatureManager::GetFeatureCount``.
Args:
- reference (str): Name of the reference plane or planar face.
+ reference (str): Name of the reference plane.
offset (float): Offset distance in millimetres. Defaults to 0.0.
- angle (float): Angle in degrees; unsupported (non-zero errors).
- Defaults to 0.0.
+ angle (float): Angle in degrees about ``axis``. Defaults to 0.0.
flip (bool): Reverse the offset direction. Defaults to False.
+ axis (str | None): Axis to rotate about, for an angle.
+ face_point (list[float] | None): Point on a flat face, in mm.
Returns:
AdapterResult[dict[str, Any]]: The new plane's name and
parameters, or error.
"""
- if angle:
- return AdapterResult(
- status=AdapterResultStatus.ERROR,
- error=(
- "create_reference_plane does not support 'angle' yet: an "
- "angled plane needs a second reference (an axis or edge "
- "to rotate about) which this signature cannot take. Use "
- "'offset' for parallel planes."
- ),
- )
+ from .solidworks.features import reference_plane_refusal
- if not offset:
- return AdapterResult(
- status=AdapterResultStatus.ERROR,
- error=(
- "create_reference_plane requires a non-zero offset - a "
- "plane coincident with its reference is not useful"
- ),
- )
+ refusal = reference_plane_refusal(reference, offset, angle, axis, face_point)
+ if refusal is not None:
+ return AdapterResult(status=AdapterResultStatus.ERROR, error=refusal)
await asyncio.sleep(self._delays["feature_operation"])
self._operation_count += 1
@@ -3176,9 +3219,11 @@ async def create_reference_plane(
status=AdapterResultStatus.SUCCESS,
data={
"name": self._reference_planes[-1],
- "reference": reference,
- "offset": offset,
- "angle": None,
+ "reference": reference if face_point is None else None,
+ "face_point": face_point,
+ "offset": offset or None,
+ "angle": angle or None,
+ "axis": axis if angle else None,
"flip": flip,
"features_before": before,
"features_after": after,
@@ -3223,10 +3268,8 @@ async def create_axis(self, reference: str) -> AdapterResult[dict[str, Any]]:
# Invent a sequential "AxisN" name, tracked on self._axes, the same
# way create_reference_plane invents "PlaneN" names on
- # self._reference_planes. InsertAxis2 returns only a bool, so the
- # live adapter cannot report a name either - but pattern_circular
- # needs some name to validate the axis argument against, and this
- # keeps the mock's bookkeeping consistent with the plane convention.
+ # self._reference_planes. The live adapter reads the new axis's name
+ # from the end of the feature tree (InsertAxis2 returns only a bool).
self._axes.append(f"Axis{len(self._axes) + 1}")
return AdapterResult(
diff --git a/src/solidworks_mcp/adapters/solidworks/face_selection.py b/src/solidworks_mcp/adapters/solidworks/face_selection.py
index bc784a2a..3085f31f 100644
--- a/src/solidworks_mcp/adapters/solidworks/face_selection.py
+++ b/src/solidworks_mcp/adapters/solidworks/face_selection.py
@@ -73,6 +73,23 @@ def side_vector(side: str) -> tuple[float, float, float]:
) from None
+def mate_entity_label(entity: str, face: list[float] | None) -> str:
+ """How a mate's reply names one side: the entity, or the face point.
+
+ Shared by the live and mock adapters.
+
+ Args:
+ entity: The named entity (used when no face is given).
+ face: A point on a face, in part coordinates (mm).
+
+ Returns:
+ str: e.g. ``"Front Plane"`` or ``"face at [0.0, 5.0, 0.0] mm"``.
+ """
+ if face is None:
+ return entity
+ return f"face at {[float(c) for c in face]} mm"
+
+
def validate_face_query(
point: list[float] | None,
side: str | None,
@@ -265,6 +282,21 @@ def faces(self) -> list[FaceInfo]:
part = self._model
_flag(self._adapter, part, "GetBodies2")
bodies = as_list(_attempt(self._adapter, lambda: part.GetBodies2(0, False)))
+ return self._body_faces(bodies)
+
+ def component_faces(self, component: Any) -> list[FaceInfo]:
+ """Return every face of a component's solid bodies.
+
+ Measured on SW 2026: these faces report their geometry in the
+ component's own part coordinates, wherever it sits in the assembly,
+ and ``IEntity::GetComponent`` on them returns the component.
+ """
+ _flag(self._adapter, component, "GetBodies2")
+ bodies = as_list(_attempt(self._adapter, lambda: component.GetBodies2(0)))
+ return self._body_faces(bodies)
+
+ def _body_faces(self, bodies: list[Any]) -> list[FaceInfo]:
+ """Describe every face of ``bodies``."""
out: list[FaceInfo] = []
for body in bodies:
_flag(self._adapter, body, "GetFaces")
diff --git a/src/solidworks_mcp/adapters/solidworks/features.py b/src/solidworks_mcp/adapters/solidworks/features.py
index 8f497259..be553139 100644
--- a/src/solidworks_mcp/adapters/solidworks/features.py
+++ b/src/solidworks_mcp/adapters/solidworks/features.py
@@ -99,9 +99,13 @@ async def add_fillet(
return result
async def add_chamfer(
- self, distance: float, edge_names: list[str]
+ self,
+ distance: float,
+ edge_names: list[str],
+ angle: float = 45.0,
+ flip: bool = False,
) -> AdapterResult[SolidWorksFeature]:
- return _add_chamfer_impl(self, distance, edge_names)
+ return _add_chamfer_impl(self, distance, edge_names, angle, flip)
async def delete_feature(self, name: str) -> AdapterResult[dict[str, Any]]:
return _delete_feature_impl(self, name)
@@ -135,8 +139,12 @@ async def create_reference_plane(
offset: float = 0.0,
angle: float = 0.0,
flip: bool = False,
+ axis: str | None = None,
+ face_point: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
- return _create_reference_plane_impl(self, reference, offset, angle, flip)
+ return _create_reference_plane_impl(
+ self, reference, offset, angle, flip, axis, face_point
+ )
async def create_axis(self, reference: str) -> AdapterResult[dict[str, Any]]:
return _create_axis_impl(self, reference)
@@ -351,6 +359,10 @@ async def mirror_feature(
}
+#: ``swThinWallType_e`` for each ``thin_side``.
+_THIN_SIDES: dict[str, int] = {"one_side": 0, "other_side": 1, "mid_plane": 2}
+
+
def _extrusion_end_condition(
adapter: Any, end_condition: str, both_directions: bool
) -> tuple[str, int]:
@@ -465,7 +477,13 @@ def _extrusion_operation() -> SolidWorksFeature:
both_directions=bool(getattr(params, "both_directions", False)),
auto_fillet_corners=bool(getattr(params, "auto_fillet_corners", False)),
fillet_corners_radius=float(getattr(params, "fillet_corners_radius", 0.0)),
+ thin_side=str(getattr(params, "thin_side", "one_side") or "one_side"),
)
+ if normalized.thin_side not in _THIN_SIDES:
+ raise ValueError(
+ f"Unsupported thin_side {normalized.thin_side!r}; use one of: "
+ + ", ".join(_THIN_SIDES)
+ )
feature_manager = adapter.currentModel.FeatureManager
t0 = adapter.constants.get("swStartSketchPlane", 0)
end_name, t1 = _extrusion_end_condition(
@@ -497,9 +515,9 @@ def _extrusion_operation() -> SolidWorksFeature:
normalized.merge_result,
normalized.thin_thickness / 1000.0,
normalized.thin_thickness / 1000.0,
- 0.0,
- 0,
- 0,
+ 0.0, # EndThk
+ _THIN_SIDES[normalized.thin_side], # RevThinDir: swThinWallType_e
+ 0, # CapEnds
normalized.auto_fillet_corners,
normalized.fillet_corners_radius / 1000.0,
False,
@@ -595,6 +613,8 @@ def _extrusion_operation() -> SolidWorksFeature:
"reverse_direction": normalized.reverse_direction,
"thin_feature": normalized.thin_feature,
"thin_thickness": normalized.thin_thickness,
+ "thin_side": normalized.thin_side if normalized.thin_feature else None,
+ "both_directions": normalized.both_directions,
"end_condition": end_name,
},
properties={"created": datetime.now().isoformat()},
@@ -2065,14 +2085,21 @@ def _fillet_operation() -> SolidWorksFeature:
def _add_chamfer_impl(
- adapter: Any, distance: float, edge_names: list[str]
+ adapter: Any,
+ distance: float,
+ edge_names: list[str],
+ angle: float = 45.0,
+ flip: bool = False,
) -> AdapterResult[SolidWorksFeature]:
- """Create an equal-distance chamfer on one or more named edges.
+ """Create an angle-distance chamfer on one or more named edges.
Each edge in ``edge_names`` is selected by name using
``Extension.SelectByID2`` with entity type ``"EDGE"``. After all edges
are in the selection set, ``IFeatureManager::InsertFeatureChamfer`` is
- called in equal-distance mode (type ``1``). A version-gated
+ called with ``swChamferAngleDistance`` (type ``1``; equal distance is
+ 16). At 45 degrees the two legs are equal, which is what this call has
+ always made. ``Options`` 1 is ``swFeatureChamferFlipDirection``, kept as
+ the default so existing chamfers are unchanged; ``flip`` clears it. A version-gated
``IModelDoc2::FeatureChamferType`` path was tried for SW 2025+ but
live-verified to silently create an empty, zero-volume feature -- see
the comment at its call site for how this was confirmed and why
@@ -2081,8 +2108,10 @@ def _add_chamfer_impl(
Args:
adapter: A fully connected ``PyWin32Adapter`` with a non-``None``
``currentModel``.
- distance: Chamfer distance in **millimetres**. Converted to metres
- internally.
+ distance: Chamfer distance in **millimetres**, along one face.
+ Converted to metres internally.
+ angle: Chamfer angle in degrees (0-90 exclusive). Defaults to 45.
+ flip: Measure ``distance`` along the other face.
edge_names: List of SolidWorks edge entity names, e.g.
``["Edge<2>", "Edge<5>"]``, coordinate hints in metres
(``"x,y,z"``), or ``"face:x,y,z"`` to select a whole face and
@@ -2168,10 +2197,10 @@ def _chamfer_operation() -> SolidWorksFeature:
feature_name = "Chamfer"
feature, insert_err = adapter._attempt_with_error(
lambda: fm.InsertFeatureChamfer(
- 1, # Options
- 1, # ChamferType = equal distance
+ 0 if flip else 1, # Options: swFeatureChamferFlipDirection
+ 1, # ChamferType: swChamferAngleDistance
distance / 1000.0, # Width in metres
- math.pi / 4, # 45 degree angle
+ math.radians(angle),
0.0,
0.0,
0.0,
@@ -2189,7 +2218,12 @@ def _chamfer_operation() -> SolidWorksFeature:
name=feature_name,
type="Chamfer",
id=adapter._get_feature_id(feature) if feature else "",
- parameters={"distance": distance, "edges": edge_names},
+ parameters={
+ "distance": distance,
+ "angle": angle,
+ "flip": flip,
+ "edges": edge_names,
+ },
properties={"created": datetime.now().isoformat()},
)
@@ -2585,6 +2619,7 @@ def _undo_operation() -> dict[str, Any]:
#: ``swRefPlaneReferenceConstraints_e`` members used by ``InsertRefPlane``.
_REF_PLANE_DISTANCE = 8
_REF_PLANE_ANGLE = 16
+_REF_PLANE_COINCIDENT = 4
_REF_PLANE_OPTION_FLIP = 256
#: Plane pairs whose intersection defines each principal axis. ``InsertAxis2``
@@ -2707,36 +2742,121 @@ def _select_reference_entity(
return False
+def _select_face_at_point(adapter: Any, point_mm: list[float], mark: int) -> bool:
+ """Select the planar face of the active part at ``point_mm`` under ``mark``.
+
+ The face is found as ``select_face_edges`` finds it (the face whose
+ closest point is within 0.01 mm), then selected with ``IEntity.Select4``
+ and a ``SelectData`` carrying the mark ``InsertRefPlane`` reads.
+
+ Args:
+ adapter: A connected adapter with a part open.
+ point_mm: ``[x, y, z]`` on the face, in millimetres.
+ mark: Selection mark for this reference.
+
+ Returns:
+ bool: True when the face was selected.
+
+ Raises:
+ LookupError: When no face lies at the point, or the face isn't flat.
+ """
+ from .face_selection import FaceEdgeSelector
+
+ selector = FaceEdgeSelector(adapter)
+ face = selector.face_at_point(selector.faces(), point_mm)
+ if not face.is_planar:
+ raise LookupError(
+ f"The face at {list(point_mm)} mm is not flat; a plane needs a flat face."
+ )
+ sel_mgr = adapter.currentModel.SelectionManager
+ _flag(adapter, sel_mgr, "CreateSelectData")
+ select_data = _attempt(adapter, lambda: sel_mgr.CreateSelectData())
+ select_data.Mark = mark
+ _flag(adapter, face.handle, "Select4")
+ return bool(
+ _attempt(adapter, lambda: face.handle.Select4(False, select_data), False)
+ )
+
+
+def reference_plane_refusal(
+ reference: str,
+ offset: float,
+ angle: float,
+ axis: str | None,
+ face_point: list[float] | None,
+) -> str | None:
+ """Why a reference-plane request can't be made, or None when it can.
+
+ Shared by the live and mock adapters so both refuse the same requests.
+
+ Args:
+ reference: Named reference plane (unused with ``face_point``).
+ offset: Offset in mm.
+ angle: Angle in degrees.
+ axis: Axis to rotate about, for an angle.
+ face_point: Point on a flat face to use as the reference.
+
+ Returns:
+ str | None: The refusal message, or None.
+ """
+ if not reference and face_point is None:
+ return (
+ "create_reference_plane requires a reference plane name or a "
+ "face_point on a flat face"
+ )
+ if angle and not axis:
+ return (
+ "An angled plane needs an axis to rotate about: pass 'axis' "
+ "(e.g. 'Axis1', made with create_axis)."
+ )
+ if not angle and not offset and face_point is None:
+ return (
+ "create_reference_plane requires a non-zero offset - a plane "
+ "coincident with a named plane is not useful"
+ )
+ return None
+
+
def _create_reference_plane_impl(
adapter: Any,
reference: str,
offset: float,
angle: float,
flip: bool,
+ axis: str | None = None,
+ face_point: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
- """Create a reference plane offset from, or angled to, an existing plane.
+ """Create a reference plane offset from, angled to, or on a plane or face.
Wraps ``IFeatureManager::InsertRefPlane(FirstConstraint,
- FirstConstraintAngleOrDistance, Second, 0, Third, 0)``. The reference
- entity must be selected under **mark 0** first, which is what the
- SolidWorks documentation example does before the identical call.
-
- This is what lets a sketch be opened anywhere other than the six built-in
- planes. ``create_sketch`` already accepts a plane by its tree name, so the
- name returned here can be passed straight to it - verified live: a plane
- 76.2 mm off the Front Plane came back as ``"Plane1"``, and a circle
- sketched on it extruded to exactly the expected volume.
+ FirstConstraintAngleOrDistance, Second, value, Third, value)``. The
+ reference (a named plane, or the flat face at ``face_point``) is selected
+ under **mark 0**; for an angled plane, ``axis`` is selected under
+ **mark 1** with the Coincident constraint, which is the second reference
+ an angled plane needs (an angle constraint on one plane alone adds
+ nothing, measured on SW 2025).
+
+ ``create_sketch`` accepts a plane by its tree name, so the name returned
+ here can be passed straight to it - verified live: a plane 76.2 mm off
+ the Front Plane came back as ``"Plane1"``, and a circle sketched on it
+ extruded to exactly the expected volume.
Args:
adapter: A connected ``PyWin32Adapter`` with a valid ``currentModel``.
- reference: Name of the reference plane or planar face.
+ reference: Name of the reference plane; ignored when ``face_point``
+ is given.
offset: Offset distance in **millimetres**, converted to metres.
- angle: Angle in **degrees**; used instead of ``offset`` when non-zero.
+ angle: Angle in **degrees** about ``axis``; used instead of ``offset``
+ when non-zero.
flip: Reverse the offset or angle direction.
+ axis: Named axis to rotate about (e.g. ``"Axis1"``); required with
+ ``angle``.
+ face_point: ``[x, y, z]`` mm on a flat face to use as the reference;
+ with a zero offset the plane lies on the face.
Returns:
AdapterResult[dict[str, Any]]: The new plane's name and the parameters
- used. ``ERROR`` when there is no model, the reference cannot be
+ used. ``ERROR`` when there is no model, a reference cannot be
selected, or no plane was added to the tree.
Raises:
@@ -2744,42 +2864,19 @@ def _create_reference_plane_impl(
Example::
- plane = await adapter.create_reference_plane("Front Plane", offset=76.2)
+ plane = await adapter.create_reference_plane("Top Plane", angle=40, axis="Axis1")
await adapter.create_sketch(plane.data["name"])
"""
if not adapter.currentModel:
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
- if not reference:
- return AdapterResult(
- status=AdapterResultStatus.ERROR,
- error="create_reference_plane requires a reference plane/face name",
- )
-
- if angle:
- # Measured on SW 2025: InsertRefPlane with the angle constraint and a
- # single selected plane adds nothing to the tree and reports no error.
- # An angled plane is under-defined by one reference - SolidWorks needs
- # a second entity (an axis or edge) under mark 1 to rotate about.
- # Refusing is better than returning a plane name that does not exist.
- return AdapterResult(
- status=AdapterResultStatus.ERROR,
- error=(
- "create_reference_plane does not support 'angle' yet: an "
- "angled plane needs a second reference (an axis or edge to "
- "rotate about) which this signature cannot take. Use 'offset' "
- "for parallel planes."
- ),
- )
+ refusal = reference_plane_refusal(reference, offset, angle, axis, face_point)
+ if refusal is not None:
+ return AdapterResult(status=AdapterResultStatus.ERROR, error=refusal)
- if not offset:
- return AdapterResult(
- status=AdapterResultStatus.ERROR,
- error=(
- "create_reference_plane requires a non-zero offset - a plane "
- "coincident with its reference is not useful"
- ),
- )
+ described = (
+ f"the face at {list(face_point)} mm" if face_point is not None else reference
+ )
def _plane_operation() -> dict[str, Any]:
before = _feature_count(adapter)
@@ -2787,34 +2884,48 @@ def _plane_operation() -> dict[str, Any]:
lambda: adapter.currentModel.ClearSelection2(True), default=None
)
- if not _select_reference_entity(adapter, reference, 0, append=False):
+ if face_point is not None:
+ if not _select_face_at_point(adapter, face_point, 0):
+ raise Exception(f"Failed to select {described}.")
+ elif not _select_reference_entity(adapter, reference, 0, append=False):
raise Exception(
f"Failed to select reference plane/face: {reference}. "
- "Use an existing plane name (e.g. 'Front Plane') or a planar "
- "face."
+ "Use an existing plane name (e.g. 'Front Plane'), or face_point "
+ "for a face."
)
- value, flip_bits = _offset_plane_distance(offset, flip)
- constraint = _REF_PLANE_DISTANCE | flip_bits
+ if angle:
+ if not _select_named_feature(adapter, str(axis), 1, append=True):
+ raise Exception(
+ f"Failed to select axis '{axis}'. Use an axis in the tree, "
+ "e.g. one made with create_axis."
+ )
+ first = _REF_PLANE_ANGLE | (_REF_PLANE_OPTION_FLIP if flip else 0)
+ value = math.radians(float(angle))
+ second = _REF_PLANE_COINCIDENT
+ elif offset:
+ value, flip_bits = _offset_plane_distance(offset, flip)
+ first = _REF_PLANE_DISTANCE | flip_bits
+ second = 0
+ else:
+ first, value, second = _REF_PLANE_COINCIDENT, 0.0, 0
feature_manager = sw_type_info.flagged(
adapter.currentModel.FeatureManager, "IFeatureManager"
)
plane = adapter._attempt(
- lambda: feature_manager.InsertRefPlane(constraint, value, 0, 0.0, 0, 0.0),
+ lambda: feature_manager.InsertRefPlane(first, value, second, 0.0, 0, 0.0),
default=None,
)
after = _feature_count(adapter)
if before is not None and after is not None and after <= before:
raise Exception(
- f"No reference plane was added from '{reference}' - the "
+ f"No reference plane was added from {described} - the "
f"feature tree still has {after} feature(s)."
)
if plane is None:
- raise Exception(
- f"InsertRefPlane returned nothing for reference '{reference}'"
- )
+ raise Exception(f"InsertRefPlane returned nothing for {described}")
name = adapter._attempt(
lambda: adapter._get_attr_or_call(plane, "Name"), default=None
@@ -2829,9 +2940,11 @@ def _plane_operation() -> dict[str, Any]:
return {
"name": str(name),
- "reference": reference,
+ "reference": reference if face_point is None else None,
+ "face_point": list(face_point) if face_point is not None else None,
"offset": offset or None,
"angle": angle or None,
+ "axis": axis if angle else None,
"flip": flip,
"features_before": before,
"features_after": after,
@@ -2905,7 +3018,14 @@ def _axis_operation() -> dict[str, Any]:
"unchanged."
)
+ # The new axis is the last feature; its name is what create_reference_plane's
+ # `axis` takes. None if it can't be read, never invented.
+ newest = adapter._attempt(
+ lambda: adapter.currentModel.FeatureByPositionReverse(0), default=None
+ )
+ name = adapter._attempt(lambda: newest.Name, default=None) if newest else None
return {
+ "name": str(name) if name else None,
"reference": key,
"planes": [plane_a, plane_b],
"features_before": before,
diff --git a/src/solidworks_mcp/adapters/solidworks/io.py b/src/solidworks_mcp/adapters/solidworks/io.py
index 8f781a3b..af6bff79 100644
--- a/src/solidworks_mcp/adapters/solidworks/io.py
+++ b/src/solidworks_mcp/adapters/solidworks/io.py
@@ -14,6 +14,7 @@
from .. import sw_type_info as _sw_type_info
from ..base import AdapterResult, AdapterResultStatus, MassProperties, SolidWorksModel
+from .face_selection import mate_entity_label
try:
import pythoncom
@@ -293,6 +294,118 @@ def _byref_bstr() -> Any:
)
+def _find_components(adapter: Any, assembly: Any, names: list[str]) -> dict[str, Any]:
+ """The named top-level components of ``assembly``, as ``IComponent2``.
+
+ Args:
+ adapter: The connected adapter.
+ assembly: The ``IAssemblyDoc`` dispatch.
+ names: Instance names, e.g. ``["bracket-1", "pin-1"]``.
+
+ Returns:
+ dict[str, Any]: Name to wrapped component, one per name.
+
+ Raises:
+ Exception: When the components can't be read, or a name isn't there
+ (the message lists what the assembly holds).
+ """
+ components = adapter._attempt(lambda: assembly.GetComponents(True), default=None)
+ if not isinstance(components, (list, tuple)):
+ raise Exception("Could not read the assembly's components")
+
+ found: dict[str, Any] = {}
+ for component in components:
+ wrapped = _as_com(adapter, component, "IComponent2")
+ if wrapped is None:
+ continue
+ name = adapter._attempt(lambda w=wrapped: w.Name2, default=None)
+ if name and str(name) in names:
+ found[str(name)] = wrapped
+
+ missing = [n for n in names if n not in found]
+ if missing:
+ available = [
+ str(
+ adapter._attempt(
+ lambda c=c: _as_com(adapter, c, "IComponent2").Name2,
+ default="?",
+ )
+ )
+ for c in components
+ ]
+ raise Exception(
+ f"Component(s) not found: {', '.join(missing)}. "
+ f"The assembly holds: {', '.join(available)}."
+ )
+ return found
+
+
+def _component_state(adapter: Any, component: Any) -> dict[str, Any]:
+ """Read what ``CompConfigProperties5`` sets, plus whether it is fixed."""
+ visible = adapter._attempt(lambda: component.Visible, default=None)
+ return {
+ "fixed": adapter._attempt(lambda: bool(component.IsFixed()), default=None),
+ "flexible": adapter._attempt(lambda: component.Solving == 1, default=None),
+ "suppression": adapter._attempt(
+ lambda: component.GetSuppression2(), default=None
+ ),
+ "visible": None if visible is None else visible == 1,
+ "referenced_configuration": adapter._attempt(
+ lambda: component.ReferencedConfiguration, default=None
+ ),
+ "exclude_from_bom": adapter._attempt(
+ lambda: bool(component.ExcludeFromBOM), default=None
+ ),
+ "envelope": adapter._attempt(
+ lambda: bool(component.IsEnvelope()), default=None
+ ),
+ }
+
+
+def _select_component_face(
+ adapter: Any,
+ model: Any,
+ component: Any,
+ component_name: str,
+ point: list[float],
+ append: bool,
+) -> None:
+ """Select the face of ``component`` at ``point`` (its part coordinates, mm).
+
+ Only that component's faces are searched, so coincident surfaces of two
+ parts (a pin in its hole) can't be confused.
+
+ Args:
+ adapter: The connected adapter.
+ model: The active assembly document.
+ component: The ``IComponent2`` dispatch.
+ component_name: Its instance name, for messages.
+ point: ``[x, y, z]`` mm on the face, in the component's part coordinates.
+ append: Add to the current selection.
+
+ Raises:
+ Exception: When no face of the component lies at the point, or it
+ won't select.
+ """
+ from .face_selection import FaceEdgeSelector
+
+ selector = FaceEdgeSelector(adapter)
+ try:
+ face = selector.face_at_point(selector.component_faces(component), point)
+ except LookupError as exc:
+ raise Exception(
+ f"Face not found on {component_name} at {list(point)} mm (part "
+ f"coordinates): {exc}"
+ ) from exc
+ sel_mgr = _sw_type_info.flagged(model.SelectionManager, "ISelectionMgr")
+ select_data = adapter._attempt(lambda: sel_mgr.CreateSelectData(), default=None)
+ handle = _sw_type_info.flagged(face.handle, "IEntity")
+ if not adapter._attempt(lambda: handle.Select4(append, select_data), default=False):
+ raise Exception(
+ f"Failed to select the face of {component_name} at {list(point)} mm"
+ )
+
+
def _byref_int() -> Any:
"""Return a byref long VARIANT for a SolidWorks out-parameter.
@@ -2616,17 +2729,19 @@ async def add_mate(
alignment: str = "aligned",
distance: float = 0.0,
angle: float = 0.0,
+ face_a: list[float] | None = None,
+ face_b: list[float] | None = None,
) -> AdapterResult[dict[str, Any]]:
"""Mate two components together.
- Wraps ``IAssemblyDoc::AddMate5``. The two entities are selected via
- ``IComponent2::FeatureByName`` + ``IFeature::Select2`` rather than
- ``SelectByID2``, which raises ``Type mismatch`` on this build.
-
- That restricts the entities to *named tree features* — the reference
- planes and axes of each component. Plane-to-plane mating covers
- alignment and stacking, which is the common case; mating to a specific
- face or edge needs entity names this adapter cannot enumerate.
+ Wraps ``IAssemblyDoc::AddMate5``. A named entity (a reference plane or
+ axis) is selected via ``IComponent2::FeatureByName`` +
+ ``IFeature::Select2``. A face is given as a point on it in the
+ component's own part coordinates: the face is found among *that
+ component's* faces only (``IComponent2::GetBodies2``, which report
+ part coordinates; measured on SW 2026) and selected with
+ ``IEntity::Select4``. Searching one component means a pin and the
+ hole it sits in, whose surfaces share points, can't be confused.
Args:
component_a (str): First component instance name, as reported by
@@ -2639,6 +2754,10 @@ async def add_mate(
alignment (str): ``aligned``, ``anti_aligned`` or ``closest``.
distance (float): Distance in millimetres, for a distance mate.
angle (float): Angle in degrees, for an angle mate.
+ face_a (list[float] | None): ``[x, y, z]`` mm on a face of the
+ first component, in its part coordinates; used instead of
+ ``entity_a``.
+ face_b (list[float] | None): The same for the second component.
Returns:
AdapterResult[dict[str, Any]]: The mate created, plus the bounding
@@ -2685,42 +2804,20 @@ def _mate() -> dict[str, Any]:
model = adapter.currentModel
assembly = _sw_type_info.flagged(model, "IAssemblyDoc")
- components = adapter._attempt(
- lambda: assembly.GetComponents(True), default=None
- )
- if not isinstance(components, (list, tuple)):
- raise Exception("Could not read the assembly's components")
-
wanted = {component_a: entity_a, component_b: entity_b}
- found: dict[str, Any] = {}
- for component in components:
- wrapped = _as_com(adapter, component, "IComponent2")
- if wrapped is None:
- continue
- name = adapter._attempt(lambda w=wrapped: w.Name2, default=None)
- if name and str(name) in wanted:
- found[str(name)] = wrapped
-
- missing = [n for n in (component_a, component_b) if n not in found]
- if missing:
- available = [
- str(
- adapter._attempt(
- lambda c=c: _as_com(adapter, c, "IComponent2").Name2,
- default="?",
- )
- )
- for c in components
- ]
- raise Exception(
- f"Component(s) not found: {', '.join(missing)}. "
- f"The assembly holds: {', '.join(available)}."
- )
+ faces = {component_a: face_a, component_b: face_b}
+ found = _find_components(adapter, assembly, [component_a, component_b])
adapter._attempt(lambda: model.ClearSelection2(True), default=None)
for index, component_name in enumerate((component_a, component_b)):
wrapped = found[component_name]
entity_name = wanted[component_name]
+ point = faces[component_name]
+ if point is not None:
+ _select_component_face(
+ adapter, model, wrapped, component_name, point, index > 0
+ )
+ continue
feature = adapter._attempt(
lambda w=wrapped, e=entity_name: w.FeatureByName(e), default=None
)
@@ -2794,7 +2891,10 @@ def _mate() -> dict[str, Any]:
"mate_type": mate_key,
"alignment": align_key,
"components": [component_a, component_b],
- "entities": [entity_a, entity_b],
+ "entities": [
+ mate_entity_label(entity_a, face_a),
+ mate_entity_label(entity_b, face_b),
+ ],
"distance": distance or None,
"angle": angle or None,
"moved_components": moved,
@@ -2806,6 +2906,98 @@ def _mate() -> dict[str, Any]:
adapter._handle_com_operation("add_mate", _mate),
)
+ async def set_component_state(
+ self,
+ component: str,
+ fixed: bool | None = None,
+ flexible: bool | None = None,
+ ) -> AdapterResult[dict[str, Any]]:
+ """Fix or float a component, and make a sub-assembly rigid or flexible.
+
+ Fixing selects the component and calls ``IAssemblyDoc::FixComponent``
+ or ``UnfixComponent``. Solving goes through
+ ``IAssemblyDoc::CompConfigProperties5``, which also sets suppression,
+ visibility, configuration, BOM and envelope for the selected
+ component: each is read from the component first and passed back, so
+ only solving changes.
+
+ Args:
+ component (str): Instance name, as ``list_components`` reports it.
+ fixed (bool | None): True fixes it, False floats it.
+ flexible (bool | None): True flexible, False rigid (sub-assemblies).
+
+ Returns:
+ AdapterResult[dict[str, Any]]: The state before and after.
+ """
+ adapter = self._adapter(self)
+ if _doc_type(adapter) != 2:
+ return AdapterResult(
+ status=AdapterResultStatus.ERROR,
+ error="set_component_state requires an assembly document",
+ )
+ if fixed is None and flexible is None:
+ return AdapterResult(
+ status=AdapterResultStatus.ERROR,
+ error="Nothing to change: give fixed, flexible or both.",
+ )
+
+ def _state() -> dict[str, Any]:
+ model = adapter.currentModel
+ assembly = _sw_type_info.flagged(model, "IAssemblyDoc")
+ target = _find_components(adapter, assembly, [component])[component]
+ before = _component_state(adapter, target)
+ sel_mgr = _sw_type_info.flagged(model.SelectionManager, "ISelectionMgr")
+
+ def select() -> None:
+ adapter._attempt(lambda: model.ClearSelection2(True), default=None)
+ data = adapter._attempt(
+ lambda: sel_mgr.CreateSelectData(), default=None
+ )
+ if not adapter._attempt(
+ lambda: target.Select4(False, data, False), default=False
+ ):
+ raise Exception(f"Failed to select {component}")
+
+ if fixed is not None:
+ select()
+ call = assembly.FixComponent if fixed else assembly.UnfixComponent
+ adapter._attempt(call, default=None)
+ if flexible is not None:
+ select()
+ config = before["referenced_configuration"] or ""
+ applied = adapter._attempt(
+ lambda: assembly.CompConfigProperties5(
+ before["suppression"],
+ 1 if flexible else 0, # swComponentSolvingOption_e
+ bool(before["visible"]),
+ bool(config),
+ config,
+ bool(before["exclude_from_bom"]),
+ bool(before["envelope"]),
+ ),
+ default=False,
+ )
+ if not applied:
+ raise Exception(
+ f"SolidWorks refused to make {component} "
+ f"{'flexible' if flexible else 'rigid'}; only a "
+ "sub-assembly can be flexible."
+ )
+ adapter._attempt(lambda: model.ClearSelection2(True), default=None)
+ adapter._attempt(lambda: model.EditRebuild3(), default=None)
+ after = _component_state(adapter, target)
+ if fixed is not None and after["fixed"] is not fixed:
+ raise Exception(
+ f"{component} is still {'floating' if fixed else 'fixed'} "
+ "after the change."
+ )
+ return {"component": component, "before": before, "after": after}
+
+ return cast(
+ AdapterResult[dict[str, Any]],
+ adapter._handle_com_operation("set_component_state", _state),
+ )
+
async def list_components(self) -> AdapterResult[list[str]]:
"""List the top-level components of the active assembly.
diff --git a/src/solidworks_mcp/server.py b/src/solidworks_mcp/server.py
index 6e5e7e47..94faa70e 100644
--- a/src/solidworks_mcp/server.py
+++ b/src/solidworks_mcp/server.py
@@ -1,6 +1,6 @@
"""Main SolidWorks MCP Server implementation using FastMCP and PydanticAI.
-This server provides 155 tools for comprehensive SolidWorks automation with configurable
+This server provides 156 tools for comprehensive SolidWorks automation with configurable
deployment (local/remote) and security options.
"""
diff --git a/src/solidworks_mcp/skills/solidworks-mcp/references/errors-and-limits.md b/src/solidworks_mcp/skills/solidworks-mcp/references/errors-and-limits.md
index a1c37d8b..0c4bbd54 100644
--- a/src/solidworks_mcp/skills/solidworks-mcp/references/errors-and-limits.md
+++ b/src/solidworks_mcp/skills/solidworks-mcp/references/errors-and-limits.md
@@ -51,8 +51,8 @@ mostly batch, template, macro-recording and drawing-analysis tools, plus
| `create_technical_drawing` ignores its template, sheet size, title and output path. | `generate_drawing_package` for a complete drawing. |
| `delete_feature`'s message may not mention the dependents it also removed. | `list_features` afterwards. |
| `save_as` replaces an existing file whatever `overwrite` says. | `save_part` or `save_assembly`, which refuse an existing file unless `overwrite` is on. |
-| The `export_*` tools take the format from the file extension, not `format_type`. | Name the file with the right extension. |
+| The `export_*` file tools take the format from the file extension; `format_type` is optional and `options` is ignored. | Name the file with the right extension. |
| `calculate_mass_properties` labels moments of inertia kg·mm², but they are kg·m². | Read them as kg·m². |
| `check_interference` ignores `include_hidden`. | Show the components you want checked. |
| `execute_macro` runs a macro once, whatever `repeat_count` says, and runs only binary `.swp` files. | Call it again for a repeat. |
-| `add_mate` can mate only reference planes and axes, by name. | Model parts so their planes line up with the mates you need. |
+| `add_mate` can't mate edges or vertices, only planes, axes and faces. | Mate the faces that meet at the edge. |
diff --git a/src/solidworks_mcp/skills/solidworks-mcp/references/workflows.md b/src/solidworks_mcp/skills/solidworks-mcp/references/workflows.md
index 01833070..85e9e069 100644
--- a/src/solidworks_mcp/skills/solidworks-mcp/references/workflows.md
+++ b/src/solidworks_mcp/skills/solidworks-mcp/references/workflows.md
@@ -29,8 +29,20 @@ step. Values are examples; sizes are millimetres.
## Sketching somewhere other than the three planes
`create_reference_plane` with `reference: "Top Plane"` and `offset: 10` makes
-a plane 10 mm up; its name (e.g. "Plane1") goes into `create_sketch`. Angled
-planes aren't supported yet.
+a plane 10 mm up; its name (e.g. "Plane1") goes into `create_sketch`.
+
+- At an angle: `create_axis` first (its reply names the axis, e.g. "Axis1"),
+ then `reference`, `angle` (degrees) and `axis`.
+- On or off a face, sloped or not: `face_point`, a point on the face in mm,
+ with `offset` (0 puts the plane on the face).
+
+## A bent plate or other wall from an open outline
+
+Draw the outline as an open chain of `add_line` (the plate's centreline or
+one edge), `exit_sketch`, then `create_extrusion` with `thin_feature: true`,
+`thin_thickness` and `thin_side` (`"one_side"`, `"other_side"` or `"mid_plane"`).
+`both_directions: true` extrudes half the depth each side of the sketch
+plane; the reply's `end_condition` says `MidPlane`.
## A revolved part (a knob or a shaft)
@@ -68,8 +80,14 @@ To turn a feature off without deleting it, `suppress_feature`. After
2. `create_assembly`, then `insert_component` for each part, with a
position in mm.
3. `list_components` for the instance names (e.g. "plate-1").
-4. `add_mate` between components, by reference plane or axis name
- ("Front Plane", "Axis1"). Faces and edges can't be mated by these tools.
+4. `add_mate` between components. Each side is a reference plane or axis
+ name (`entity_a`: "Front Plane", "Axis1") or a face (`face_a`: a point on
+ it, in mm, in that part's own coordinates, as you modelled it). A pin in a
+ hole: `concentric`, a point on the pin's round face and one on the hole's
+ wall.
+ The first part inserted is fixed. To let mates move it, or to hold a part
+ still, use `set_component_state` (`fixed`); to let a sub-assembly's own
+ mates move in the main assembly (a crank that turns), `flexible: true`.
5. `check_interference`, then `verify_model` with `component_count`,
`mate_count` and `no_interference: true`.
6. `save_assembly`, or `pack_and_go_assembly` for a self-contained folder.
diff --git a/src/solidworks_mcp/tools/analysis.py b/src/solidworks_mcp/tools/analysis.py
index 7c56060f..94b85c42 100644
--- a/src/solidworks_mcp/tools/analysis.py
+++ b/src/solidworks_mcp/tools/analysis.py
@@ -393,7 +393,7 @@ async def calculate_mass_properties(
@mcp.tool()
async def get_mass_properties(
- input_data: MassPropertiesInput | dict[str, Any] | None = None,
+ input_data: MassPropertiesInput | None = None,
) -> dict[str, Any]:
"""Measure the active part or assembly's mass properties: volume, surface area, mass, centre of mass and moments of inertia.
@@ -410,13 +410,7 @@ async def get_mass_properties(
Returns:
dict[str, Any]: The mass properties.
"""
- if input_data is None:
- normalized_input = MassPropertiesInput()
- elif isinstance(input_data, MassPropertiesInput):
- normalized_input = input_data
- else:
- normalized_input = MassPropertiesInput.model_validate(input_data)
- return await calculate_mass_properties(normalized_input)
+ return await calculate_mass_properties(input_data or MassPropertiesInput())
@mcp.tool()
async def check_interference(input_data: InterferenceCheckInput) -> dict[str, Any]:
diff --git a/src/solidworks_mcp/tools/drawing.py b/src/solidworks_mcp/tools/drawing.py
index dde03e1b..955f2f88 100644
--- a/src/solidworks_mcp/tools/drawing.py
+++ b/src/solidworks_mcp/tools/drawing.py
@@ -439,39 +439,8 @@ async def add_dimension(input_data: AddDimensionInput) -> dict[str, Any]:
Returns:
dict[str, Any]: The dimension, or a `not_supported` error.
"""
- if hasattr(input_data, "model_dump"):
- payload = input_data.model_dump()
- else:
- payload = dict(input_data)
-
- dimension_type = payload.get("dimension_type")
- entity1 = payload.get("entity1")
- entity2 = payload.get("entity2")
- position_x = payload.get("position_x")
- position_y = payload.get("position_y")
-
- if (entity1 is None or entity2 is None) and payload.get("entities"):
- entities = payload["entities"]
- if entity1 is None and len(entities) >= 1:
- entity1 = entities[0]
- if entity2 is None and len(entities) >= 2:
- entity2 = entities[1]
-
- if (position_x is None or position_y is None) and payload.get("position"):
- position = payload["position"]
- if len(position) >= 2:
- if position_x is None:
- position_x = position[0]
- if position_y is None:
- position_y = position[1]
-
- # Feed the alias-normalized values back into the payload so the
- # adapter call sees entity1/entity2/position_x/position_y even
- # when the caller only supplied entities/position.
- payload["entity1"] = entity1
- payload["entity2"] = entity2
- payload["position_x"] = position_x
- payload["position_y"] = position_y
+ payload = input_data.model_dump()
+ dimension_type = payload["dimension_type"]
require_capability(
adapter,
diff --git a/src/solidworks_mcp/tools/export.py b/src/solidworks_mcp/tools/export.py
index 56b3b5e9..722fdaed 100644
--- a/src/solidworks_mcp/tools/export.py
+++ b/src/solidworks_mcp/tools/export.py
@@ -35,8 +35,12 @@ class ExportFileInput(CompatInput):
"""
file_path: str = Field(description="Full path for the exported file")
- format_type: str = Field(
- description="Export format (step, iges, stl, pdf, dwg, jpg, png)"
+ format_type: str | None = Field(
+ default=None,
+ description=(
+ "Not needed: the file extension sets the format; defaults to the "
+ "tool's own format"
+ ),
)
options: dict[str, Any] | None = Field(
default=None, description="Format-specific export options"
@@ -350,18 +354,15 @@ def model_post_init(self, __context: Any) -> None:
def _file_input(input_data: Any, format_type: str) -> Any:
- """Validate a plain-dict argument as ``ExportFileInput``.
+ """Fill ``format_type`` with the tool's own format when it was left out.
- Over MCP the arguments arrive already validated; a script calling the
- tool function directly passes a dict, which has no ``file_path``
- attribute. The tool's own format fills ``format_type`` when the dict
- leaves it out. Models, including the per-format aliases, pass through.
+ The file extension decides the format; the adapter is still told which
+ format the tool exports. The per-format models (``ExportSTEPInput`` and
+ the like) pass through unchanged.
"""
- if isinstance(input_data, dict):
- return normalize_input(
- {"format_type": format_type, **input_data}, ExportFileInput
- )
- return input_data
+ if not isinstance(input_data, ExportFileInput) or input_data.format_type:
+ return input_data
+ return input_data.model_copy(update={"format_type": format_type})
async def register_export_tools(
@@ -404,7 +405,7 @@ async def export_step(input_data: ExportFileInput) -> dict[str, Any]:
Args:
input_data: The export. `file_path` (ending in .step or .stp) is required;
- `format_type` is required by the schema but the extension decides the
+ `format_type` is not needed: the file extension decides the
format, and `options` is not used yet.
Returns:
@@ -449,7 +450,7 @@ async def export_iges(input_data: ExportFileInput) -> dict[str, Any]:
Args:
input_data: The export. `file_path` (ending in .igs or .iges) is required;
- `format_type` is required by the schema but the extension decides the
+ `format_type` is not needed: the file extension decides the
format, and `options` is not used yet.
Returns:
@@ -493,7 +494,7 @@ async def export_stl(input_data: ExportFileInput) -> dict[str, Any]:
Args:
input_data: The export. `file_path` (ending in .stl) is required;
- `format_type` is required by the schema but the extension decides the
+ `format_type` is not needed: the file extension decides the
format, and `options` is not used yet.
Returns:
@@ -538,7 +539,7 @@ async def export_pdf(input_data: ExportFileInput) -> dict[str, Any]:
Args:
input_data: The export. `file_path` (ending in .pdf) is required;
- `format_type` is required by the schema but the extension decides the
+ `format_type` is not needed: the file extension decides the
format, and `options` is not used yet.
Returns:
@@ -582,7 +583,7 @@ async def export_dwg(input_data: ExportFileInput) -> dict[str, Any]:
Args:
input_data: The export. `file_path` (ending in .dwg) is required;
- `format_type` is required by the schema but the extension decides the
+ `format_type` is not needed: the file extension decides the
format, and `options` is not used yet.
Returns:
diff --git a/src/solidworks_mcp/tools/macro_recording.py b/src/solidworks_mcp/tools/macro_recording.py
index 7380c95d..0cd74895 100644
--- a/src/solidworks_mcp/tools/macro_recording.py
+++ b/src/solidworks_mcp/tools/macro_recording.py
@@ -364,11 +364,7 @@ async def batch_execute_macros(input_data: MacroBatchInput) -> dict[str, Any]:
Returns:
dict[str, Any]: The results, or a `not_supported` error.
"""
- payload = (
- input_data.model_dump()
- if hasattr(input_data, "model_dump")
- else dict(input_data)
- )
+ payload = input_data.model_dump()
if hasattr(adapter, "batch_execute_macros"):
result = await adapter.batch_execute_macros(payload)
@@ -402,11 +398,7 @@ async def optimize_macro(input_data: dict[str, Any]) -> dict[str, Any]:
Returns:
dict[str, Any]: The suggestions, or a `not_supported` error.
"""
- payload = (
- input_data.model_dump()
- if hasattr(input_data, "model_dump")
- else dict(input_data)
- )
+ payload = dict(input_data)
if hasattr(adapter, "optimize_macro"):
result = await adapter.optimize_macro(payload)
@@ -440,11 +432,7 @@ async def create_macro_library(input_data: dict[str, Any]) -> dict[str, Any]:
Returns:
dict[str, Any]: The library, or a `not_supported` error.
"""
- payload = (
- input_data.model_dump()
- if hasattr(input_data, "model_dump")
- else dict(input_data)
- )
+ payload = dict(input_data)
if hasattr(adapter, "create_macro_library"):
result = await adapter.create_macro_library(payload)
diff --git a/src/solidworks_mcp/tools/modeling.py b/src/solidworks_mcp/tools/modeling.py
index e9f736ee..2b738bce 100644
--- a/src/solidworks_mcp/tools/modeling.py
+++ b/src/solidworks_mcp/tools/modeling.py
@@ -4,7 +4,7 @@
assemblies, drawings, and features like extrusions, revolves, etc.
"""
-from typing import Any
+from typing import Any, Literal
from fastmcp import FastMCP
from pydantic import BaseModel, Field
@@ -195,6 +195,7 @@ class CreateExtrusionInput(CompatInput):
sketch_name (str): The sketch name value.
thin_feature (bool): The thin feature value.
thin_thickness (float | None): The thin thickness value.
+ thin_side (str): The side a thin wall grows on.
"""
sketch_name: str = Field(description="Sketch name to extrude")
@@ -206,11 +207,28 @@ class CreateExtrusionInput(CompatInput):
default=False, description="Reverse extrusion direction"
)
both_directions: bool = Field(
- default=False, description="Extrude in both directions"
+ default=False,
+ description=(
+ "Extrude symmetrically about the sketch plane (mid-plane): `depth` is "
+ "the total thickness, half on each side"
+ ),
+ )
+ thin_feature: bool = Field(
+ default=False,
+ description=(
+ "Make a thin wall of `thin_thickness` from the sketch's outline; the "
+ "sketch may be an open chain (a bent plate's centreline)"
+ ),
)
- thin_feature: bool = Field(default=False, description="Create as thin wall feature")
thin_thickness: float | None = Field(
- default=None, description="Thickness for thin wall feature in mm"
+ default=None, description="Wall thickness in mm; required with thin_feature"
+ )
+ thin_side: Literal["one_side", "other_side", "mid_plane"] = Field(
+ default="one_side",
+ description=(
+ "Which side of the sketch line the thin wall grows on: one_side, "
+ "other_side, or mid_plane (half each side)"
+ ),
)
end_condition: str = Field(default="Blind", description="End condition type")
merge_result: bool = Field(default=True, description="Merge with existing geometry")
@@ -233,6 +251,8 @@ def model_post_init(self, __context: Any) -> None:
raise ValueError("sketch_name is required")
if self.reverse is not None:
self.reverse_direction = self.reverse
+ if self.thin_feature and not (self.thin_thickness and self.thin_thickness > 0):
+ raise ValueError("thin_feature needs a positive thin_thickness (mm)")
class CreateRevolveInput(CompatInput):
@@ -502,14 +522,32 @@ def model_post_init(self, __context: Any) -> None:
class AddChamferInput(CompatInput):
- """Input schema for adding an equal-distance chamfer feature.
+ """Input schema for adding a chamfer feature.
Attributes:
distance (float): Chamfer distance in millimeters.
edge_names (list[str]): Edge names to chamfer.
+ angle (float): Chamfer angle in degrees.
+ flip (bool): Measure the distance along the other face.
"""
- distance: float = Field(description="Chamfer distance in millimeters")
+ distance: float = Field(
+ description="Chamfer distance in millimeters, along one of the edge's faces"
+ )
+ angle: float = Field(
+ default=45.0,
+ description=(
+ "Chamfer angle in degrees, between 0 and 90; 45 makes both legs "
+ "equal to distance"
+ ),
+ )
+ flip: bool = Field(
+ default=False,
+ description=(
+ "Measure distance along the edge's other face (swaps the long and "
+ "short legs of an angled chamfer)"
+ ),
+ )
edge_names: list[str] = Field(
default_factory=list,
description=(
@@ -533,6 +571,8 @@ class AddChamferInput(CompatInput):
def model_post_init(self, __context: Any) -> None:
if self.distance <= 0:
raise ValueError("distance must be positive")
+ if not 0 < self.angle < 90:
+ raise ValueError("angle must be between 0 and 90 degrees")
class CreateAssemblyInput(CompatInput):
@@ -727,15 +767,34 @@ class AddMateInput(CompatInput):
description="First component instance name, as reported by list_components"
)
component_b: str = Field(description="Second component instance name")
- entity_a: str = Field(
- default="Front Plane",
+ entity_a: str | None = Field(
+ default=None,
+ description=(
+ "Named reference plane or axis on the first component, e.g. 'Front "
+ "Plane'; defaults to 'Front Plane' when face_a is not given either"
+ ),
+ )
+ entity_b: str | None = Field(
+ default=None,
+ description=(
+ "Named reference plane or axis on the second component; defaults "
+ "to 'Front Plane' when face_b is not given either"
+ ),
+ )
+ face_a: list[float] | None = Field(
+ default=None,
description=(
- "Named feature on the first component. Only tree features "
- "(reference planes and axes) can be selected"
+ "[x, y, z] in mm on a face of the first component, in that part's "
+ "own coordinates (as it was modelled), instead of entity_a: e.g. a "
+ "point on a pin's round face for a concentric mate"
),
)
- entity_b: str = Field(
- default="Front Plane", description="Named feature on the second component"
+ face_b: list[float] | None = Field(
+ default=None,
+ description=(
+ "[x, y, z] in mm on a face of the second component, in its own "
+ "part coordinates, instead of entity_b"
+ ),
)
mate_type: str = Field(
default="coincident",
@@ -755,38 +814,107 @@ class AddMateInput(CompatInput):
def model_post_init(self, __context: Any) -> None:
if not self.component_a.strip() or not self.component_b.strip():
raise ValueError("component_a and component_b are required")
+ for side in ("a", "b"):
+ entity, face = (
+ getattr(self, f"entity_{side}"),
+ getattr(self, f"face_{side}"),
+ )
+ if entity and face is not None:
+ raise ValueError(f"give entity_{side} or face_{side}, not both")
+ if face is not None and len(face) != 3:
+ raise ValueError(f"face_{side} must be [x, y, z] in millimetres")
+
+
+class SetComponentStateInput(CompatInput):
+ """Input schema for fixing, floating and flexing a component.
+
+ Attributes:
+ component (str): Component instance name.
+ fixed (bool | None): True fixes it, False floats it.
+ flexible (bool | None): True flexible, False rigid.
+ """
+
+ component: str = Field(
+ description="Component instance name, as reported by list_components"
+ )
+ fixed: bool | None = Field(
+ default=None,
+ description=(
+ "true fixes the component in place; false floats it so mates can "
+ "move it; leave it out to keep it as it is"
+ ),
+ )
+ flexible: bool | None = Field(
+ default=None,
+ description=(
+ "For a sub-assembly: true lets its own mates move inside this "
+ "assembly (a crank that turns); false makes it rigid; leave it out "
+ "to keep it as it is"
+ ),
+ )
+
+ def model_post_init(self, __context: Any) -> None:
+ if not self.component.strip():
+ raise ValueError("component is required")
+ if self.fixed is None and self.flexible is None:
+ raise ValueError("give fixed, flexible or both")
class CreateReferencePlaneInput(CompatInput):
"""Input schema for creating a reference plane.
Attributes:
- reference (str): Name of the reference plane or planar face.
+ reference (str): Name of the reference plane.
+ face_point (list[float] | None): A point on a flat face to use instead.
offset (float): Offset distance in millimetres.
angle (float): Angle in degrees.
+ axis (str | None): Axis to rotate about, for an angle.
flip (bool): Reverse the offset or angle direction.
"""
reference: str = Field(
- description="Name of the reference plane or planar face to offset "
- "from, e.g. 'Front Plane'"
+ default="",
+ description=(
+ "Name of the reference plane, e.g. 'Front Plane' or 'Plane1'; leave "
+ "it out when giving face_point"
+ ),
+ )
+ face_point: list[float] | None = Field(
+ default=None,
+ description=(
+ "[x, y, z] in mm on a flat face of the part (sloped or not), to use "
+ "that face as the reference instead of a named plane"
+ ),
)
offset: float = Field(
default=0.0,
- description="Offset distance in millimetres. Must be non-zero — a "
- "plane coincident with its reference is not useful",
+ description=(
+ "Offset distance in millimetres. Non-zero for a named plane; 0 with "
+ "face_point puts the plane on the face"
+ ),
)
angle: float = Field(
default=0.0,
- description="Angle in degrees. Not yet supported: an angled plane "
- "needs a second reference this tool cannot take, so a non-zero "
- "value errors. Use offset for parallel planes",
+ description="Angle in degrees between the new plane and the reference, about axis",
+ )
+ axis: str | None = Field(
+ default=None,
+ description=(
+ "Name of the axis the plane rotates about, e.g. 'Axis1' from "
+ "create_axis; required with angle"
+ ),
+ )
+ flip: bool = Field(
+ default=False, description="Reverse the offset side or the angle direction"
)
- flip: bool = Field(default=False, description="Reverse the offset direction")
def model_post_init(self, __context: Any) -> None:
- if not self.reference.strip():
- raise ValueError("reference is required")
+ if bool(self.reference.strip()) == (self.face_point is not None):
+ raise ValueError("give exactly one of reference or face_point")
+ if self.face_point is not None and len(self.face_point) != 3:
+ raise ValueError("face_point must be [x, y, z] in millimetres")
+ if self.angle and not self.axis:
+ raise ValueError("an angled plane needs axis, the axis to rotate about")
class CreateAxisInput(CompatInput):
@@ -1151,10 +1279,14 @@ async def create_extrusion(input_data: CreateExtrusionInput) -> dict[str, Any]:
Use it after `exit_sketch`; to remove material, use `create_cut_extrude`. It
extrudes the sketch `exit_sketch` closed, whatever `sketch_name` says. End
- conditions: Blind (default), MidPlane, ThroughAll, UpToNext. Needs: an open part
- and a closed profile just exited. Changes: adds a Boss-Extrude feature. Returns:
- `extrusion` (`name`, `depth`) and `measured_change` (volume, bounding box and
- solid-body count before and after; left out when the model can't be read).
+ conditions: Blind (default), MidPlane, ThroughAll, UpToNext; `both_directions`
+ means MidPlane. With `thin_feature` and `thin_thickness` it makes a wall from the
+ sketch's outline, and an open chain works (a bent plate from its centreline).
+ Needs: an open part and a profile just exited (closed, unless thin). Changes:
+ adds a Boss-Extrude feature. Returns: `extrusion` (`name`, `depth`, the
+ `end_condition` SolidWorks used, `both_directions`, and `thin` when thin) and
+ `measured_change` (volume, bounding box and solid-body count before and after;
+ left out when the model can't be read).
Args:
input_data: The extrusion. `sketch_name` and `depth` (mm) are required;
@@ -1174,6 +1306,7 @@ async def create_extrusion(input_data: CreateExtrusionInput) -> dict[str, Any]:
both_directions=input_data.both_directions,
thin_feature=input_data.thin_feature,
thin_thickness=input_data.thin_thickness,
+ thin_side=input_data.thin_side,
end_condition=input_data.end_condition,
merge_result=input_data.merge_result,
feature_scope=False,
@@ -1186,17 +1319,25 @@ async def create_extrusion(input_data: CreateExtrusionInput) -> dict[str, Any]:
require_success(result, context="Failed to create extrusion")
after = await _read_summary(adapter) if before is not None else None
feature = result.data
+ name = _result_value(feature, "feature_name", "name", default="Extrusion")
+ applied = _result_value(feature, "parameters", default=None) or {}
+ extrusion: dict[str, Any] = {
+ "name": name,
+ "sketch": input_data.sketch_name,
+ "depth": input_data.depth,
+ "end_condition": applied.get("end_condition")
+ or ("MidPlane" if input_data.both_directions else input_data.end_condition),
+ "both_directions": input_data.both_directions,
+ }
+ if input_data.thin_feature:
+ extrusion["thin"] = {
+ "thickness": input_data.thin_thickness,
+ "side": input_data.thin_side,
+ }
reply = {
"status": "success",
- "message": f"Created extrusion: {_result_value(feature, 'feature_name', 'name', default='Extrusion')}",
- "extrusion": {
- "name": _result_value(
- feature, "feature_name", "name", default="Extrusion"
- ),
- "sketch": input_data.sketch_name,
- "depth": input_data.depth,
- "direction": input_data.direction,
- },
+ "message": f"Created extrusion: {name}",
+ "extrusion": extrusion,
"execution_time": result.execution_time,
}
return _attach_measured_change(reply, before, after)
@@ -1459,12 +1600,14 @@ async def add_fillet(input_data: AddFilletInput) -> dict[str, Any]:
@mcp.tool()
async def add_chamfer(input_data: AddChamferInput) -> dict[str, Any]:
- """Bevel edges of the active part with an equal-distance chamfer at 45 degrees.
+ """Bevel edges of the active part with a chamfer: `distance` along one face at `angle` (45 by default).
Name the edges as "Edge<1>", a point "x,y,z" (metres) or "face:x,y,z" for every
edge of a face; or leave `edge_names` empty to use the current selection (e.g.
- from `select_face_edges`). Needs: an open part with a solid body. Changes: adds
- a Chamfer feature. Returns: `chamfer` with `name`, `distance` and `edges`.
+ from `select_face_edges`). At 45 degrees both legs equal `distance`; at another
+ angle, `flip` swaps which face `distance` is measured on. Needs: an open part
+ with a solid body. Changes: adds a Chamfer feature. Returns: `chamfer` with
+ `name`, `distance`, `angle`, `flip` and `edges`.
Args:
input_data: The chamfer. `distance` (mm) is required; `edge_names`
@@ -1474,7 +1617,12 @@ async def add_chamfer(input_data: AddChamferInput) -> dict[str, Any]:
dict[str, Any]: The created chamfer.
"""
input_data = normalize_input(input_data, AddChamferInput)
- result = await adapter.add_chamfer(input_data.distance, input_data.edge_names)
+ result = await adapter.add_chamfer(
+ input_data.distance,
+ input_data.edge_names,
+ input_data.angle,
+ input_data.flip,
+ )
require_success(result, context="Failed to add chamfer")
feature = result.data
return {
@@ -1485,6 +1633,8 @@ async def add_chamfer(input_data: AddChamferInput) -> dict[str, Any]:
feature, "feature_name", "name", default="Chamfer"
),
"distance": input_data.distance,
+ "angle": input_data.angle,
+ "flip": input_data.flip,
"edges": input_data.edge_names,
},
"execution_time": result.execution_time,
@@ -1602,16 +1752,18 @@ async def insert_component(input_data: InsertComponentInput) -> dict[str, Any]:
async def add_mate(input_data: AddMateInput) -> dict[str, Any]:
"""Mate two components of the active assembly: coincident, concentric, parallel, perpendicular, tangent, distance or angle.
- Only reference planes and axes can be mated (by name, e.g. "Front Plane"), not
- faces or edges. Get instance names from `list_components`. Needs: an open
- assembly with both components. Changes: adds a mate and may move components.
- Returns: `mate` with `mate_type`, `components`, `entities`, `geometry_moved` and
- `moved_components`.
+ Each side is a named reference plane or axis (`entity_a`, e.g. "Front Plane"),
+ or a face given by a point on it (`face_a`, mm, in that part's own coordinates):
+ a pin's round face and a hole's face make a concentric mate. Get instance names
+ from `list_components`. Needs: an open assembly with both components. Changes:
+ adds a mate and may move components. Returns: `mate` with `mate_type`,
+ `components`, `entities`, `geometry_moved` and `moved_components`.
Args:
input_data: The mate. `component_a` and `component_b` are required;
- `entity_a`/`entity_b` ("Front Plane"), `mate_type` (coincident),
- `alignment`, `distance` (mm) and `angle` (degrees) have defaults.
+ each side takes `entity_*` or `face_*` (default "Front Plane");
+ `mate_type` (coincident), `alignment`, `distance` (mm) and `angle`
+ (degrees) have defaults.
Returns:
dict[str, Any]: The mate added.
@@ -1620,12 +1772,14 @@ async def add_mate(input_data: AddMateInput) -> dict[str, Any]:
result = await adapter.add_mate(
input_data.component_a,
input_data.component_b,
- input_data.entity_a,
- input_data.entity_b,
+ input_data.entity_a or "Front Plane",
+ input_data.entity_b or "Front Plane",
input_data.mate_type,
input_data.alignment,
input_data.distance,
input_data.angle,
+ input_data.face_a,
+ input_data.face_b,
)
require_success(result, context="Failed to add mate")
data = result.data if isinstance(result.data, dict) else {}
@@ -1645,6 +1799,44 @@ async def add_mate(input_data: AddMateInput) -> dict[str, Any]:
"execution_time": result.execution_time,
}
+ @mcp.tool()
+ async def set_component_state(input_data: SetComponentStateInput) -> dict[str, Any]:
+ """Fix or float a component of the active assembly, or make a sub-assembly rigid or flexible.
+
+ Float a part so mates can position it (the first part inserted is fixed); fix
+ one to hold it still. Make a sub-assembly flexible so its own mates move in
+ this assembly (a crank whose knob turns). Only what you pass changes: the
+ component's suppression, visibility and configuration are kept. Needs: an
+ open assembly. Changes: the component's fixed or solving state. Returns:
+ `state` with `component`, `before` and `after` (`fixed`, `flexible` and the
+ properties kept).
+
+ Args:
+ input_data: The component and what to change: `fixed`, `flexible` or
+ both.
+
+ Returns:
+ dict[str, Any]: The state before and after.
+ """
+ input_data = normalize_input(input_data, SetComponentStateInput)
+ result = await adapter.set_component_state(
+ input_data.component, input_data.fixed, input_data.flexible
+ )
+ require_success(result, context="Failed to set the component state")
+ data = result.data if isinstance(result.data, dict) else {}
+ after = data.get("after", {})
+ words = []
+ if input_data.fixed is not None:
+ words.append("fixed" if after.get("fixed") else "floating")
+ if input_data.flexible is not None:
+ words.append("flexible" if after.get("flexible") else "rigid")
+ return {
+ "status": "success",
+ "message": f"{input_data.component} is now {' and '.join(words)}",
+ "state": data,
+ "execution_time": result.execution_time,
+ }
+
@mcp.tool()
async def list_components() -> dict[str, Any]:
"""List the top-level components of the active assembly by instance name, e.g. "bracket-1".
@@ -1908,17 +2100,19 @@ async def undo(input_data: UndoInput | None = None) -> dict[str, Any]:
async def create_reference_plane(
input_data: CreateReferencePlaneInput,
) -> dict[str, Any]:
- """Add a reference plane parallel to a plane or flat face, offset by a distance in mm.
+ """Add a reference plane offset from, at an angle to, or on a plane or flat face.
Use it to sketch somewhere other than the three default planes: pass the new
- plane's name to `create_sketch`. Angled planes are not supported (a non-zero
- `angle` is an error), and a zero offset is refused. Needs: an open part.
- Changes: adds a plane feature. Returns: `plane` with `name`, `reference`,
- `offset` and `flip`.
+ plane's name to `create_sketch`. Offset: `reference` and a non-zero `offset`
+ (mm). Angle: `reference`, `angle` (degrees) and `axis`, an axis lying in the
+ reference (make one with `create_axis`). Face: `face_point`, a point on a flat
+ face (sloped or not), with an `offset` or 0 for a plane on the face. Needs: an
+ open part. Changes: adds a plane feature. Returns: `plane` with `name` and the
+ inputs used.
Args:
- input_data: The plane. `reference` (e.g. "Front Plane") and a non-zero
- `offset` (mm) are needed; `flip` is off.
+ input_data: The plane. Exactly one of `reference` and `face_point`;
+ then `offset`, or `angle` with `axis`; `flip` is off.
Returns:
dict[str, Any]: The new plane.
@@ -1929,6 +2123,8 @@ async def create_reference_plane(
input_data.offset,
input_data.angle,
input_data.flip,
+ input_data.axis,
+ input_data.face_point,
)
require_success(result, context="Failed to create reference plane")
data = result.data if isinstance(result.data, dict) else {}
@@ -1945,7 +2141,8 @@ async def create_axis(input_data: CreateAxisInput) -> dict[str, Any]:
Use it before `pattern_circular`, which rotates features about an axis. It is
built where two default planes meet. Needs: an open part. Changes: adds an axis
- feature. Returns: `axis` with `reference`, `planes` and the feature counts.
+ feature. Returns: `axis` with `name` (e.g. "Axis1", for
+ `create_reference_plane`'s `axis`), `reference`, `planes` and the feature counts.
Args:
input_data: The axis. `reference` ("x", "y" or "z") is required.
diff --git a/src/solidworks_mcp/tools/support_levels.py b/src/solidworks_mcp/tools/support_levels.py
index c0a18093..1958585a 100644
--- a/src/solidworks_mcp/tools/support_levels.py
+++ b/src/solidworks_mcp/tools/support_levels.py
@@ -82,9 +82,7 @@ def _beta(reason: str) -> Support:
return Support(SupportLevel.BETA, reason)
-_EXPORT_FORMAT = _beta(
- "the file extension sets the format; `format_type` and `options` are ignored."
-)
+_EXPORT_FORMAT = _beta("`options` is ignored; the file extension sets the format.")
SUPPORT_LEVELS: dict[str, Support] = {
# analysis
@@ -199,7 +197,7 @@ def _beta(reason: str) -> Support:
# modeling
"add_chamfer": _STABLE,
"add_fillet": _STABLE,
- "add_mate": _beta("mates only reference planes and axes, by name, not faces."),
+ "add_mate": _STABLE,
"close_model": _STABLE,
"create_assembly": _beta("`components` is ignored; use `insert_component`."),
"create_axis": _STABLE,
@@ -230,6 +228,7 @@ def _beta(reason: str) -> Support:
"rename_feature": _STABLE,
"select_face_edges": _STABLE,
"select_feature": _STABLE,
+ "set_component_state": _STABLE,
"set_dimension": _STABLE,
"set_units": _STABLE,
"suppress_feature": _STABLE,
diff --git a/src/solidworks_mcp/tools/tool_boundary.py b/src/solidworks_mcp/tools/tool_boundary.py
index 5cee766b..ddda5ad7 100644
--- a/src/solidworks_mcp/tools/tool_boundary.py
+++ b/src/solidworks_mcp/tools/tool_boundary.py
@@ -19,7 +19,10 @@
from __future__ import annotations
import functools
+import inspect
import time
+import types
+import typing
from collections.abc import Awaitable, Callable, Mapping
from typing import Any, cast
@@ -178,6 +181,36 @@ def error_payload(
return payload
+def model_parameters(fn: Callable[..., Any]) -> dict[str, type[BaseModel]]:
+ """The parameters of ``fn`` annotated with a pydantic model.
+
+ A parameter counts when its annotation is a model, or a model ``| None``.
+
+ Args:
+ fn: The tool function.
+
+ Returns:
+ dict[str, type[BaseModel]]: Parameter name to its model; empty when
+ the annotations can't be resolved (a model defined inside a function).
+ """
+ try:
+ hints = typing.get_type_hints(fn)
+ except NameError:
+ return {}
+ models: dict[str, type[BaseModel]] = {}
+ for name, hint in hints.items():
+ options = (
+ typing.get_args(hint)
+ if isinstance(hint, types.UnionType)
+ or typing.get_origin(hint) is typing.Union
+ else (hint,)
+ )
+ found = [o for o in options if isinstance(o, type) and issubclass(o, BaseModel)]
+ if name != "return" and len(found) == 1:
+ models[name] = found[0]
+ return models
+
+
def tool_boundary(
fn: Callable[..., Awaitable[dict[str, Any]]],
classifier: ErrorClassifier = _CLASSIFIER,
@@ -195,10 +228,25 @@ def tool_boundary(
experimental tool's level), after any warning of the tool's own;
errors carry a ``hint`` instead.
+ A dict passed where the tool expects its input model is validated as
+ that model first, so a script can call any tool function with a dict, as
+ an MCP client effectively does.
+
Returns:
The wrapped tool.
"""
tool_name = fn.__name__
+ models = model_parameters(fn)
+ signature = inspect.signature(fn)
+
+ def with_models(
+ args: tuple[Any, ...], kwargs: dict[str, Any]
+ ) -> inspect.BoundArguments:
+ bound = signature.bind_partial(*args, **kwargs)
+ for name, model in models.items():
+ if isinstance(bound.arguments.get(name), dict):
+ bound.arguments[name] = normalize_input(bound.arguments[name], model)
+ return bound
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
@@ -208,7 +256,8 @@ def elapsed() -> float:
return time.perf_counter() - started
try:
- result = await fn(*args, **kwargs)
+ bound = with_models(args, kwargs)
+ result = await fn(*bound.args, **bound.kwargs)
except InvalidInputError as exc:
return error_payload(
f"Invalid input: {exc}",
diff --git a/src/solidworks_mcp/tools/tool_hints.py b/src/solidworks_mcp/tools/tool_hints.py
index cb8fd334..5f1c379d 100644
--- a/src/solidworks_mcp/tools/tool_hints.py
+++ b/src/solidworks_mcp/tools/tool_hints.py
@@ -215,6 +215,7 @@ def _discards(title: str) -> ToolHints:
"create_loft": _adds("Create Loft"),
"insert_component": _adds("Insert Component"),
"add_mate": _adds("Add Mate"),
+ "set_component_state": _sets("Set Component State"),
"list_components": _reads("List Components"),
"delete_feature": _discards("Delete Feature"),
"suppress_feature": _sets("Suppress or Unsuppress Feature"),
diff --git a/tests/live/evidence.py b/tests/live/evidence.py
index 3ae9decd..c14c929c 100644
--- a/tests/live/evidence.py
+++ b/tests/live/evidence.py
@@ -184,6 +184,13 @@ def _solidworks_window_image() -> Any:
from PIL import Image
hwnd = solidworks_window()
+ if win32gui.IsIconic(hwnd):
+ # A minimized window has nothing to copy; without this the frames
+ # come out as captions over a blank strip.
+ raise RuntimeError(
+ "SolidWorks is minimized, so its window can't be captured; restore "
+ "it (it may stay behind other windows) and run again."
+ )
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
width, height = right - left, bottom - top
window_dc = win32gui.GetWindowDC(hwnd)
diff --git a/tests/live/test_live_sw_ujoint_tool_gaps.py b/tests/live/test_live_sw_ujoint_tool_gaps.py
new file mode 100644
index 00000000..443a5a48
--- /dev/null
+++ b/tests/live/test_live_sw_ujoint_tool_gaps.py
@@ -0,0 +1,572 @@
+"""The U-joint tool gaps (#209), each checked on real SolidWorks.
+
+One test per changed tool, all called as an agent would (through the MCP
+tool functions, with plain dicts):
+
+- a thin wall from an open L-shaped sketch (a bent plate), mid-plane;
+- a both-directions extrusion that is symmetric and says so;
+- a 60° chamfer, and ``flip`` swapping its legs;
+- a plane at 40° to the Top Plane about an axis;
+- a plane offset from a sloped face;
+- a pin mated concentric in a hole by two face points;
+- fixing, floating and flexing components;
+- interference before and after the mate, through dict calls.
+
+Run one with::
+
+ powershell -ExecutionPolicy Bypass -File tests\\scripts\\live_demo.ps1 `
+ -Test tests/live/test_live_sw_ujoint_tool_gaps.py::test_a_pin_mates_concentric_in_its_hole_by_two_faces
+
+Collected only with ``SOLIDWORKS_MCP_RUN_REAL_INTEGRATION=1`` on Windows (see
+``tests/live/conftest.py``).
+"""
+
+from __future__ import annotations
+
+import functools
+import json
+import math
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from tests.live.evidence import evidence, starting
+
+#: The bent plate: a 80 mm wall and a 40 mm foot, 6.35 mm thick, 38.1 mm wide.
+_WALL, _FOOT, _THICK, _WIDE = 80.0, 40.0, 6.35, 38.1
+
+#: Adapter steps the tests take directly; each is also an MCP tool of that name.
+_CAPTIONED_STEPS = frozenset(
+ {
+ "create_part",
+ "create_sketch",
+ "add_rectangle",
+ "add_circle",
+ "add_line",
+ "exit_sketch",
+ "save_file",
+ "create_assembly",
+ "insert_component",
+ }
+)
+
+
+def _shown(value: Any) -> str:
+ """One argument as a caption shows it: a file path by its name only."""
+ if isinstance(value, str) and ("\\" in value or "/" in value):
+ return json.dumps(Path(value).name)
+ return json.dumps(value, ensure_ascii=False)
+
+
+class _Captioned:
+ """An adapter whose test-level steps caption the recording as they start.
+
+ A recorded demo then shows every call under the motion it causes, not
+ only the final result. Everything else passes straight through.
+ """
+
+ def __init__(self, raw: Any) -> None:
+ self.raw = raw
+
+ def __getattr__(self, name: str) -> Any:
+ member = getattr(self.raw, name)
+ if name not in _CAPTIONED_STEPS:
+ return member
+
+ @functools.wraps(member)
+ async def step(*args: Any) -> Any:
+ starting(name, f"{name}({', '.join(_shown(a) for a in args)})")
+ return await member(*args)
+
+ return step
+
+
+@pytest.fixture(autouse=True)
+def _caption_steps(scratch: Any) -> Iterator[None]:
+ """Caption each direct adapter step; restore the plain adapter for cleanup."""
+ raw = scratch.adapter
+ scratch.adapter = _Captioned(raw)
+ yield
+ scratch.adapter = raw
+
+
+def _captioned_tool(name: str, fn: Any) -> Any:
+ """``fn``, captioning the recording with its call as it starts."""
+
+ @functools.wraps(fn)
+ async def call(arguments: dict[str, Any]) -> Any:
+ starting(name, f"{name}({json.dumps(arguments, ensure_ascii=False)})")
+ return await fn(arguments)
+
+ return call
+
+
+async def _tools(adapter: Any) -> dict[str, Any]:
+ """Register the modelling and analysis tools against ``adapter``; name -> fn.
+
+ Each tool captions the recording as it is called. The tools drive the
+ plain adapter, so a tool's own adapter call isn't captioned twice.
+ """
+ from fastmcp import FastMCP
+
+ from solidworks_mcp.tools.analysis import register_analysis_tools
+ from solidworks_mcp.tools.modeling import register_modeling_tools
+
+ raw = getattr(adapter, "raw", adapter)
+ mcp = FastMCP("ujoint-tool-gaps-live")
+ await register_modeling_tools(mcp, raw, {})
+ await register_analysis_tools(mcp, raw, {})
+ return {t.name: _captioned_tool(t.name, t.fn) for t in await mcp.list_tools()}
+
+
+async def _summary(adapter: Any) -> dict[str, Any]:
+ result = await adapter.get_model_geometry_summary()
+ assert result.is_success, result.error
+ return result.data
+
+
+async def _block(adapter: Any, tools: dict[str, Any]) -> None:
+ """A 40 x 20 x 10 mm block: x -20..20, y 0..10, z -10..10."""
+ assert (await adapter.create_part()).is_success
+ sketch = await adapter.create_sketch("Top")
+ assert (await adapter.add_rectangle(-20.0, -10.0, 20.0, 10.0)).is_success
+ assert (await adapter.exit_sketch()).is_success
+ boss = await tools["create_extrusion"]({"sketch_name": sketch.data, "depth": 10.0})
+ assert boss["status"] == "success", boss
+
+
+def _sloped_faces(adapter: Any) -> list[tuple[float, float, float]]:
+ """Normals of the active part's flat faces that aren't square to an axis."""
+ from solidworks_mcp.adapters.solidworks.face_selection import FaceEdgeSelector
+
+ return [
+ f.normal
+ for f in FaceEdgeSelector(adapter).faces()
+ if f.is_planar and sum(abs(c) > 1e-6 for c in f.normal) > 1
+ ]
+
+
+def _plane(adapter: Any, name: str) -> tuple[tuple[float, ...], tuple[float, ...]]:
+ """``(origin_mm, unit normal)`` of a reference plane."""
+ from solidworks_mcp.adapters.solidworks.face_selection import FaceEdgeSelector
+
+ origin, normal = FaceEdgeSelector(adapter).plane_geometry(name)
+ return tuple(c * 1000 for c in origin), normal
+
+
+async def _part(scratch: Any, tools: dict[str, Any], path: Path, build: Any) -> str:
+ """Build a part with ``build(tools)`` and save it to ``path``."""
+ assert (await scratch.adapter.create_part()).is_success
+ await build(tools)
+ saved = await scratch.adapter.save_file(str(path))
+ assert saved.is_success, saved.error
+ scratch.written_files.append(str(path))
+ return str(path)
+
+
+async def _plate_and_pin(
+ scratch: Any, tools: dict[str, Any], folder: Path, tag: str
+) -> tuple[str, str]:
+ """A 60 x 40 x 10 plate with a Ø10 hole at x = 20, and a Ø9.5 x 30 pin.
+
+ ``tag`` names the files, so two tests never open same-named parts.
+ """
+ adapter = scratch.adapter
+
+ async def plate(t: dict[str, Any]) -> None:
+ s = await adapter.create_sketch("Top")
+ assert (await adapter.add_rectangle(-30.0, -20.0, 30.0, 20.0)).is_success
+ assert (await adapter.exit_sketch()).is_success
+ assert (await t["create_extrusion"]({"sketch_name": s.data, "depth": 10.0}))[
+ "status"
+ ] == "success"
+ h = await adapter.create_sketch("Top")
+ assert (await adapter.add_circle(20.0, 0.0, 5.0)).is_success
+ assert (await adapter.exit_sketch()).is_success
+ cut = await t["create_cut_extrude"](
+ {"sketch_name": h.data, "depth": 10.0, "end_condition": "ThroughAllBoth"}
+ )
+ assert cut["status"] == "success", cut
+
+ async def pin(t: dict[str, Any]) -> None:
+ s = await adapter.create_sketch("Top")
+ assert (await adapter.add_circle(0.0, 0.0, 4.75)).is_success
+ assert (await adapter.exit_sketch()).is_success
+ assert (await t["create_extrusion"]({"sketch_name": s.data, "depth": 30.0}))[
+ "status"
+ ] == "success"
+
+ return (
+ await _part(scratch, tools, folder / f"{tag}_plate.SLDPRT", plate),
+ await _part(scratch, tools, folder / f"{tag}_pin.SLDPRT", pin),
+ )
+
+
+async def _plate_then_pin(adapter: Any) -> tuple[str, str]:
+ """The plate's and the pin's instance names, by name, not list order."""
+ names = (await adapter.list_components()).data
+ plate = next(n for n in names if "_plate" in n)
+ pin = next(n for n in names if "_pin" in n)
+ return plate, pin
+
+
+def _transforms(adapter: Any) -> dict[str, list[float]]:
+ """Each top-level component's ``Transform2.ArrayData`` (rotation, then m)."""
+ from solidworks_mcp.adapters import sw_type_info
+
+ assembly = sw_type_info.flagged(adapter.currentModel, "IAssemblyDoc")
+ out = {}
+ for component in assembly.GetComponents(True):
+ c = sw_type_info.flagged(component, "IComponent2")
+ out[str(c.Name2)] = list(c.Transform2.ArrayData)
+ return out
+
+
+def _axis_point_mm(data: list[float], y: float) -> tuple[float, float, float]:
+ """Where the part point (0, y, 0) mm lands in the assembly, in mm.
+
+ Only the rotation's y column matters for a point on the y axis.
+ """
+ return (
+ data[3] * y + data[9] * 1000,
+ data[4] * y + data[10] * 1000,
+ data[5] * y + data[11] * 1000,
+ )
+
+
+async def _seat_pin(
+ tools: dict[str, Any], plate: str, pin: str
+) -> list[dict[str, Any]]:
+ """Two face mates: the pin concentric in the hole, its bottom on the plate's."""
+ concentric = {
+ "component_a": plate,
+ "component_b": pin,
+ "face_a": [25.0, 5.0, 0.0], # the hole's wall, 5 mm from its axis at x = 20
+ "face_b": [4.75, 15.0, 0.0], # the pin's round face
+ "mate_type": "concentric",
+ }
+ flush = {
+ "component_a": plate,
+ "component_b": pin,
+ "face_a": [-20.0, 0.0, 0.0], # the plate's bottom, clear of the hole
+ "face_b": [0.0, 0.0, 0.0], # the pin's bottom
+ "mate_type": "coincident",
+ "alignment": "aligned",
+ }
+ return [await tools["add_mate"](concentric), await tools["add_mate"](flush)]
+
+
+@pytest.mark.asyncio
+async def test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate(scratch) -> None:
+ """An open L, 6.35 mm thin wall, mid-plane both ways: one body, exact volume."""
+ adapter = scratch.adapter
+ tools = await _tools(adapter)
+ assert (await adapter.create_part()).is_success
+ sketch = await adapter.create_sketch("Right")
+ assert (await adapter.add_line(0.0, 0.0, 0.0, _WALL)).is_success
+ assert (await adapter.add_line(0.0, 0.0, _FOOT, 0.0)).is_success
+ assert (await adapter.exit_sketch()).is_success
+
+ call = {
+ "sketch_name": sketch.data,
+ "depth": _WIDE,
+ "both_directions": True,
+ "thin_feature": True,
+ "thin_thickness": _THICK,
+ "thin_side": "mid_plane",
+ }
+ result = await tools["create_extrusion"](call)
+ summary = await _summary(adapter)
+ expected = (_WALL + _FOOT) * _THICK * _WIDE # mid-plane: corners cancel
+ evidence(
+ f"open L, thin 6.35 mm mid-plane, 38.1 mm both ways (expect {expected:.0f} mm³)",
+ {
+ "extrusion": result.get("extrusion"),
+ "volume_mm3": summary["volume_mm3"],
+ "solid_bodies": summary.get("solid_body_count"),
+ "x_mm": [
+ summary["bounding_box_mm"]["min"][0],
+ summary["bounding_box_mm"]["max"][0],
+ ],
+ },
+ call=f"create_extrusion({call})",
+ )
+ assert result["status"] == "success", result
+ assert result["extrusion"]["end_condition"] == "MidPlane"
+ assert result["extrusion"]["thin"] == {"thickness": _THICK, "side": "mid_plane"}
+ assert summary["solid_body_count"] == 1
+ assert summary["volume_mm3"] == pytest.approx(expected, rel=0.01)
+ assert summary["bounding_box_mm"]["min"][0] == pytest.approx(-_WIDE / 2, abs=0.01)
+ assert summary["bounding_box_mm"]["max"][0] == pytest.approx(_WIDE / 2, abs=0.01)
+
+
+@pytest.mark.asyncio
+async def test_both_directions_is_symmetric_and_says_mid_plane(scratch) -> None:
+ """A 20 mm both-ways extrusion spans -10..10 and reports MidPlane."""
+ adapter = scratch.adapter
+ tools = await _tools(adapter)
+ assert (await adapter.create_part()).is_success
+ sketch = await adapter.create_sketch("Front")
+ assert (await adapter.add_rectangle(-15.0, -10.0, 15.0, 10.0)).is_success
+ assert (await adapter.exit_sketch()).is_success
+
+ call = {"sketch_name": sketch.data, "depth": 20.0, "both_directions": True}
+ result = await tools["create_extrusion"](call)
+ box = (await _summary(adapter))["bounding_box_mm"]
+ evidence(
+ "both_directions: 20 mm about the Front Plane (expect z -10..10)",
+ {"extrusion": result.get("extrusion"), "z_mm": [box["min"][2], box["max"][2]]},
+ call=f"create_extrusion({call})",
+ )
+ assert result["extrusion"]["end_condition"] == "MidPlane"
+ assert box["min"][2] == pytest.approx(-10.0, abs=0.01)
+ assert box["max"][2] == pytest.approx(10.0, abs=0.01)
+
+
+@pytest.mark.asyncio
+async def test_a_60_degree_chamfer_and_flip_swaps_its_legs(scratch) -> None:
+ """2 mm at 60°: the right volume, and flip turns a 60° face into a 30° one."""
+ adapter = scratch.adapter
+ tools = await _tools(adapter)
+ await _block(adapter, tools)
+ before = (await _summary(adapter))["volume_mm3"]
+ removed = 0.5 * 2.0 * 2.0 * math.tan(math.radians(60)) * 40.0
+
+ first = {"distance": 2.0, "angle": 60, "edge_names": ["0,0.01,0.01"]}
+ chamfer = await tools["add_chamfer"](first)
+ after_first = (await _summary(adapter))["volume_mm3"]
+ slopes = _sloped_faces(adapter)
+ tilt_1 = math.degrees(math.acos(abs(slopes[0][1])))
+ evidence(
+ f"chamfer 2 mm at 60° along a 40 mm edge (expect -{removed:.1f} mm³)",
+ {
+ "chamfer": chamfer.get("chamfer"),
+ "volume_change_mm3": round(after_first - before, 2),
+ "face_tilt_from_top_deg": round(tilt_1, 2),
+ },
+ call=f"add_chamfer({first})",
+ )
+ assert chamfer["status"] == "success", chamfer
+ assert before - after_first == pytest.approx(removed, rel=0.01)
+
+ second = {
+ "distance": 2.0,
+ "angle": 60,
+ "flip": True,
+ "edge_names": ["0,0.01,-0.01"],
+ }
+ flipped = await tools["add_chamfer"](second)
+ after_second = (await _summary(adapter))["volume_mm3"]
+ tilts = sorted(
+ round(math.degrees(math.acos(abs(n[1]))), 2) for n in _sloped_faces(adapter)
+ )
+ evidence(
+ "the same chamfer with flip on the opposite edge (expect the other leg)",
+ {
+ "chamfer": flipped.get("chamfer"),
+ "volume_change_mm3": round(after_second - after_first, 2),
+ "face_tilts_from_top_deg": tilts,
+ },
+ call=f"add_chamfer({second})",
+ )
+ assert after_first - after_second == pytest.approx(removed, rel=0.01)
+ assert tilts == pytest.approx([30.0, 60.0], abs=0.1)
+
+
+@pytest.mark.asyncio
+async def test_a_plane_at_40_degrees_about_an_axis(scratch) -> None:
+ """create_axis names the axis; the plane is 40° from the Top Plane about it."""
+ adapter = scratch.adapter
+ tools = await _tools(adapter)
+ await _block(adapter, tools)
+
+ axis = await tools["create_axis"]({"reference": "x"})
+ call = {"reference": "Top Plane", "angle": 40, "axis": axis["axis"]["name"]}
+ plane = await tools["create_reference_plane"](call)
+ _origin, normal = _plane(adapter, plane["plane"]["name"])
+ tilt = math.degrees(math.acos(abs(normal[1])))
+ evidence(
+ "a plane at 40° to the Top Plane about the x axis",
+ {
+ "axis": axis["axis"]["name"],
+ "plane": plane.get("plane"),
+ "tilt_deg": round(tilt, 3),
+ },
+ call=f"create_reference_plane({call})",
+ )
+ assert axis["axis"]["name"], axis
+ assert plane["status"] == "success", plane
+ assert tilt == pytest.approx(40.0, abs=0.01)
+ assert abs(normal[0]) < 1e-6 # rotated about x
+
+
+@pytest.mark.asyncio
+async def test_a_plane_offset_from_a_sloped_face(scratch) -> None:
+ """A 3-4-5 wedge: a plane 5 mm off its slope, parallel to it."""
+ adapter = scratch.adapter
+ tools = await _tools(adapter)
+ assert (await adapter.create_part()).is_success
+ sketch = await adapter.create_sketch("Front")
+ for line in ((0, 0, 40, 0), (40, 0, 0, 30), (0, 30, 0, 0)):
+ assert (await adapter.add_line(*map(float, line))).is_success
+ assert (await adapter.exit_sketch()).is_success
+ assert (
+ await tools["create_extrusion"]({"sketch_name": sketch.data, "depth": 10.0})
+ )["status"] == "success"
+
+ call = {"face_point": [20.0, 15.0, 5.0], "offset": 5.0}
+ plane = await tools["create_reference_plane"](call)
+ origin, normal = _plane(adapter, plane["plane"]["name"])
+ # The slope is 0.6 x + 0.8 y = 24 mm; the new plane is 5 mm from it.
+ unit = (0.6, 0.8, 0.0)
+ gap = abs(abs(sum(a * b for a, b in zip(origin, unit, strict=True))) - 24.0)
+ evidence(
+ "a plane 5 mm off the wedge's 3-4-5 slope",
+ {
+ "plane": plane.get("plane"),
+ "normal": [round(c, 4) for c in normal],
+ "gap_from_slope_mm": round(gap, 4),
+ },
+ call=f"create_reference_plane({call})",
+ )
+ assert plane["status"] == "success", plane
+ assert [abs(c) for c in normal] == pytest.approx([0.6, 0.8, 0.0], abs=1e-4)
+ assert gap == pytest.approx(5.0, abs=0.01)
+
+
+@pytest.mark.asyncio
+async def test_a_pin_mates_concentric_in_its_hole_by_two_faces(
+ scratch, tmp_path
+) -> None:
+ """Two face mates by points in each part's own coordinates seat the pin in its hole."""
+ adapter = scratch.adapter
+ tools = await _tools(adapter)
+ plate, pin = await _plate_and_pin(scratch, tools, tmp_path, "mate")
+ assert (await adapter.create_assembly()).is_success
+ assert (await adapter.insert_component(plate, 0.0, 0.0, 0.0)).is_success
+ assert (await adapter.insert_component(pin, 100.0, 0.0, 0.0)).is_success
+ scratch.written_files.append(str(tmp_path / "gap_assembly.SLDASM"))
+ plate_name, pin_name = await _plate_then_pin(adapter)
+
+ concentric, flush = await _seat_pin(tools, plate_name, pin_name)
+ placed = _transforms(adapter)
+ hole = _axis_point_mm(placed[plate_name], 0.0) # the plate's bottom, on its origin
+ ends = sorted(
+ _axis_point_mm(placed[pin_name], y) for y in (0.0, 30.0)
+ ) # the pin's axis, bottom to top
+ axis = [round(ends[0][0] - hole[0], 3), round(ends[0][2] - hole[2], 3)]
+ span = [round(ends[0][1] - hole[1], 3), round(ends[1][1] - hole[1], 3)]
+ evidence(
+ "pin seated in the plate's hole by two face mates (expect axis 20, 0; y 0..30)",
+ {
+ "mates": [m.get("mate", m).get("entities") for m in (concentric, flush)],
+ "pin_axis_from_plate_mm": axis,
+ "pin_height_above_plate_bottom_mm": span,
+ },
+ call="add_mate(concentric, faces), add_mate(coincident, bottoms)",
+ )
+ assert concentric["status"] == "success", concentric
+ assert flush["status"] == "success", flush
+ assert axis == pytest.approx([20.0, 0.0], abs=0.01)
+ assert span == pytest.approx([0.0, 30.0], abs=0.01)
+
+
+@pytest.mark.asyncio
+async def test_components_fix_float_and_a_sub_assembly_turns_flexible(
+ scratch, tmp_path
+) -> None:
+ """Float and fix a part; flex a sub-assembly, keeping its other settings."""
+ adapter = scratch.adapter
+ tools = await _tools(adapter)
+ plate, pin = await _plate_and_pin(scratch, tools, tmp_path, "state")
+ assert (await adapter.create_assembly()).is_success
+ assert (await adapter.insert_component(plate, 0.0, 0.0, 0.0)).is_success
+ assert (await adapter.insert_component(pin, 100.0, 0.0, 0.0)).is_success
+ sub = tmp_path / "gap_pin_plate.SLDASM"
+ assert (await adapter.save_file(str(sub))).is_success
+ scratch.written_files.append(str(sub))
+ plate_name, _pin_name = await _plate_then_pin(adapter)
+
+ floated = await tools["set_component_state"](
+ {"component": plate_name, "fixed": False}
+ )
+ fixed = await tools["set_component_state"]({"component": plate_name, "fixed": True})
+ evidence(
+ "the first part in, which SolidWorks fixes: floated, then fixed again",
+ {"floated": floated.get("message"), "fixed": fixed.get("message")},
+ call=f'set_component_state({{"component": "{plate_name}", "fixed": false}})',
+ )
+ assert floated["state"]["before"]["fixed"] is True
+ assert floated["state"]["after"]["fixed"] is False
+ assert fixed["state"]["after"]["fixed"] is True
+
+ assert (await adapter.create_assembly()).is_success
+ assert (await adapter.insert_component(str(sub), 0.0, 0.0, 0.0)).is_success
+ scratch.written_files.append(str(tmp_path / "gap_main.SLDASM"))
+ sub_name = (await adapter.list_components()).data[0]
+ flexed = await tools["set_component_state"](
+ {"component": sub_name, "flexible": True}
+ )
+ kept = ("suppression", "visible", "referenced_configuration", "exclude_from_bom")
+ evidence(
+ "the pin-and-plate sub-assembly made flexible; everything else kept",
+ {
+ "message": flexed.get("message"),
+ "before": {
+ k: flexed["state"]["before"].get(k) for k in ("flexible", *kept)
+ },
+ "after": {k: flexed["state"]["after"].get(k) for k in ("flexible", *kept)},
+ },
+ call=f'set_component_state({{"component": "{sub_name}", "flexible": true}})',
+ )
+ assert flexed["status"] == "success", flexed
+ assert flexed["state"]["after"]["flexible"] is True
+ for key in kept:
+ assert flexed["state"]["after"][key] == flexed["state"]["before"][key], key
+ rigid = await tools["set_component_state"](
+ {"component": sub_name, "flexible": False}
+ )
+ evidence(
+ "the sub-assembly back to rigid",
+ {
+ "message": rigid.get("message"),
+ "flexible": [
+ rigid["state"]["before"].get("flexible"),
+ rigid["state"]["after"].get("flexible"),
+ ],
+ },
+ call=f'set_component_state({{"component": "{sub_name}", "flexible": false}})',
+ )
+ assert rigid["state"]["after"]["flexible"] is False
+
+
+@pytest.mark.asyncio
+async def test_interference_is_fresh_after_a_mate(scratch, tmp_path) -> None:
+ """Called with dicts: the pin overlaps the plate, then sits in the Ø10 hole clear."""
+ adapter = scratch.adapter
+ tools = await _tools(adapter)
+ plate, pin = await _plate_and_pin(scratch, tools, tmp_path, "clash")
+ assert (await adapter.create_assembly()).is_success
+ assert (await adapter.insert_component(plate, 0.0, 0.0, 0.0)).is_success
+ assert (await adapter.insert_component(pin, 0.0, 0.0, 0.0)).is_success
+ scratch.written_files.append(str(tmp_path / "gap_clash.SLDASM"))
+ plate_name, pin_name = await _plate_then_pin(adapter)
+ check = {"components": [plate_name, pin_name]}
+
+ clash = await tools["check_interference"](check)
+ seated = await _seat_pin(tools, plate_name, pin_name)
+ assert all(m["status"] == "success" for m in seated), seated
+ clear = await tools["check_interference"](check)
+ before = clash["data"]["interference_count"]
+ after = clear["data"]["interference_count"]
+ evidence(
+ "interference: the pin through solid plate, then seated in the Ø10 hole",
+ {"before_mate": before, "after_mate": after},
+ call=f"check_interference({check})",
+ )
+ assert clash["status"] == "success", clash
+ assert clear["status"] == "success", clear
+ assert before >= 1
+ assert after == 0
diff --git a/tests/scripts/make_demo_gif.py b/tests/scripts/make_demo_gif.py
index 72beecd2..ed520541 100644
--- a/tests/scripts/make_demo_gif.py
+++ b/tests/scripts/make_demo_gif.py
@@ -116,12 +116,14 @@ def build_recording(
max_hold: float,
min_caption: float = 0.6,
prompt_seconds: float = 4.0,
+ end_seconds: float = 5.0,
) -> Path | None:
"""One recording as a GIF; ``None`` when it holds no frames.
A frame where a new caption appears shows for at least ``min_caption``
seconds, so a tool call can be read however fast the rest plays; the
- first caption, the prompt, for at least ``prompt_seconds``.
+ first caption, the prompt, for at least ``prompt_seconds``; and the last
+ frame, the result, for at least ``end_seconds`` before the GIF loops.
"""
frames, captions, end = _timeline(folder)
if not frames:
@@ -164,6 +166,8 @@ def scaled(path: Path) -> Image.Image:
# The first caption is the prompt: give it time to be read.
first = caption_path == captions[0][1]
seconds = max(seconds, prompt_seconds if first else min_caption)
+ if i == len(events) - 1:
+ seconds = max(seconds, end_seconds)
images.append(frame.quantize(palette=palette, dither=Image.Dither.NONE))
durations.append(max(20, int(seconds * 1000)))
images[0].save(
@@ -203,6 +207,7 @@ def build_recordings(
max_hold: float,
min_caption: float = 0.6,
prompt_seconds: float = 4.0,
+ end_seconds: float = 5.0,
) -> list[Path]:
"""``-motion.gif`` for every recording under ``directory/recording``."""
root = directory / "recording"
@@ -218,6 +223,7 @@ def build_recordings(
max_hold,
min_caption,
prompt_seconds,
+ end_seconds,
)
if gif is not None:
written.append(gif)
@@ -251,6 +257,12 @@ def main() -> None:
default=4.0,
help="how long the first frame, the prompt, shows (s)",
)
+ parser.add_argument(
+ "--end-seconds",
+ type=float,
+ default=5.0,
+ help="how long the last motion frame, the result, shows (s)",
+ )
args = parser.parse_args()
paths = build_gifs(args.directory, args.width, args.seconds, args.prompt_seconds)
if args.recording:
@@ -261,6 +273,7 @@ def main() -> None:
args.max_hold,
args.min_caption,
args.prompt_seconds,
+ args.end_seconds,
)
for path in paths:
print(f"{path} ({path.stat().st_size // 1024} KB)")
diff --git a/tests/solidworks_mcp/adapters/solidworks/test_face_selection.py b/tests/solidworks_mcp/adapters/solidworks/test_face_selection.py
index c31c4cec..011c9a3c 100644
--- a/tests/solidworks_mcp/adapters/solidworks/test_face_selection.py
+++ b/tests/solidworks_mcp/adapters/solidworks/test_face_selection.py
@@ -450,3 +450,31 @@ async def test_side_without_a_planar_face_is_an_error() -> None:
assert result.is_error
assert "No planar face faces 'top'" in (result.error or "")
+
+
+# ---- component faces and mate labels (#209) ----------------------------------
+
+
+def test_component_faces_reads_the_components_own_bodies() -> None:
+ faces, _ = _block()
+ body = SimpleNamespace(GetFaces=lambda: faces)
+ asked: list[int] = []
+ component = SimpleNamespace(GetBodies2=lambda t: asked.append(t) or [body])
+ selector = fs.FaceEdgeSelector(_Harness(_model([])))
+
+ found = selector.component_faces(component)
+
+ assert asked == [0] # swSolidBody
+ assert len(found) == len(faces)
+ assert selector.face_at_point(found, [0.0, 10.0, 0.0]).handle in faces
+
+
+@pytest.mark.parametrize(
+ ("entity", "face", "label"),
+ [
+ ("Front Plane", None, "Front Plane"),
+ ("Front Plane", [0, 5, 1.5], "face at [0.0, 5.0, 1.5] mm"),
+ ],
+)
+def test_mate_entity_label(entity: str, face: list[float] | None, label: str) -> None:
+ assert fs.mate_entity_label(entity, face) == label
diff --git a/tests/solidworks_mcp/adapters/solidworks/test_features.py b/tests/solidworks_mcp/adapters/solidworks/test_features.py
index b7d4db19..eb8e8647 100644
--- a/tests/solidworks_mcp/adapters/solidworks/test_features.py
+++ b/tests/solidworks_mcp/adapters/solidworks/test_features.py
@@ -503,6 +503,32 @@ def test_add_chamfer_empty_edge_names_does_not_touch_selection() -> None:
select_by_id2.assert_not_called()
+@pytest.mark.parametrize(
+ ("angle", "flip", "options"), [(45.0, False, 1), (60.0, False, 1), (60.0, True, 0)]
+)
+def test_add_chamfer_passes_angle_and_flip(
+ angle: float, flip: bool, options: int
+) -> None:
+ import math
+
+ adapter = _FakeFeatureAdapter()
+ insert = Mock(return_value=SimpleNamespace(Name="Chamfer1"))
+ adapter.currentModel = SimpleNamespace(
+ ClearSelection2=Mock(return_value=True),
+ Extension=SimpleNamespace(SelectByID2=Mock(return_value=True)),
+ FeatureManager=SimpleNamespace(InsertFeatureChamfer=insert),
+ )
+
+ result = features._add_chamfer_impl(adapter, 2.0, [], angle, flip)
+
+ assert result.is_success
+ args = insert.call_args.args
+ assert args[:3] == (options, 1, 0.002) # Options, swChamferAngleDistance, m
+ assert args[3] == pytest.approx(math.radians(angle))
+ assert result.data.parameters["angle"] == angle
+ assert result.data.parameters["flip"] is flip
+
+
def test_add_fillet_sw33_also_uses_new_path() -> None:
"""major=33 (SW 2025) hits the >= 33 branch; name defaults to 'Fillet'
because IModelDoc2.FeatureFillet3 returns int, not IFeature."""
@@ -1579,7 +1605,7 @@ def test_errors_when_reference_blank(self, monkeypatch) -> None:
result = _create_reference_plane_impl(adapter, "", 10.0, 0.0, False)
assert result.is_error
- assert "requires a reference plane/face name" in (result.error or "")
+ assert "requires a reference plane name or a face_point" in (result.error or "")
def test_flip_true_combines_the_option_flag_on_success(self, monkeypatch) -> None:
adapter = _build_adapter(monkeypatch)
@@ -2472,6 +2498,27 @@ def test_fillet_and_chamfer_pick_coordinate_specs_by_nearest_entity(
assert "Failed to select edge: face:0.01,0.02,0.0" in (result.error or "")
+def _patch_face(
+ monkeypatch: pytest.MonkeyPatch, normal: tuple[float, float, float], handle: Any
+) -> None:
+ """Make FaceEdgeSelector find one face with ``normal`` at any point."""
+ from solidworks_mcp.adapters.solidworks import face_selection
+
+ face = face_selection.FaceInfo(0, normal, (0.0,) * 6, 1.0, handle)
+
+ class _Selector:
+ def __init__(self, adapter: Any) -> None:
+ pass
+
+ def faces(self) -> list[Any]:
+ return [face]
+
+ def face_at_point(self, faces: list[Any], point: list[float]) -> Any:
+ return faces[0]
+
+ monkeypatch.setattr(face_selection, "FaceEdgeSelector", _Selector)
+
+
def _selectable_feature(**attrs: Any) -> SimpleNamespace:
return SimpleNamespace(Select2=lambda _append, _mark: True, **attrs)
@@ -2605,11 +2652,113 @@ def test_undo_reports_whether_the_tree_changed(
class TestReferenceGeometryOutcomes:
"""Reference plane, axis and point: refusals and successes."""
- def test_angled_plane_is_refused(self, monkeypatch) -> None:
+ def test_an_angle_without_an_axis_is_refused(self, monkeypatch) -> None:
adapter = _build_adapter(monkeypatch)
adapter.currentModel = MagicMock()
result = _create_reference_plane_impl(adapter, "Top Plane", 0.0, 30.0, False)
- assert "does not support 'angle' yet" in (result.error or "")
+ assert "needs an axis to rotate about" in (result.error or "")
+
+ @pytest.mark.parametrize(("flip", "first"), [(False, 16), (True, 16 | 256)])
+ def test_an_angled_plane_selects_the_axis_under_mark_1(
+ self, monkeypatch, flip: bool, first: int
+ ) -> None:
+ adapter = _build_adapter(monkeypatch)
+ adapter.currentModel = MagicMock()
+ marks: list[tuple[str, bool, int]] = []
+
+ def feature(name: str) -> SimpleNamespace:
+ return SimpleNamespace(
+ Select2=lambda append, mark: marks.append((name, append, mark)) or True
+ )
+
+ adapter.currentModel.FeatureByName.side_effect = feature
+ manager = MagicMock()
+ manager.GetFeatureCount.side_effect = [5, 6]
+ manager.InsertRefPlane.return_value = SimpleNamespace(Name="Plane1")
+ adapter.currentModel.FeatureManager = manager
+
+ result = _create_reference_plane_impl(
+ adapter, "Top Plane", 0.0, 40.0, flip, axis="Axis1"
+ )
+
+ assert result.is_success, result.error
+ assert marks == [("Top Plane", False, 0), ("Axis1", True, 1)]
+ args = manager.InsertRefPlane.call_args.args
+ assert args[0] == first
+ assert args[1] == pytest.approx(math.radians(40))
+ assert args[2] == 4 # Coincident with the axis
+ assert result.data["axis"] == "Axis1"
+ assert result.data["angle"] == 40.0
+
+ def test_an_axis_that_cannot_be_selected_is_named(self, monkeypatch) -> None:
+ adapter = _build_adapter(monkeypatch)
+ adapter.currentModel = MagicMock()
+ adapter.currentModel.FeatureByName.side_effect = lambda name: (
+ _selectable_feature() if name == "Top Plane" else None
+ )
+
+ result = _create_reference_plane_impl(
+ adapter, "Top Plane", 0.0, 40.0, False, axis="Axis9"
+ )
+
+ assert result.is_error
+ assert "Failed to select axis 'Axis9'" in (result.error or "")
+
+ @pytest.mark.parametrize(("offset", "first"), [(5.0, 8), (0.0, 4)])
+ def test_a_face_point_selects_that_face_under_mark_0(
+ self, monkeypatch, offset: float, first: int
+ ) -> None:
+ adapter = _build_adapter(monkeypatch)
+ adapter.currentModel = MagicMock()
+ data = SimpleNamespace(Mark=None)
+ adapter.currentModel.SelectionManager.CreateSelectData.return_value = data
+ selected: list[Any] = []
+ handle = SimpleNamespace(
+ Select4=lambda append, d: selected.append((append, d.Mark)) or True
+ )
+ _patch_face(monkeypatch, normal=(0.0, 0.6, 0.8), handle=handle)
+ manager = MagicMock()
+ manager.GetFeatureCount.side_effect = [5, 6]
+ manager.InsertRefPlane.return_value = SimpleNamespace(Name="Plane1")
+ adapter.currentModel.FeatureManager = manager
+
+ result = _create_reference_plane_impl(
+ adapter, "", offset, 0.0, False, face_point=[1.0, 2.0, 3.0]
+ )
+
+ assert result.is_success, result.error
+ assert selected == [(False, 0)]
+ assert manager.InsertRefPlane.call_args.args[0] == first
+ assert result.data["face_point"] == [1.0, 2.0, 3.0]
+ assert result.data["reference"] is None
+
+ def test_a_curved_face_is_refused(self, monkeypatch) -> None:
+ adapter = _build_adapter(monkeypatch)
+ adapter.currentModel = MagicMock()
+ _patch_face(monkeypatch, normal=(0.0, 0.0, 0.0), handle=None)
+
+ result = _create_reference_plane_impl(
+ adapter, "", 5.0, 0.0, False, face_point=[1.0, 2.0, 3.0]
+ )
+
+ assert result.is_error
+ assert "is not flat" in (result.error or "")
+
+ def test_a_face_that_will_not_select_is_reported(self, monkeypatch) -> None:
+ adapter = _build_adapter(monkeypatch)
+ adapter.currentModel = MagicMock()
+ _patch_face(
+ monkeypatch,
+ normal=(0.0, 1.0, 0.0),
+ handle=SimpleNamespace(Select4=lambda append, d: False),
+ )
+
+ result = _create_reference_plane_impl(
+ adapter, "", 5.0, 0.0, False, face_point=[1.0, 2.0, 3.0]
+ )
+
+ assert result.is_error
+ assert "Failed to select the face at [1.0, 2.0, 3.0] mm" in (result.error or "")
def test_plane_without_offset_is_refused(self, monkeypatch) -> None:
adapter = _build_adapter(monkeypatch)
@@ -2629,14 +2778,30 @@ def test_axis_reports_its_planes(self, monkeypatch) -> None:
adapter.currentModel = MagicMock()
adapter.currentModel.FeatureByName.return_value = _selectable_feature()
adapter.currentModel.FeatureManager.GetFeatureCount.side_effect = [5, 6]
+ adapter.currentModel.FeatureByPositionReverse.return_value = SimpleNamespace(
+ Name="Axis1"
+ )
result = _create_axis_impl(adapter, "-Z")
+ assert result.data["name"] == "Axis1"
assert result.data["reference"] == "z"
assert len(result.data["planes"]) == 2
assert result.data["features_before"] == 5
assert result.data["features_after"] == 6
+ def test_an_axis_whose_name_cannot_be_read_says_none(self, monkeypatch) -> None:
+ adapter = _build_adapter(monkeypatch)
+ adapter.currentModel = MagicMock()
+ adapter.currentModel.FeatureByName.return_value = _selectable_feature()
+ adapter.currentModel.FeatureManager.GetFeatureCount.side_effect = [5, 6]
+ adapter.currentModel.FeatureByPositionReverse.return_value = None
+
+ result = _create_axis_impl(adapter, "x")
+
+ assert result.is_success
+ assert result.data["name"] is None
+
def test_unknown_point_mode_is_refused(self) -> None:
result = features._create_reference_point_impl(
_point_adapter(), "vertex", 0, 0, 0, None, None
@@ -2873,6 +3038,43 @@ def test_thin_extrusion_uses_the_same_end_condition() -> None:
assert calls[0][3] == 6
+@pytest.mark.parametrize(
+ ("side", "rev_thin_dir"), [("one_side", 0), ("other_side", 1), ("mid_plane", 2)]
+)
+def test_thin_side_sets_the_thin_wall_type(side: str, rev_thin_dir: int) -> None:
+ calls: list[tuple] = []
+ adapter = _extrusion_adapter(
+ FeatureExtrusionThin2=lambda *a: (
+ calls.append(a) or SimpleNamespace(Name="Thin1")
+ )
+ )
+
+ result = features._create_extrusion_impl(
+ adapter,
+ ExtrusionParameters(
+ depth=38.1, thin_feature=True, thin_thickness=6.35, thin_side=side
+ ),
+ )
+
+ assert result.is_success, result.error
+ assert calls[0][21] == rev_thin_dir # RevThinDir
+ assert result.data.parameters["thin_side"] == side
+
+
+def test_an_unknown_thin_side_fails_clearly() -> None:
+ adapter = _extrusion_adapter(FeatureExtrusionThin2=Mock())
+
+ result = features._create_extrusion_impl(
+ adapter,
+ ExtrusionParameters(
+ depth=5.0, thin_feature=True, thin_thickness=1.0, thin_side="inside"
+ ),
+ )
+
+ assert result.is_error
+ assert "Unsupported thin_side 'inside'" in (result.error or "")
+
+
def test_extrusion_with_an_unsupported_end_condition_fails_clearly() -> None:
adapter = _extrusion_adapter(FeatureExtrusion3=Mock())
diff --git a/tests/solidworks_mcp/adapters/solidworks/test_io.py b/tests/solidworks_mcp/adapters/solidworks/test_io.py
index ca56560a..c0ec2cb9 100644
--- a/tests/solidworks_mcp/adapters/solidworks/test_io.py
+++ b/tests/solidworks_mcp/adapters/solidworks/test_io.py
@@ -36,6 +36,7 @@ def _handle_com_operation(self, _name, callback, *args):
import struct
import sys
from pathlib import Path
+from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, call
import pytest
@@ -3934,6 +3935,194 @@ async def test_add_mate_reports_which_components_moved(self, monkeypatch) -> Non
assert result.data["moved_components"] == ["part-2"]
assert result.data["geometry_moved"] is True
+ @staticmethod
+ def _face_mate(monkeypatch, handle: Any, found: bool = True) -> PyWin32Adapter:
+ """A two-component assembly whose 'pin-1' has one face, at ``handle``."""
+ from solidworks_mcp.adapters.solidworks import face_selection
+
+ adapter = TestAssemblyAndDrawingSuccessPaths._document(monkeypatch, 2)
+ monkeypatch.setattr(
+ _io_module, "_dynamic", SimpleNamespace(Dispatch=lambda obj: obj)
+ )
+ feature = SimpleNamespace(Select2=lambda append, mark: True)
+ adapter.currentModel.GetComponents.return_value = [
+ SimpleNamespace(Name2="bracket-1", FeatureByName=lambda e: feature),
+ SimpleNamespace(Name2="pin-1", FeatureByName=lambda e: feature),
+ ]
+ adapter.currentModel.SelectionManager.GetSelectedObjectCount2.return_value = 2
+ adapter.currentModel.AddMate5.return_value = SimpleNamespace()
+ monkeypatch.setattr(_io_module, "_byref_int", lambda: SimpleNamespace(value=1))
+ monkeypatch.setattr(_io_module, "_component_transforms", lambda _a, _asm: {})
+ face = face_selection.FaceInfo(0, (0.0, 0.0, 0.0), (0.0,) * 6, 1.0, handle)
+
+ class _Selector:
+ def __init__(self, _adapter: Any) -> None:
+ pass
+
+ def component_faces(self, component: Any) -> list[Any]:
+ assert component.Name2 == "pin-1"
+ return [face]
+
+ def face_at_point(self, faces: list[Any], point: list[float]) -> Any:
+ if not found:
+ raise LookupError(f"No face found at point {point} mm.")
+ return faces[0]
+
+ monkeypatch.setattr(face_selection, "FaceEdgeSelector", _Selector)
+ return adapter
+
+ @pytest.mark.asyncio
+ async def test_add_mate_selects_a_face_of_that_component(self, monkeypatch) -> None:
+ picks: list[bool] = []
+ handle = SimpleNamespace(
+ Select4=lambda append, data: picks.append(append) or True
+ )
+ adapter = self._face_mate(monkeypatch, handle)
+
+ result = await adapter.add_mate(
+ "bracket-1", "pin-1", "Top Plane", mate_type="concentric", face_b=[0, 5, 0]
+ )
+
+ assert result.is_success, result.error
+ assert picks == [True] # the second side appends to the first
+ assert result.data["entities"] == ["Top Plane", "face at [0.0, 5.0, 0.0] mm"]
+ assert result.data["geometry_moved"] is None
+
+ @pytest.mark.asyncio
+ async def test_add_mate_names_a_missing_face(self, monkeypatch) -> None:
+ adapter = self._face_mate(monkeypatch, None, found=False)
+
+ result = await adapter.add_mate("bracket-1", "pin-1", face_b=[0, 99, 0])
+
+ assert result.is_error
+ assert "Face not found on pin-1 at [0, 99, 0] mm" in (result.error or "")
+
+ @pytest.mark.asyncio
+ async def test_add_mate_reports_a_face_that_will_not_select(
+ self, monkeypatch
+ ) -> None:
+ handle = SimpleNamespace(Select4=lambda append, data: False)
+ adapter = self._face_mate(monkeypatch, handle)
+
+ result = await adapter.add_mate("bracket-1", "pin-1", face_b=[0, 5, 0])
+
+ assert result.is_error
+ assert "Failed to select the face of pin-1" in (result.error or "")
+
+ @staticmethod
+ def _state_adapter(
+ monkeypatch, *, fixed: bool = True, applied: bool = True, selects: bool = True
+ ) -> tuple[PyWin32Adapter, SimpleNamespace, list[Any]]:
+ """An assembly holding 'crank-1', whose state the fakes track."""
+ adapter = TestAssemblyAndDrawingSuccessPaths._document(monkeypatch, 2)
+ monkeypatch.setattr(
+ _io_module, "_dynamic", SimpleNamespace(Dispatch=lambda obj: obj)
+ )
+ calls: list[Any] = []
+ comp = SimpleNamespace(
+ Name2="crank-1",
+ Visible=1,
+ Solving=0,
+ ReferencedConfiguration="Default",
+ ExcludeFromBOM=False,
+ IsEnvelope=lambda: False,
+ GetSuppression2=lambda: 3,
+ Select4=lambda append, data, popup: selects,
+ )
+ state = {"fixed": fixed}
+ comp.IsFixed = lambda: state["fixed"]
+ model = adapter.currentModel
+ model.GetComponents.return_value = [comp]
+
+ def fix() -> None:
+ calls.append("fix")
+ state["fixed"] = True
+
+ def unfix() -> None:
+ calls.append("unfix")
+ state["fixed"] = False
+
+ def config(*args: Any) -> bool:
+ calls.append(("config", args))
+ if applied:
+ comp.Solving = args[1]
+ return applied
+
+ model.FixComponent = fix
+ model.UnfixComponent = unfix
+ model.CompConfigProperties5 = config
+ return adapter, comp, calls
+
+ @pytest.mark.asyncio
+ async def test_set_component_state_floats_and_flexes(self, monkeypatch) -> None:
+ adapter, _comp, calls = self._state_adapter(monkeypatch)
+
+ result = await adapter.set_component_state(
+ "crank-1", fixed=False, flexible=True
+ )
+
+ assert result.is_success, result.error
+ assert calls[0] == "unfix"
+ # Suppression, visibility, configuration, BOM and envelope go back as read.
+ assert calls[1] == ("config", (3, 1, True, True, "Default", False, False))
+ assert result.data["before"]["fixed"] is True
+ assert result.data["after"]["fixed"] is False
+ assert result.data["after"]["flexible"] is True
+ assert result.data["after"]["referenced_configuration"] == "Default"
+
+ @pytest.mark.asyncio
+ async def test_set_component_state_fixes(self, monkeypatch) -> None:
+ adapter, _comp, calls = self._state_adapter(monkeypatch, fixed=False)
+
+ result = await adapter.set_component_state("crank-1", fixed=True)
+
+ assert result.is_success, result.error
+ assert calls == ["fix"]
+ assert result.data["after"]["fixed"] is True
+
+ @pytest.mark.asyncio
+ async def test_a_part_cannot_be_flexible(self, monkeypatch) -> None:
+ adapter, _comp, _calls = self._state_adapter(monkeypatch, applied=False)
+
+ result = await adapter.set_component_state("crank-1", flexible=True)
+
+ assert result.is_error
+ assert "only a sub-assembly can be flexible" in (result.error or "")
+
+ @pytest.mark.asyncio
+ async def test_a_component_that_will_not_select_is_reported(
+ self, monkeypatch
+ ) -> None:
+ adapter, _comp, _calls = self._state_adapter(monkeypatch, selects=False)
+
+ result = await adapter.set_component_state("crank-1", fixed=False)
+
+ assert result.is_error
+ assert "Failed to select crank-1" in (result.error or "")
+
+ @pytest.mark.asyncio
+ async def test_a_fix_that_does_not_take_is_reported(self, monkeypatch) -> None:
+ adapter, _comp, _calls = self._state_adapter(monkeypatch, fixed=False)
+ adapter.currentModel.FixComponent = lambda: None # SolidWorks ignores it
+
+ result = await adapter.set_component_state("crank-1", fixed=True)
+
+ assert result.is_error
+ assert "crank-1 is still floating" in (result.error or "")
+
+ @pytest.mark.asyncio
+ async def test_set_component_state_refusals(self, monkeypatch) -> None:
+ part = self._document(monkeypatch, 1)
+ adapter, _comp, _calls = self._state_adapter(monkeypatch)
+
+ wrong_doc = await part.set_component_state("crank-1", fixed=True)
+ nothing = await adapter.set_component_state("crank-1")
+ missing = await adapter.set_component_state("ghost-1", fixed=True)
+
+ assert "requires an assembly document" in (wrong_doc.error or "")
+ assert "Nothing to change" in (nothing.error or "")
+ assert "Component(s) not found: ghost-1" in (missing.error or "")
+
@pytest.mark.asyncio
async def test_list_components_returns_their_names(self, monkeypatch) -> None:
adapter = self._document(monkeypatch, 2)
diff --git a/tests/solidworks_mcp/adapters/test_base.py b/tests/solidworks_mcp/adapters/test_base.py
index c028d49a..14c6b4c4 100644
--- a/tests/solidworks_mcp/adapters/test_base.py
+++ b/tests/solidworks_mcp/adapters/test_base.py
@@ -634,6 +634,10 @@ async def set_dimension(self, name, value):
assert not mate_result.is_success
assert "add_mate" in (mate_result.error or "")
+ state_result = await adapter.set_component_state("a-1", fixed=True)
+ assert not state_result.is_success
+ assert "set_component_state" in (state_result.error or "")
+
CAPABILITIESFeatureEditingCapability = [
"delete_feature",
diff --git a/tests/solidworks_mcp/adapters/test_circuit_breaker.py b/tests/solidworks_mcp/adapters/test_circuit_breaker.py
index 6049823f..c01fcce9 100644
--- a/tests/solidworks_mcp/adapters/test_circuit_breaker.py
+++ b/tests/solidworks_mcp/adapters/test_circuit_breaker.py
@@ -914,6 +914,7 @@ async def create_cut_extrude(self, params):
"insert_component",
"list_components",
"add_mate",
+ "set_component_state",
]
diff --git a/tests/solidworks_mcp/adapters/test_connection_pool.py b/tests/solidworks_mcp/adapters/test_connection_pool.py
index 819dfafc..396b0103 100644
--- a/tests/solidworks_mcp/adapters/test_connection_pool.py
+++ b/tests/solidworks_mcp/adapters/test_connection_pool.py
@@ -786,7 +786,13 @@ async def disconnect(self):
return None
async def create_reference_plane(
- self, reference, offset=0.0, angle=0.0, flip=False
+ self,
+ reference,
+ offset=0.0,
+ angle=0.0,
+ flip=False,
+ axis=None,
+ face_point=None,
):
return _ok("plane")
@@ -871,10 +877,15 @@ async def add_mate(
alignment="aligned",
distance=0.0,
angle=0.0,
+ face_a=None,
+ face_b=None,
):
return _ok("mate")
- async def add_chamfer(self, distance, edge_names):
+ async def set_component_state(self, component, fixed=None, flexible=None):
+ return _ok("state")
+
+ async def add_chamfer(self, distance, edge_names, angle=45.0, flip=False):
return _ok("chamfer")
async def get_selected_entities(self):
@@ -906,6 +917,7 @@ async def _exercise_every_passthrough(wrapped) -> None:
assert (await wrapped.rename_feature("Sketch1", "Sketch2")).is_success
assert (await wrapped.undo()).is_success
assert (await wrapped.add_mate("Comp1", "Comp2")).is_success
+ assert (await wrapped.set_component_state("Comp1", fixed=True)).is_success
assert (await wrapped.add_chamfer(1.0, ["Edge1"])).is_success
assert (await wrapped.get_selected_entities()).is_success
diff --git a/tests/solidworks_mcp/tools/test_analysis.py b/tests/solidworks_mcp/tools/test_analysis.py
index b9c82d42..53430299 100644
--- a/tests/solidworks_mcp/tools/test_analysis.py
+++ b/tests/solidworks_mcp/tools/test_analysis.py
@@ -7,6 +7,7 @@
import pytest
from fastmcp import FastMCP
+from solidworks_mcp.adapters.base import AdapterResult, AdapterResultStatus
from solidworks_mcp.adapters.mock_adapter import MockSolidWorksAdapter
from solidworks_mcp.config import (
AdapterType,
@@ -491,3 +492,25 @@ def test_capability_name_matches_the_tool_gate() -> None:
f"no tool in analysis.py gates on adapter.{CAPABILITY}; either the "
"gate was renamed or this capability is unreachable"
)
+
+
+@pytest.mark.asyncio
+async def test_check_interference_accepts_a_plain_dict(
+ mcp_server, mock_adapter
+) -> None:
+ """A script can pass a dict, as #209's U-joint session did."""
+ mock_adapter.check_interference = AsyncMock(
+ return_value=AdapterResult(
+ status=AdapterResultStatus.SUCCESS,
+ data={"interference_count": 0, "interferences": []},
+ )
+ )
+ await register_analysis_tools(mcp_server, mock_adapter, {})
+ tool = next(
+ t.fn for t in await mcp_server.list_tools() if t.name == "check_interference"
+ )
+
+ result = await tool({"components": ["a-1", "b-1"]})
+
+ assert result["status"] == "success"
+ assert mock_adapter.check_interference.await_count == 1
diff --git a/tests/solidworks_mcp/tools/test_drawing.py b/tests/solidworks_mcp/tools/test_drawing.py
index f8615405..04ee002b 100644
--- a/tests/solidworks_mcp/tools/test_drawing.py
+++ b/tests/solidworks_mcp/tools/test_drawing.py
@@ -812,15 +812,10 @@ async def test_typed_drawing_tools_exception_paths(
# ── additional drawing tool branches (legacy tool set) ───────────────
@pytest.mark.asyncio
- async def test_add_dimension_raw_dict_payload_normalization(
+ async def test_add_dimension_accepts_a_plain_dict(
self, mcp_server, mock_adapter, mock_config
):
- """Covers dict payload branch with entities/position alias mapping.
-
- The entities/position aliases must reach the adapter call as
- entity1/entity2/position_x/position_y even when the caller only
- supplied entities/position.
- """
+ """A plain dict is validated as AddDimensionInput and reaches the adapter."""
await register_drawing_tools(mcp_server, mock_adapter, mock_config)
mock_adapter.add_dimension = AsyncMock(
@@ -834,8 +829,10 @@ async def test_add_dimension_raw_dict_payload_normalization(
result = await tool_func(
{
"dimension_type": "linear",
- "entities": ["Edge1", "Edge2"],
- "position": [11.0, 22.0],
+ "entity1": "Edge1",
+ "entity2": "Edge2",
+ "position_x": 11.0,
+ "position_y": 22.0,
"precision": 3,
}
)
diff --git a/tests/solidworks_mcp/tools/test_drawing_automation.py b/tests/solidworks_mcp/tools/test_drawing_automation.py
index 1e2ffe1b..ab60c207 100644
--- a/tests/solidworks_mcp/tools/test_drawing_automation.py
+++ b/tests/solidworks_mcp/tools/test_drawing_automation.py
@@ -33,6 +33,22 @@
)
+#: The smallest valid input of each tool that has required fields.
+_VALID_INPUT: dict[str, dict[str, Any]] = {
+ "insert_bom_table": {"view_name": "Drawing View1"},
+ "add_gtol": {"characteristic": "position", "tolerance": 0.1},
+ "add_datum_tag": {
+ "label": "A",
+ "attach_x_mm": 10,
+ "attach_y_mm": 10,
+ "x_mm": 20,
+ "y_mm": 20,
+ },
+ "set_sheet_scale": {"numerator": 1, "denominator": 2},
+ "generate_drawing_package": {"model_path": "C:/parts/block.sldprt"},
+}
+
+
async def _tool(mcp_server: Any, name: str) -> Any:
return next(t.fn for t in await mcp_server.list_tools() if t.name == name)
@@ -331,7 +347,7 @@ async def test_missing_adapter_method_is_reported(
) -> None:
await register_drawing_automation_tools(mcp_server, object(), mock_config)
- result = await (await _tool(mcp_server, tool))({})
+ result = await (await _tool(mcp_server, tool))(_VALID_INPUT.get(tool, {}))
assert result["error_type"] == "not_supported"
assert f"does not support {tool}" in result["message"]
diff --git a/tests/solidworks_mcp/tools/test_export.py b/tests/solidworks_mcp/tools/test_export.py
index a837db50..1bdebab6 100644
--- a/tests/solidworks_mcp/tools/test_export.py
+++ b/tests/solidworks_mcp/tools/test_export.py
@@ -800,3 +800,20 @@ async def test_export_tools_native_adapter_methods(mcp_server, mock_config):
)
)
)["status"] == "success"
+
+
+@pytest.mark.asyncio
+async def test_a_file_export_without_format_type_uses_the_tools_own(
+ mcp_server, mock_adapter, mock_config
+) -> None:
+ await register_export_tools(mcp_server, mock_adapter, mock_config)
+ mock_adapter.export_step = AsyncMock(
+ return_value=Mock(is_success=True, data={}, execution_time=0.1)
+ )
+ tool = next(t.fn for t in await mcp_server.list_tools() if t.name == "export_step")
+
+ result = await tool({"file_path": "C:/out/part.step"})
+
+ assert result["status"] == "success"
+ assert mock_adapter.export_step.call_args.args[0]["format_type"] == "step"
+ assert ExportFileInput(file_path="C:/out/part.step").format_type is None
diff --git a/tests/solidworks_mcp/tools/test_modeling.py b/tests/solidworks_mcp/tools/test_modeling.py
index 1746b67d..cfc7e3cf 100644
--- a/tests/solidworks_mcp/tools/test_modeling.py
+++ b/tests/solidworks_mcp/tools/test_modeling.py
@@ -43,6 +43,7 @@
RenameFeatureInput,
SelectFaceEdgesInput,
SelectFeatureInput,
+ SetComponentStateInput,
SetDimensionInput,
SetUnitsInput,
SuppressFeatureInput,
@@ -257,7 +258,8 @@ async def test_create_extrusion_blind(self, mcp_server, mock_adapter, mock_confi
assert result["status"] == "success"
assert result["extrusion"]["sketch"] == "Sketch1"
assert result["extrusion"]["depth"] == 25.0
- assert result["extrusion"]["direction"] == "blind"
+ assert result["extrusion"]["end_condition"] == "Blind"
+ assert "direction" not in result["extrusion"]
@pytest.mark.asyncio
async def test_create_revolve_full(self, mcp_server, mock_adapter, mock_config):
@@ -807,7 +809,9 @@ async def test_add_chamfer_success_and_failure_paths(
assert success_result["status"] == "success"
assert success_result["chamfer"]["distance"] == 1.5
assert success_result["chamfer"]["edges"] == ["Edge<1>", "Edge<2>"]
- mock_adapter.add_chamfer.assert_awaited_once_with(1.5, ["Edge<1>", "Edge<2>"])
+ mock_adapter.add_chamfer.assert_awaited_once_with(
+ 1.5, ["Edge<1>", "Edge<2>"], 45.0, False
+ )
mock_adapter.add_chamfer = AsyncMock(
return_value=Mock(
@@ -1361,7 +1365,7 @@ async def test_create_reference_plane_surfaces_adapter_error(
def test_create_reference_plane_input_rejects_blank_reference(self):
"""CreateReferencePlaneInput.model_post_init rejects a blank reference."""
- with pytest.raises(ValueError, match="reference is required"):
+ with pytest.raises(ValueError, match="exactly one of reference or face_point"):
CreateReferencePlaneInput(reference=" ", offset=10.0)
def test_set_units_input_rejects_blank_unit_system(self):
@@ -2060,3 +2064,199 @@ async def test_rebuild_model_tool_no_model(
assert result["status"] == "error"
assert "No active model" in result["message"]
assert "data" not in result
+
+
+@pytest.mark.asyncio
+async def test_extrusion_reply_says_mid_plane_for_both_directions(
+ mcp_server, mock_adapter
+) -> None:
+ await mock_adapter.create_part()
+ await register_modeling_tools(mcp_server, mock_adapter, {})
+ tool = next(
+ t.fn for t in await mcp_server.list_tools() if t.name == "create_extrusion"
+ )
+
+ result = await tool(
+ {"sketch_name": "Sketch1", "depth": 38.1, "both_directions": True}
+ )
+
+ assert result["extrusion"]["end_condition"] == "MidPlane"
+ assert result["extrusion"]["both_directions"] is True
+ assert "thin" not in result["extrusion"]
+
+
+@pytest.mark.asyncio
+async def test_a_thin_extrusion_reports_its_wall(mcp_server, mock_adapter) -> None:
+ await mock_adapter.create_part()
+ await register_modeling_tools(mcp_server, mock_adapter, {})
+ tool = next(
+ t.fn for t in await mcp_server.list_tools() if t.name == "create_extrusion"
+ )
+
+ result = await tool(
+ {
+ "sketch_name": "Sketch1",
+ "depth": 38.1,
+ "thin_feature": True,
+ "thin_thickness": 6.35,
+ "thin_side": "other_side",
+ }
+ )
+
+ assert result["extrusion"]["thin"] == {"thickness": 6.35, "side": "other_side"}
+ feature = next(iter(mock_adapter._features.values()))
+ assert feature.parameters["thin_side"] == "other_side"
+
+
+@pytest.mark.asyncio
+async def test_extrusion_falls_back_to_the_request_when_the_adapter_is_silent(
+ mcp_server, mock_adapter
+) -> None:
+ mock_adapter.create_extrusion = AsyncMock(
+ return_value=AdapterResult(
+ status=AdapterResultStatus.SUCCESS, data={"name": "Boss-Extrude1"}
+ )
+ )
+ await register_modeling_tools(mcp_server, mock_adapter, {})
+ tool = next(
+ t.fn for t in await mcp_server.list_tools() if t.name == "create_extrusion"
+ )
+
+ result = await tool({"sketch_name": "Sketch1", "depth": 5, "both_directions": True})
+
+ assert result["extrusion"]["end_condition"] == "MidPlane"
+
+
+def test_a_thin_wall_needs_a_thickness() -> None:
+ with pytest.raises(
+ ValueError, match="thin_feature needs a positive thin_thickness"
+ ):
+ CreateExtrusionInput(sketch_name="Sketch1", depth=5, thin_feature=True)
+
+
+def test_a_chamfer_angle_must_be_between_0_and_90() -> None:
+ with pytest.raises(ValueError, match="angle must be between 0 and 90"):
+ AddChamferInput(distance=1.0, angle=90)
+
+
+@pytest.mark.asyncio
+async def test_the_mock_chamfer_records_angle_and_flip(mock_adapter) -> None:
+ await mock_adapter.create_part()
+
+ result = await mock_adapter.add_chamfer(1.0, ["Edge<1>"], 60.0, True)
+ refused = await mock_adapter.add_chamfer(1.0, [], 60.0, True)
+
+ assert result.data.parameters["angle"] == 60.0
+ assert result.data.parameters["flip"] is True
+ assert refused.is_error
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"reference": "Top Plane", "face_point": [0, 0, 0]}, "exactly one of"),
+ ({"face_point": [0, 0]}, "face_point must be"),
+ ({"reference": "Top Plane", "angle": 30}, "needs axis"),
+ ],
+)
+def test_reference_plane_input_refusals(kwargs: dict[str, Any], message: str) -> None:
+ with pytest.raises(ValueError, match=message):
+ CreateReferencePlaneInput(**kwargs)
+
+
+@pytest.mark.asyncio
+async def test_reference_plane_tool_passes_axis_and_face_point(
+ mcp_server, mock_adapter
+) -> None:
+ await mock_adapter.create_part()
+ await register_modeling_tools(mcp_server, mock_adapter, {})
+ tool = next(
+ t.fn
+ for t in await mcp_server.list_tools()
+ if t.name == "create_reference_plane"
+ )
+
+ angled = await tool({"reference": "Top Plane", "angle": 40, "axis": "Axis1"})
+ on_face = await tool({"face_point": [0, 10, 5]})
+
+ assert angled["plane"]["axis"] == "Axis1"
+ assert angled["plane"]["angle"] == 40
+ assert on_face["plane"]["face_point"] == [0, 10, 5]
+ assert on_face["plane"]["reference"] is None
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"entity_a": "Top Plane", "face_a": [0, 0, 0]}, "entity_a or face_a"),
+ ({"face_b": [0, 0]}, "face_b must be"),
+ ],
+)
+def test_add_mate_input_refusals(kwargs: dict[str, Any], message: str) -> None:
+ with pytest.raises(ValueError, match=message):
+ AddMateInput(component_a="a-1", component_b="b-1", **kwargs)
+
+
+@pytest.mark.asyncio
+async def test_add_mate_tool_passes_faces_and_defaults_the_planes(
+ mcp_server, mock_adapter
+) -> None:
+ await register_modeling_tools(mcp_server, mock_adapter, {})
+ tool = next(t.fn for t in await mcp_server.list_tools() if t.name == "add_mate")
+
+ by_face = await tool(
+ {
+ "component_a": "bracket-1",
+ "component_b": "pin-1",
+ "face_a": [0, 76.2, 38.1],
+ "face_b": [0, 4.7, 0],
+ "mate_type": "concentric",
+ }
+ )
+ by_plane = await tool({"component_a": "bracket-1", "component_b": "pin-1"})
+
+ assert by_face["mate"]["entities"] == [
+ "face at [0.0, 76.2, 38.1] mm",
+ "face at [0.0, 4.7, 0.0] mm",
+ ]
+ assert by_plane["mate"]["entities"] == ["Front Plane", "Front Plane"]
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"component": " ", "fixed": True}, "component is required"),
+ ({"component": "a-1"}, "give fixed"),
+ ],
+)
+def test_set_component_state_input_refusals(
+ kwargs: dict[str, Any], message: str
+) -> None:
+ with pytest.raises(ValueError, match=message):
+ SetComponentStateInput(**kwargs)
+
+
+@pytest.mark.asyncio
+async def test_set_component_state_tool_says_what_changed(
+ mcp_server, mock_adapter
+) -> None:
+ await register_modeling_tools(mcp_server, mock_adapter, {})
+ tool = next(
+ t.fn for t in await mcp_server.list_tools() if t.name == "set_component_state"
+ )
+
+ both = await tool({"component": "crank-1", "fixed": False, "flexible": True})
+ fixed = await tool({"component": "crank-1", "fixed": True})
+ rigid = await tool({"component": "crank-1", "flexible": False})
+
+ assert both["message"] == "crank-1 is now floating and flexible"
+ assert both["state"]["before"] == {"fixed": False, "flexible": False}
+ assert fixed["message"] == "crank-1 is now fixed"
+ assert rigid["message"] == "crank-1 is now rigid"
+
+
+@pytest.mark.asyncio
+async def test_the_mock_refuses_an_empty_state_change(mock_adapter) -> None:
+ result = await mock_adapter.set_component_state("crank-1")
+
+ assert result.is_error
diff --git a/tests/solidworks_mcp/tools/test_tool_boundary.py b/tests/solidworks_mcp/tools/test_tool_boundary.py
index 5345e4e9..bf7db27c 100644
--- a/tests/solidworks_mcp/tools/test_tool_boundary.py
+++ b/tests/solidworks_mcp/tools/test_tool_boundary.py
@@ -17,6 +17,7 @@
ToolOperationError,
ToolRegistrar,
describe_validation_error,
+ model_parameters,
normalize_input,
require_success,
tool_boundary,
@@ -493,3 +494,52 @@ async def without_data() -> dict[str, Any]:
assert (await with_data())["data"] == {"error_count": 1}
assert "data" not in await without_data()
+
+
+# ---- plain dicts --------------------------------------------------------------
+
+
+class _Payload(BaseModel):
+ depth: float
+
+
+@pytest.mark.asyncio
+async def test_a_dict_is_validated_as_the_tools_model() -> None:
+ seen: list[Any] = []
+
+ async def body(input_data: _Payload) -> dict[str, Any]:
+ seen.append(input_data)
+ return {"status": "success"}
+
+ wrapped = tool_boundary(body)
+
+ assert (await wrapped({"depth": 2})) == {"status": "success"}
+ assert (await wrapped(input_data={"depth": 3})) == {"status": "success"}
+ assert seen == [_Payload(depth=2), _Payload(depth=3)]
+
+
+@pytest.mark.asyncio
+async def test_a_bad_dict_is_invalid_input() -> None:
+ async def body(input_data: _Payload | None = None) -> dict[str, Any]:
+ return {"status": "success"}
+
+ result = await tool_boundary(body)({"depth": "deep"})
+
+ assert result["error_type"] == "invalid_input"
+ assert "depth" in result["message"]
+
+
+def test_model_parameters_finds_models_alone_or_optional() -> None:
+ async def body(
+ a: _Payload, b: _Payload | None, c: dict[str, Any], d: int | str
+ ) -> dict[str, Any]:
+ return {}
+
+ assert model_parameters(body) == {"a": _Payload, "b": _Payload}
+
+
+def test_model_parameters_is_empty_when_annotations_cannot_resolve() -> None:
+ async def body(input_data: NoSuchModel) -> dict[str, Any]: # noqa: F821
+ return {}
+
+ assert model_parameters(body) == {}