diff --git a/CHANGELOG.md b/CHANGELOG.md index 60948160..5a552a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`reset_circuit_breaker`** (#208). Re-opens operations the server paused + after repeated failures, all of them or one by name; the `circuit_open` + hint now names it. + - **The U-joint demo** (#175, #207). SolidWorks' U-Joint sample, written up as a prompt and built by Claude Code (Opus 5.5, medium effort) in 294 tool calls, is the top demo in the README and on the Demos page, beside @@ -58,6 +62,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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 + breaker at once; every retry did the same. The fallback is gone (it only + re-ran the same COM call). +- **`check_interference` repeated an old result** (#209). Its result was + cached for 60 seconds while components moved; it is no longer cached. + - **The live chamfer and fillet tests treat the faces they name** (#199). They assumed the test block's height ran along Z; it runs along Y, so they chamfered a side face where they meant the face with the hole, and their diff --git a/CLAUDE.md b/CLAUDE.md index 93a0cefd..33b6a3d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,9 +164,13 @@ updated 2026-04-24. ### 4. `Circuit breaker is open for ` -- **Cause:** server-side resilience library trips after N failures in a - window. Subsequent calls fail fast even when the underlying issue is fixed. -- **Fix:** wait for breaker timeout (~30–60s) or restart the server. +- **Cause:** the server's circuit breaker pauses an operation after 5 + failures. Subsequent calls fail fast even when the underlying issue is + fixed. Before #208 a single failure could count many times: the server's + router fell back to the VBA adapter, which re-entered the router. +- **Fix:** call the `reset_circuit_breaker` tool (all operations, or one by + name), or wait for the timeout (`SOLIDWORKS_MCP_CIRCUIT_BREAKER_TIMEOUT`, + default 60 s), or restart the server. ### 5. COM apartment / threading mismatch — FastMCP async workers diff --git a/README.md b/README.md index b69e4866..948e2d7b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![SolidWorks](https://img.shields.io/badge/tested%20on-SolidWorks%202026-red)](https://www.solidworks.com/) [![Coverage](https://codecov.io/gh/andrewbartels1/SolidworksMCP-python/branch/main/graph/badge.svg)](https://codecov.io/gh/andrewbartels1/SolidworksMCP-python) -Python MCP server for SolidWorks automation with 154 tools, plus an optional agent/prompt-testing layer for AI-assisted workflows. +Python MCP server for SolidWorks automation with 155 tools, plus an optional agent/prompt-testing layer for AI-assisted workflows. ## See it work @@ -276,7 +276,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 154 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 155 tools` (the count grows as tools are added). diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 8c0b629f..5e1b2e25 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -58,7 +58,7 @@ Nothing in the install or setup signs in to anything. The only download is the P Open SolidWorks, restart your client as setup told you, and ask *"What model is open in SolidWorks?"*. The server's log (VS Code shows it under the server's output; Claude Desktop writes `%LOCALAPPDATA%\Claude\Logs\mcp-server-solidworks.log`) includes: - `SolidWorks COM interface is available` -- `Registered 154 SolidWorks tools` (the count grows as tools are added) +- `Registered 155 SolidWorks tools` (the count grows as tools are added) - `Connected to SolidWorks` VS Code labels every line the server writes to stderr as `[warning]`. That is where its normal log goes, since stdout carries the MCP messages, so those labels aren't problems. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index cba688dc..4bf9a033 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -30,7 +30,7 @@ Run `uvx solidworks-mcp-python setup` again any time to add a client. To work on Open SolidWorks, restart your client as setup told you, and ask *"What model is open in SolidWorks?"*. The server's log includes: - `SolidWorks COM interface is available` -- `Registered 154 SolidWorks tools` (the count grows as tools are added) +- `Registered 155 SolidWorks tools` (the count grows as tools are added) - `Connected to SolidWorks` If it says `Could not connect to SolidWorks`, SolidWorks wasn't open when the server started. Open it and restart the server from your client. diff --git a/docs/getting-started/vscode-mcp-setup.md b/docs/getting-started/vscode-mcp-setup.md index ebe9869d..ab2bbd49 100644 --- a/docs/getting-started/vscode-mcp-setup.md +++ b/docs/getting-started/vscode-mcp-setup.md @@ -65,7 +65,7 @@ Open SolidWorks, then run **MCP: List Servers** and start (or restart) `solidwor - `SolidWorks COM interface is available` - `Connected to SolidWorks` -- `Discovered 154 tools` (the count grows as tools are added) +- `Discovered 155 tools` (the count grows as tools are added) VS Code labels every line the server writes to stderr as `[warning]`. That is the server's normal log, so those labels aren't problems. diff --git a/docs/index.md b/docs/index.md index ca7118fc..2f03a60a 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 154 SolidWorks tools` (the count grows as tools are added) +- `Registered 155 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 cd794ccc..f8332277 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 154 specialized tools for CAD automation. This page is a navigation map, not a tutorial. +The SolidWorks MCP Server provides 155 specialized tools for CAD automation. This page is a navigation map, not a tutorial. ## Read this first @@ -148,7 +148,7 @@ The SolidWorks MCP Server provides 154 specialized tools for CAD automation. Thi | **Macro Recording** | 7 | High | Workflow capture and optimization | | **Docs Discovery** | 2 | Medium | API and COM discovery workflows | -**Total: 154 Tools** +**Total: 155 Tools** ## Tool Design Principles diff --git a/openspec/changes/circuit-breaker-recovery/.openspec.yaml b/openspec/changes/circuit-breaker-recovery/.openspec.yaml new file mode 100644 index 00000000..8a94d769 --- /dev/null +++ b/openspec/changes/circuit-breaker-recovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-06 diff --git a/openspec/changes/circuit-breaker-recovery/design.md b/openspec/changes/circuit-breaker-recovery/design.md new file mode 100644 index 00000000..0a345aa3 --- /dev/null +++ b/openspec/changes/circuit-breaker-recovery/design.md @@ -0,0 +1,28 @@ +## Context + +`factory.create_adapter` wraps the COM adapter in `CircuitBreakerAdapter` (per-operation buckets, threshold 5, timeout 60 s from `SOLIDWORKS_MCP_CIRCUIT_BREAKER_TIMEOUT`), then in `ConnectionPoolAdapter`, whose `adapter_factory` returns that same wrapped instance, so every pool slot shares one breaker. On an exception the pool retries up to `max_retries`, disconnects the "failed" adapter and asks the factory for a replacement (the same object). No server log survived the failing session. + +## Goals / Non-Goals + +**Goals:** find and fix the lock-out with a test that reproduces it; a reset tool; clearer `circuit_open` errors. +**Non-Goals:** redesigning the pool (a later change may remove it, since one COM apartment can't use several connections). + +## Decisions + +- **Reproduce first.** Build the production stack around a fake adapter that fails in the ways `create_extrusion` can (exception, error result, slow call) and drive the session's sequence. Candidate causes to test: a pool retry recording several failures for one call; the pool disconnecting the shared adapter; a refused call resetting `last_failure_time`; `half_open_calls` never reset. The fix follows what the test shows; the spec's scenarios are the acceptance tests. +- **The reset tool lives beside the other server tools**, uses `require_capability(adapter, "reset_circuit_breakers")`, and is forwarded by the pool. The breaker gains `reset(operation=None) -> list[str]`. +- **Hint text** comes from the error classifier's `circuit_open` rule: it names `reset_circuit_breaker` and `SOLIDWORKS_MCP_CIRCUIT_BREAKER_TIMEOUT` (the seconds left aren't known to the classifier). + +## Risks / Trade-offs + +- [The reset tool lets an agent hammer a failing operation] → the result says what was open and the agent skill says to fix the cause first. + +## Cause (found 2026-11-06) + +The server log of the failing session shows one `create_extrusion` failure, then "Circuit breaker opened after 5 failures" in the same second, with a traceback cycling `server._routed_call` → `IntelligentRouter.execute` → `VbaGeneratorAdapter.create_extrusion` → `server._routed_call`. `_instrument_adapter_methods` replaces methods on the adapter instance, and the server's `VbaGeneratorAdapter` was built on that same instance, so the router's "VBA fallback" re-entered the routed method: each COM failure fell back, failed again and recursed, counting one failure many times. Every later attempt (the sketch was already used, so it really failed) did the same, which is why the breaker looked like it never recovered. Fix: the server no longer passes a VBA route (it only re-ran the same COM call). The pool is not involved, so the open question below is closed: no. + +The same session's stale interference result (#209) was the router's 60-second cache of `check_interference`; it is removed from the cacheable set. + +## Open Questions + +- Whether the lock-out also needs the pool turned off by default; decided after the reproduction. diff --git a/openspec/changes/circuit-breaker-recovery/proposal.md b/openspec/changes/circuit-breaker-recovery/proposal.md new file mode 100644 index 00000000..bdab76a9 --- /dev/null +++ b/openspec/changes/circuit-breaker-recovery/proposal.md @@ -0,0 +1,23 @@ +## Why + +In a measured agent session (2026-11-06, the U-joint demo) `create_extrusion` succeeded once, was refused 12 seconds later with "Circuit breaker is open for create_extrusion", and stayed refused for more than 8 minutes, although the breaker's timeout is 60 seconds. The agent had to rebuild every later part by revolving and cutting, and asked for a reset tool that doesn't exist (#208). + +## What Changes + +- Fix the lock-out: the server's router fell back to a VBA adapter built on the same routed adapter, so one failure re-entered the router and was counted many times (design.md, Cause). The server no longer passes a VBA route. +- `check_interference` is no longer cached by the router (its stale result is part of #209). +- A new tool, `reset_circuit_breaker`: closes every breaker, or one operation's; reports what was open. Read-only towards SolidWorks. +- An open breaker's `hint` names the reset tool and the timeout setting. +- The runbook documents `SOLIDWORKS_MCP_CIRCUIT_BREAKER_TIMEOUT` and the reset tool. + +## Capabilities + +### New Capabilities +- `adapters/circuit-breaker`: when a breaker opens, how it recovers, and how it is reset. + +### Modified Capabilities + +## Impact + +- `adapters/circuit_breaker.py`, `adapters/connection_pool.py`, possibly `adapters/factory.py` (wrapping order); a tool in `tools/analysis.py` or a new `tools/server_admin.py`; the agent skill's errors reference; `CLAUDE.md` runbook item 4. +- Touches adapter wrappers but no COM calls. Tests beside each module, plus a cross-module test of the production stack in `tests/integration/`. diff --git a/openspec/changes/circuit-breaker-recovery/specs/adapters/circuit-breaker/spec.md b/openspec/changes/circuit-breaker-recovery/specs/adapters/circuit-breaker/spec.md new file mode 100644 index 00000000..5cacac7b --- /dev/null +++ b/openspec/changes/circuit-breaker-recovery/specs/adapters/circuit-breaker/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: A success never leaves a breaker open + +A successful call SHALL close its operation's breaker, and the next call SHALL be allowed. + +#### Scenario: Success then a second call +- **WHEN** `create_extrusion` succeeds and is called again at once +- **THEN** the second call reaches the adapter + +### Requirement: An open breaker recovers after its timeout + +An open breaker SHALL let one trial call through once its timeout has passed since the last failure, and close when that call succeeds. + +#### Scenario: Waiting out the timeout +- **WHEN** a breaker opened after repeated failures and the timeout has passed +- **THEN** the next call reaches the adapter, and on success the breaker is closed + +### Requirement: Breakers can be reset by a tool + +A `reset_circuit_breaker` tool SHALL close all breakers, or the one named, and report which were open. + +#### Scenario: Reset one operation +- **WHEN** an agent calls `reset_circuit_breaker` with `operation: create_extrusion` while it is open +- **THEN** the result lists it as reset and the next `create_extrusion` call reaches the adapter + +#### Scenario: No breaker in use +- **WHEN** the adapter has no circuit breaker (mock mode) +- **THEN** the tool succeeds and reports that nothing was open + +### Requirement: An open breaker explains itself + +The error for a refused call SHALL name the operation, and its `hint` SHALL name the reset tool and the timeout setting. + +#### Scenario: Refused call +- **WHEN** a call is refused by an open breaker +- **THEN** `error_type` is `circuit_open` and `hint` names `reset_circuit_breaker` and `SOLIDWORKS_MCP_CIRCUIT_BREAKER_TIMEOUT` diff --git a/openspec/changes/circuit-breaker-recovery/tasks.md b/openspec/changes/circuit-breaker-recovery/tasks.md new file mode 100644 index 00000000..46a30d2d --- /dev/null +++ b/openspec/changes/circuit-breaker-recovery/tasks.md @@ -0,0 +1,15 @@ +## 1. Reproduce + +- [x] 1.1 `tests/integration/test_circuit_breaker_stack.py`: the factory's real wrapping around a fake adapter; reproduce "success, then open" and "never half-opens"; record the cause in design.md + +## 2. Fix and reset + +- [x] 2.1 Fix the cause; the spec's first two requirements pass +- [x] 2.2 `CircuitBreakerAdapter.reset(operation=None)`, forwarded by the pool; tests beside each +- [x] 2.3 `reset_circuit_breaker` tool (description to the standard, Read-only towards SolidWorks); tests +- [x] 2.4 `circuit_open` hint with the wait and the tool name + +## 3. Docs and ship + +- [x] 3.1 `CLAUDE.md` runbook item 4, the agent skill's errors reference, CHANGELOG, queue +- [x] 3.2 Full mock suite, ruff, 100 % coverage; then a paced live check with the user's permission diff --git a/openspec/queue.md b/openspec/queue.md index 01d0b9ad..8e6855e2 100644 --- a/openspec/queue.md +++ b/openspec/queue.md @@ -12,7 +12,7 @@ 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 | not yet written | Next | +| 3 | The circuit breaker locks a tool out after a success and never reopens; a reset tool | #208 | `circuit-breaker-recovery` | Implemented 2026-11-06, in review | | 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` | Plan written 2026-11-06, in review | | 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 | | | 6 | `--selftest` command and a compatibility-report issue template | #179 | not yet written | | diff --git a/src/solidworks_mcp/__init__.py b/src/solidworks_mcp/__init__.py index 088be1b0..5bfa4aea 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 -154 tools for SolidWorks automation with enhanced security, configurability, and modern +155 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 6311112a..6002de1e 100644 --- a/src/solidworks_mcp/adapters/base.py +++ b/src/solidworks_mcp/adapters/base.py @@ -1231,6 +1231,22 @@ async def list_components(self) -> AdapterResult[list[str]]: error="list_components is not implemented by this adapter", ) + async def reset_circuit_breakers( + self, operation: str | None = None + ) -> AdapterResult[dict[str, list[str]]]: + """Re-open operations a circuit breaker paused. + + An adapter without a circuit breaker has nothing paused; the breaker + and the connection pool override this. + + Args: + operation: The operation to reset; ``None`` resets every one. + + Returns: + AdapterResult[dict[str, list[str]]]: ``reset``, the operations that were paused. + """ + return AdapterResult(status=AdapterResultStatus.SUCCESS, data={"reset": []}) + async def get_model_geometry_summary(self) -> AdapterResult[dict[str, Any]]: """Summarise the active part or assembly without changing it. diff --git a/src/solidworks_mcp/adapters/circuit_breaker.py b/src/solidworks_mcp/adapters/circuit_breaker.py index 57fdd5e5..10a56baf 100644 --- a/src/solidworks_mcp/adapters/circuit_breaker.py +++ b/src/solidworks_mcp/adapters/circuit_breaker.py @@ -335,6 +335,53 @@ def _soc_log( # Adapter interface implementation + def reset(self, operation: str | None = None) -> list[str]: + """Close the breaker for one operation, or for all of them. + + Args: + operation: The operation to reset; ``None`` resets every one. + + Returns: + list[str]: The operations that were open or half-open. + """ + if operation is None: + buckets = dict(self._buckets) + else: + buckets = ( + {operation: self._buckets[operation]} + if operation in self._buckets + else {} + ) + was_open = [ + name + for name, bucket in buckets.items() + if bucket.state != CircuitState.CLOSED + ] + for bucket in buckets.values(): + bucket.state = CircuitState.CLOSED + bucket.failure_count = 0 + bucket.half_open_calls = 0 + if operation is None: + self.state = CircuitState.CLOSED + self.failure_count = 0 + self.half_open_calls = 0 + return sorted(was_open) + + async def reset_circuit_breakers( + self, operation: str | None = None + ) -> AdapterResult[dict[str, list[str]]]: + """Reset breakers through the adapter interface (see :meth:`reset`). + + Args: + operation: The operation to reset; ``None`` resets every one. + + Returns: + AdapterResult[dict[str, list[str]]]: ``reset``, the operations that were open. + """ + return AdapterResult( + status=AdapterResultStatus.SUCCESS, data={"reset": self.reset(operation)} + ) + async def connect(self) -> None: """Connect through circuit breaker. diff --git a/src/solidworks_mcp/adapters/connection_pool.py b/src/solidworks_mcp/adapters/connection_pool.py index e7da8f9a..f38e8687 100644 --- a/src/solidworks_mcp/adapters/connection_pool.py +++ b/src/solidworks_mcp/adapters/connection_pool.py @@ -372,6 +372,31 @@ async def _execute_with_pool( error=f"Operation {operation_name} failed after {self.max_retries} retries: {last_error}", ) + async def reset_circuit_breakers( + self, operation: str | None = None + ) -> AdapterResult[dict[str, list[str]]]: + """Reset the circuit breakers of the pooled adapters. + + Every slot may hold the same breaker-wrapped adapter, so each one is + reset once. + + Args: + operation: The operation to reset; ``None`` resets every one. + + Returns: + AdapterResult[dict[str, list[str]]]: ``reset``, the operations that were open. + """ + was_open: set[str] = set() + unique = {id(adapter): adapter for adapter in self.pool} + for adapter in unique.values(): + reset = getattr(adapter, "reset_circuit_breakers", None) + if reset is not None: + result = await reset(operation) + was_open.update((result.data or {}).get("reset", [])) + return AdapterResult( + status=AdapterResultStatus.SUCCESS, data={"reset": sorted(was_open)} + ) + # Adapter interface implementation async def connect(self) -> None: diff --git a/src/solidworks_mcp/adapters/intelligent_router.py b/src/solidworks_mcp/adapters/intelligent_router.py index 2d5cb3fb..4961b6af 100644 --- a/src/solidworks_mcp/adapters/intelligent_router.py +++ b/src/solidworks_mcp/adapters/intelligent_router.py @@ -78,7 +78,8 @@ def __init__( # "calculate_mass_properties", "get_material_properties", "analyze_geometry", - "check_interference", + # "check_interference": changes as components are mated or moved; + # a cached answer ignored the changes (#209). # Drawing analysis operations "analyze_drawing_comprehensive", "analyze_drawing_dimensions", diff --git a/src/solidworks_mcp/server.py b/src/solidworks_mcp/server.py index 29157b51..6e5e7e47 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 154 tools for comprehensive SolidWorks automation with configurable +This server provides 155 tools for comprehensive SolidWorks automation with configurable deployment (local/remote) and security options. """ @@ -27,7 +27,6 @@ from .adapters.base import AdapterResult from .adapters.complexity_analyzer import ComplexityAnalyzer from .adapters.intelligent_router import IntelligentRouter -from .adapters.vba_adapter import VbaGeneratorAdapter from .agents.history_db import insert_tool_event from .cache.response_cache import CachePolicy, ResponseCache from .config import DeploymentMode, SolidWorksMCPConfig, load_config @@ -113,7 +112,6 @@ def __init__(self, config: SolidWorksMCPConfig): self.adapter: Any | None = None self.agent: Any | None = None self._router: IntelligentRouter | None = None - self._vba_adapter: VbaGeneratorAdapter | None = None @staticmethod def _env_truthy(value: str | None) -> bool: @@ -186,7 +184,6 @@ def _configure_runtime_services(self) -> None: ) ) self._router = IntelligentRouter(analyzer=analyzer, cache=cache) - self._vba_adapter = VbaGeneratorAdapter(backing_adapter=self.adapter) if self.config.enable_intelligent_routing: self._instrument_adapter_methods() @@ -254,17 +251,10 @@ def _instrument_adapter_methods(self) -> None: if original_operation is None or not callable(original_operation): continue - vba_operation = None - if self._vba_adapter is not None: - candidate = getattr(self._vba_adapter, operation_name, None) - if callable(candidate): - vba_operation = candidate - async def _routed_call( *call_args: Any, _operation_name: str = operation_name, _com_callable: Any = original_operation, - _vba_callable: Any = vba_operation, **call_kwargs: Any, ) -> AdapterResult[Any]: """Build internal routed call. @@ -273,7 +263,6 @@ async def _routed_call( *call_args (Any): Additional positional arguments forwarded to the call. _operation_name (str): The operation name value. Defaults to operation_name. _com_callable (Any): The com callable value. Defaults to original_operation. - _vba_callable (Any): The vba callable value. Defaults to vba_operation. **call_kwargs (Any): Additional keyword arguments forwarded to the call. Returns: @@ -301,7 +290,11 @@ async def _routed_call( call_args=call_args, call_kwargs=call_kwargs, com_operation=_com_callable, - vba_operation=_vba_callable, + # No VBA fallback: the VBA adapter calls this same adapter, + # so a failed call re-entered this wrapper and recursed, + # counting one failure many times and opening the circuit + # breaker at once (#208). + vba_operation=None, cache_ttl_seconds=self.config.response_cache_ttl_seconds, ) return result 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 89b8be5f..908aafbb 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 @@ -13,7 +13,7 @@ and a `hint`; some carry `data` with details. Act on `error_type`: | `not_found` | A name or path is wrong. List what exists (`list_features`, `list_components`, `list_drawing_views`, `list_open_documents`) and retry with the exact name. | | `feature_not_built` | SolidWorks accepted the call but built nothing: the sketch isn't closed, the cut misses the body, the depth is wrong. Change the input, don't repeat the call. | | `not_supported` | The tool can't run yet. Use the one its description names. | -| `circuit_open` | Calls are paused after repeated failures. Wait about a minute, fix the cause, then retry. | +| `circuit_open` | Calls are paused after repeated failures. Fix the cause (for example, close and draw the sketch again), then call `reset_circuit_breaker` with the operation, or wait out the timeout, and retry. | | `solidworks_unavailable` | SolidWorks closed or crashed. Tell the user; they restart it. | | `com_member_not_found`, `com_type_mismatch`, `com_wrong_thread` | A server problem, not a modelling one. Stop and tell the user the tool, the arguments and the message. | diff --git a/src/solidworks_mcp/tools/__init__.py b/src/solidworks_mcp/tools/__init__.py index 64e0f28d..da467c3d 100644 --- a/src/solidworks_mcp/tools/__init__.py +++ b/src/solidworks_mcp/tools/__init__.py @@ -19,6 +19,7 @@ from .file_management import register_file_management_tools from .macro_recording import register_macro_recording_tools from .modeling import register_modeling_tools +from .server_admin import register_server_admin_tools from .sketching import register_sketching_tools from .template_management import register_template_management_tools from .vba_generation import register_vba_generation_tools @@ -52,6 +53,7 @@ async def register_tools(mcp: FastMCP, adapter: Any, config: Any) -> int: await register_template_management_tools(mcp, adapter, config) await register_macro_recording_tools(mcp, adapter, config) await register_docs_discovery_tools(mcp, adapter, config) + await register_server_admin_tools(mcp, adapter, config) tool_count = len(await mcp.list_tools()) diff --git a/src/solidworks_mcp/tools/error_classifier.py b/src/solidworks_mcp/tools/error_classifier.py index 66f3e6c5..bf2ce1ad 100644 --- a/src/solidworks_mcp/tools/error_classifier.py +++ b/src/solidworks_mcp/tools/error_classifier.py @@ -91,7 +91,9 @@ def matches(self, message: str, hresult: int | None) -> bool: error_type="circuit_open", hint=( "Calls to this operation are paused after repeated failures. Fix " - "the underlying error, then retry after the breaker timeout." + "the underlying error, then call `reset_circuit_breaker` (or wait " + "for the breaker timeout, SOLIDWORKS_MCP_CIRCUIT_BREAKER_TIMEOUT " + "seconds) and retry." ), patterns=(r"circuit breaker is (open|half[_-]open)",), ), diff --git a/src/solidworks_mcp/tools/export.py b/src/solidworks_mcp/tools/export.py index e0efbfe6..56b3b5e9 100644 --- a/src/solidworks_mcp/tools/export.py +++ b/src/solidworks_mcp/tools/export.py @@ -358,7 +358,9 @@ def _file_input(input_data: Any, format_type: str) -> Any: leaves it out. Models, including the per-format aliases, pass through. """ if isinstance(input_data, dict): - return normalize_input({"format_type": format_type, **input_data}, ExportFileInput) + return normalize_input( + {"format_type": format_type, **input_data}, ExportFileInput + ) return input_data diff --git a/src/solidworks_mcp/tools/server_admin.py b/src/solidworks_mcp/tools/server_admin.py new file mode 100644 index 00000000..f363a6a7 --- /dev/null +++ b/src/solidworks_mcp/tools/server_admin.py @@ -0,0 +1,77 @@ +"""Tools that look after the server itself rather than a SolidWorks model. + +Today one tool: ``reset_circuit_breaker``, which re-opens operations the +server's circuit breaker has paused after repeated failures (#208). +""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP +from pydantic import Field + +from ..adapters.base import SolidWorksAdapter +from .input_compat import CompatInput +from .tool_boundary import normalize_input, require_success, with_tool_boundary + + +class ResetCircuitBreakerInput(CompatInput): + """Input schema for resetting circuit breakers. + + Attributes: + operation (str | None): The operation to reset, e.g. ``create_extrusion``. + """ + + operation: str | None = Field( + default=None, + description=( + "The operation to reset, as named in the circuit_open error " + "(e.g. create_extrusion); leave it out to reset every operation" + ), + ) + + +async def register_server_admin_tools( + mcp: FastMCP, adapter: SolidWorksAdapter, config: dict[str, Any] +) -> int: + """Register the server-administration tools with FastMCP. + + Args: + mcp (FastMCP): The MCP server. + adapter (SolidWorksAdapter): Adapter used by every tool. + config (dict[str, Any]): Server configuration (unused). + + Returns: + int: Number of tools registered. + """ + mcp = with_tool_boundary(mcp) + + @mcp.tool() + async def reset_circuit_breaker( + input_data: ResetCircuitBreakerInput | None = None, + ) -> dict[str, Any]: + """Let calls through again to operations the server paused after repeated failures. + + Use it when a tool returns `error_type: circuit_open` and you have fixed what + made the calls fail (a missing sketch, a wrong reference): without a reset, the + operation stays paused until its timeout. Resetting does not fix the cause; a + call that fails again counts towards a new pause. Changes: only the server's + own state; no SolidWorks document is touched. Returns: `data` with `reset`, the + operations that were paused (empty when none were). + + Args: + input_data: Optional: `operation` resets only that operation; leave it out + to reset every one. + + Returns: + dict[str, Any]: The operations that were reset. + """ + input_data = normalize_input(input_data, ResetCircuitBreakerInput) + result = await adapter.reset_circuit_breakers(input_data.operation) + require_success(result, context="Failed to reset the circuit breaker") + names = list((result.data or {}).get("reset", [])) + message = f"Reset {', '.join(names)}" if names else "No operation was paused" + return {"status": "success", "message": message, "data": {"reset": names}} + + return 1 diff --git a/src/solidworks_mcp/tools/tool_hints.py b/src/solidworks_mcp/tools/tool_hints.py index 67cf422a..cb8fd334 100644 --- a/src/solidworks_mcp/tools/tool_hints.py +++ b/src/solidworks_mcp/tools/tool_hints.py @@ -115,6 +115,8 @@ def _discards(title: str) -> ToolHints: # docs_discovery "discover_solidworks_docs": _sets("Rebuild SolidWorks API Index"), "search_solidworks_api_help": _reads("Search SolidWorks API"), + # server_admin + "reset_circuit_breaker": _sets("Reset Circuit Breaker"), "lookup_api_method": _reads("Look Up API Method"), "lookup_api_interface": _reads("Look Up API Interface"), "find_related_api_members": _reads("Find Related API Members"), diff --git a/tests/integration/test_circuit_breaker_stack.py b/tests/integration/test_circuit_breaker_stack.py new file mode 100644 index 00000000..c12d6923 --- /dev/null +++ b/tests/integration/test_circuit_breaker_stack.py @@ -0,0 +1,127 @@ +"""The circuit breaker inside the server's routing, as in production (#208). + +In a measured agent session one failed ``create_extrusion`` opened the +breaker at once: the server's router fell back to the VBA adapter, whose +methods call the same (routed) adapter, so the failure re-entered the router +and was counted again and again. These tests drive the server's real +instrumentation around a real ``CircuitBreakerAdapter``. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from solidworks_mcp.adapters.base import ( + AdapterResult, + AdapterResultStatus, + ExtrusionParameters, +) +from solidworks_mcp.adapters.circuit_breaker import CircuitBreakerAdapter, CircuitState +from solidworks_mcp.adapters.mock_adapter import MockSolidWorksAdapter +from solidworks_mcp.config import SolidWorksMCPConfig +from solidworks_mcp.server import SolidWorksMCPServer + + +class _Extruder(MockSolidWorksAdapter): + """A mock whose extrusion raises (as the COM adapter does) until told not to.""" + + def __init__(self) -> None: + super().__init__({}) + self.fail = True + self.calls = 0 + + async def create_extrusion(self, params: Any) -> AdapterResult[Any]: + self.calls += 1 + if self.fail: + raise RuntimeError("Failed to create extrusion feature") + return AdapterResult( + status=AdapterResultStatus.SUCCESS, data={"name": "Boss-Extrude1"} + ) + + +#: What the create_extrusion tool passes the adapter. +_PARAMS = ExtrusionParameters(depth=10) + + +def _server(breaker: CircuitBreakerAdapter) -> SolidWorksMCPServer: + cfg = SolidWorksMCPConfig(mock_solidworks=True, enable_intelligent_routing=True) + server = SolidWorksMCPServer(cfg) + server.adapter = breaker + server._configure_runtime_services() + return server + + +@pytest.mark.asyncio +async def test_one_failure_counts_once_and_does_not_open_the_breaker() -> None: + fake = _Extruder() + breaker = CircuitBreakerAdapter( + adapter=fake, failure_threshold=5, recovery_timeout=60 + ) + server = _server(breaker) + + result = await server.adapter.create_extrusion(_PARAMS) + + bucket = breaker._get_bucket("create_extrusion") + assert result.is_error + assert fake.calls == 1 + assert bucket.failure_count == 1 + assert bucket.state == CircuitState.CLOSED + + +@pytest.mark.asyncio +async def test_a_success_after_failures_leaves_the_next_call_allowed() -> None: + fake = _Extruder() + breaker = CircuitBreakerAdapter( + adapter=fake, failure_threshold=5, recovery_timeout=60 + ) + server = _server(breaker) + for _ in range(4): + await server.adapter.create_extrusion(_PARAMS) + + fake.fail = False + first = await server.adapter.create_extrusion(_PARAMS) + second = await server.adapter.create_extrusion(_PARAMS) + + assert first.is_success and second.is_success + assert breaker._get_bucket("create_extrusion").state == CircuitState.CLOSED + + +@pytest.mark.asyncio +async def test_an_open_breaker_recovers_after_its_timeout() -> None: + fake = _Extruder() + breaker = CircuitBreakerAdapter( + adapter=fake, failure_threshold=2, recovery_timeout=60 + ) + server = _server(breaker) + for _ in range(2): + await server.adapter.create_extrusion(_PARAMS) + bucket = breaker._get_bucket("create_extrusion") + assert bucket.state == CircuitState.OPEN + + bucket.last_failure_time -= 61 # the timeout has passed + fake.fail = False + result = await server.adapter.create_extrusion(_PARAMS) + + assert result.is_success + assert bucket.state == CircuitState.CLOSED + + +@pytest.mark.asyncio +async def test_reset_reopens_a_paused_operation_at_once() -> None: + fake = _Extruder() + breaker = CircuitBreakerAdapter( + adapter=fake, failure_threshold=1, recovery_timeout=60 + ) + server = _server(breaker) + await server.adapter.create_extrusion(_PARAMS) + refused = await server.adapter.create_extrusion(_PARAMS) + assert "Circuit breaker is open" in str(refused.error) + + reset = await breaker.reset_circuit_breakers("create_extrusion") + fake.fail = False + result = await server.adapter.create_extrusion(_PARAMS) + + assert reset.data == {"reset": ["create_extrusion"]} + assert result.is_success diff --git a/tests/integration/test_tool_descriptions.py b/tests/integration/test_tool_descriptions.py index b4d9b595..62b5fc6e 100644 --- a/tests/integration/test_tool_descriptions.py +++ b/tests/integration/test_tool_descriptions.py @@ -28,6 +28,7 @@ "file_management", "macro_recording", "modeling", + "server_admin", "sketching", "template_management", "vba_generation", diff --git a/tests/live/test_live_sw_circuit_breaker.py b/tests/live/test_live_sw_circuit_breaker.py new file mode 100644 index 00000000..5ff9dda5 --- /dev/null +++ b/tests/live/test_live_sw_circuit_breaker.py @@ -0,0 +1,78 @@ +"""The circuit breaker inside the server's routing, on real SolidWorks (#208). + +A failed ``create_extrusion`` used to be counted many times (the router's +VBA fallback re-entered the routed method) and paused extrusion at once. +Here a real extrusion fails (no sketch to extrude), and the test checks it +counts once, that the next good extrusion goes through, and that +``reset_circuit_breaker`` re-opens a paused operation. + +Run it with:: + + powershell -ExecutionPolicy Bypass -File tests\\scripts\\live_demo.ps1 ` + -Test tests/live/test_live_sw_circuit_breaker.py + +Collected only with ``SOLIDWORKS_MCP_RUN_REAL_INTEGRATION=1`` on Windows (see +``tests/live/conftest.py``). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from solidworks_mcp.adapters.base import ExtrusionParameters +from solidworks_mcp.adapters.circuit_breaker import CircuitBreakerAdapter, CircuitState +from solidworks_mcp.config import SolidWorksMCPConfig +from solidworks_mcp.server import SolidWorksMCPServer +from solidworks_mcp.tools.server_admin import register_server_admin_tools +from tests.live.evidence import evidence + + +@pytest.mark.asyncio +async def test_a_failed_extrusion_counts_once_and_reset_reopens(scratch: Any) -> None: + """One real failure counts once; a good extrusion follows; reset re-opens.""" + breaker = CircuitBreakerAdapter(adapter=scratch.adapter, failure_threshold=5) + server = SolidWorksMCPServer( + SolidWorksMCPConfig(mock_solidworks=False, enable_intelligent_routing=True) + ) + server.adapter = breaker + server._configure_runtime_services() + adapter = server.adapter + bucket = breaker._get_bucket("create_extrusion") + + assert (await adapter.create_part()).is_success + failed = await adapter.create_extrusion(ExtrusionParameters(depth=10)) + evidence( + "an extrusion with no sketch fails, and counts once", + { + "error": failed.error, + "failures": bucket.failure_count, + "state": bucket.state.value, + }, + ) + assert failed.is_error + assert bucket.failure_count == 1 and bucket.state == CircuitState.CLOSED + + assert (await adapter.create_sketch("Top")).is_success + assert (await adapter.add_rectangle(-20, -10, 20, 10)).is_success + assert (await adapter.exit_sketch()).is_success + made = await adapter.create_extrusion(ExtrusionParameters(depth=10)) + evidence( + "the next extrusion goes through", + {"success": made.is_success, "failures": bucket.failure_count}, + ) + assert made.is_success and bucket.failure_count == 0 + + bucket.state = CircuitState.OPEN # as after five real failures + from fastmcp import FastMCP + + mcp = FastMCP("breaker-check") + await register_server_admin_tools(mcp, breaker, {}) + reset = next( + t.fn for t in await mcp.list_tools() if t.name == "reset_circuit_breaker" + ) + result = await reset({"operation": "create_extrusion"}) + evidence("reset_circuit_breaker re-opens it", result) + assert result["data"] == {"reset": ["create_extrusion"]} + assert bucket.state == CircuitState.CLOSED diff --git a/tests/solidworks_mcp/adapters/test_circuit_breaker.py b/tests/solidworks_mcp/adapters/test_circuit_breaker.py index 1b20fb81..6049823f 100644 --- a/tests/solidworks_mcp/adapters/test_circuit_breaker.py +++ b/tests/solidworks_mcp/adapters/test_circuit_breaker.py @@ -1077,3 +1077,41 @@ async def test_circuit_breaker_forwards_explicit_templates() -> None: assert part.data.properties["template"] == "C:/t/Part.prtdot" assert assembly.data.properties["template"] == "C:/t/Asm.asmdot" assert drawing.data.properties["template"] == "C:/t/Drw.drwdot" + + +class TestReset: + """``reset`` and ``reset_circuit_breakers`` (#208).""" + + def test_reset_all_closes_buckets_and_the_default_state(self) -> None: + breaker = CircuitBreakerAdapter() + breaker._get_bucket("a").state = CircuitState.OPEN + breaker._get_bucket("b").state = CircuitState.HALF_OPEN + breaker._get_bucket("c") + breaker.state = CircuitState.OPEN + breaker.failure_count = 9 + + assert breaker.reset() == ["a", "b"] + assert {b.state for b in breaker._buckets.values()} == {CircuitState.CLOSED} + assert breaker.state == CircuitState.CLOSED and breaker.failure_count == 0 + + def test_reset_one_leaves_the_others(self) -> None: + breaker = CircuitBreakerAdapter() + breaker._get_bucket("a").state = CircuitState.OPEN + breaker._get_bucket("b").state = CircuitState.OPEN + breaker.state = CircuitState.OPEN + + assert breaker.reset("a") == ["a"] + assert breaker._get_bucket("b").state == CircuitState.OPEN + assert breaker.state == CircuitState.OPEN + + def test_reset_an_unknown_operation_is_a_no_op(self) -> None: + assert CircuitBreakerAdapter().reset("never_called") == [] + + @pytest.mark.asyncio + async def test_reset_circuit_breakers_reports_through_a_result(self) -> None: + breaker = CircuitBreakerAdapter() + breaker._get_bucket("a").state = CircuitState.OPEN + + result = await breaker.reset_circuit_breakers() + + assert result.is_success and result.data == {"reset": ["a"]} diff --git a/tests/solidworks_mcp/adapters/test_connection_pool.py b/tests/solidworks_mcp/adapters/test_connection_pool.py index 1ad6593b..819dfafc 100644 --- a/tests/solidworks_mcp/adapters/test_connection_pool.py +++ b/tests/solidworks_mcp/adapters/test_connection_pool.py @@ -1802,3 +1802,19 @@ def test_connection_pool_forwards_every_circuit_breaker_passthrough() -> None: missing = sorted(circuit_forwards - set(vars(ConnectionPoolAdapter))) assert missing == [] + + +@pytest.mark.asyncio +async def test_reset_circuit_breakers_resets_a_shared_breaker_once() -> None: + """Every slot holds the same breaker; it is reset once (#208).""" + from solidworks_mcp.adapters.circuit_breaker import CircuitState + + breaker = CircuitBreakerAdapter() + breaker._get_bucket("create_extrusion").state = CircuitState.OPEN + pool = ConnectionPoolAdapter(adapter_factory=lambda: breaker, pool_size=3) + pool.pool = [breaker, breaker, MockSolidWorksAdapter({})] + + result = await pool.reset_circuit_breakers() + + assert result.data == {"reset": ["create_extrusion"]} + assert breaker._get_bucket("create_extrusion").state == CircuitState.CLOSED diff --git a/tests/solidworks_mcp/adapters/test_intelligent_router.py b/tests/solidworks_mcp/adapters/test_intelligent_router.py new file mode 100644 index 00000000..1099967b --- /dev/null +++ b/tests/solidworks_mcp/adapters/test_intelligent_router.py @@ -0,0 +1,40 @@ +"""``IntelligentRouter`` unit behaviour (the runtime-feature tests cover the rest).""" + +from __future__ import annotations + +import pytest + +from solidworks_mcp.adapters.complexity_analyzer import ComplexityAnalyzer +from solidworks_mcp.adapters.intelligent_router import IntelligentRouter +from solidworks_mcp.cache.response_cache import CachePolicy, ResponseCache + + +@pytest.mark.asyncio +async def test_an_exception_becomes_an_error_result() -> None: + router = IntelligentRouter( + analyzer=ComplexityAnalyzer(), cache=ResponseCache(CachePolicy(enabled=False)) + ) + + async def _raises(payload: object) -> None: + raise RuntimeError("COM went away") + + result, route = await router.execute( + operation="create_extrusion", + payload={}, + call_args=({},), + call_kwargs={}, + com_operation=_raises, + vba_operation=None, + ) + + assert result.is_error and "COM went away" in str(result.error) + assert route.route == "com-error" + + +def test_interference_is_not_cached() -> None: + """Interference changes as parts are mated; a cached answer went stale (#209).""" + router = IntelligentRouter( + analyzer=ComplexityAnalyzer(), cache=ResponseCache(CachePolicy(enabled=True)) + ) + + assert "check_interference" not in router._cacheable_operations diff --git a/tests/solidworks_mcp/adapters/test_vba_adapter.py b/tests/solidworks_mcp/adapters/test_vba_adapter.py index 9992eee4..81045b7d 100644 --- a/tests/solidworks_mcp/adapters/test_vba_adapter.py +++ b/tests/solidworks_mcp/adapters/test_vba_adapter.py @@ -10,6 +10,7 @@ from solidworks_mcp.adapters.base import ( AdapterResult, AdapterResultStatus, + RevolveParameters, SweepParameters, ) from solidworks_mcp.adapters.vba_adapter import VbaGeneratorAdapter @@ -147,3 +148,27 @@ async def test_vba_adapter_create_loft_calls_backing(monkeypatch): result = await adapter.create_loft(loft_params) assert result.is_success mock_backing.create_loft.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_revolve_runs_on_the_backing_adapter_with_vba_metadata() -> None: + backing = SimpleNamespace( + create_revolve=AsyncMock( + return_value=AdapterResult( + status=AdapterResultStatus.SUCCESS, data={"ok": 1} + ) + ) + ) + adapter = VbaGeneratorAdapter(backing_adapter=backing) + + result = await adapter.create_revolve(RevolveParameters(angle=360.0)) + + assert result.is_success + assert result.metadata["route"] == "vba" + assert "Sub CreateRevolve()" in result.metadata["vba_code"] + + +def test_unknown_attributes_come_from_the_backing_adapter() -> None: + adapter = VbaGeneratorAdapter(backing_adapter=SimpleNamespace(pool_size=3)) + + assert adapter.pool_size == 3 diff --git a/tests/solidworks_mcp/tools/test_export.py b/tests/solidworks_mcp/tools/test_export.py index e41755ec..a837db50 100644 --- a/tests/solidworks_mcp/tools/test_export.py +++ b/tests/solidworks_mcp/tools/test_export.py @@ -740,7 +740,9 @@ async def test_export_tools_fallback_and_aliases(mcp_server, mock_config): @pytest.mark.asyncio @pytest.mark.parametrize("adapter_cls", [_ExportFallbackAdapter, _ExportNativeAdapter]) @pytest.mark.parametrize("fmt", ["step", "iges", "stl", "pdf", "dwg"]) -async def test_export_tools_accept_a_plain_dict(mcp_server, mock_config, adapter_cls, fmt): +async def test_export_tools_accept_a_plain_dict( + mcp_server, mock_config, adapter_cls, fmt +): """A dict argument, as a script calling the tool function passes, works. Over MCP the arguments arrive as ``ExportFileInput``; called directly diff --git a/tests/solidworks_mcp/tools/test_server_admin.py b/tests/solidworks_mcp/tools/test_server_admin.py new file mode 100644 index 00000000..83bc8238 --- /dev/null +++ b/tests/solidworks_mcp/tools/test_server_admin.py @@ -0,0 +1,83 @@ +"""``reset_circuit_breaker`` (spec: adapters/circuit-breaker, the reset requirement).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from solidworks_mcp.adapters.base import AdapterResult, AdapterResultStatus +from solidworks_mcp.adapters.circuit_breaker import CircuitBreakerAdapter, CircuitState +from solidworks_mcp.tools.server_admin import ( + ResetCircuitBreakerInput, + register_server_admin_tools, +) + + +async def _reset_tool(mcp_server: Any, adapter: Any) -> Any: + await register_server_admin_tools(mcp_server, adapter, {}) + return next( + t.fn for t in await mcp_server.list_tools() if t.name == "reset_circuit_breaker" + ) + + +def _breaker_with_open(*operations: str) -> CircuitBreakerAdapter: + breaker = CircuitBreakerAdapter(failure_threshold=1) + for name in operations: + breaker._get_bucket(name).state = CircuitState.OPEN + return breaker + + +@pytest.mark.asyncio +async def test_registers_one_tool(mcp_server, mock_adapter) -> None: + assert await register_server_admin_tools(mcp_server, mock_adapter, {}) == 1 + + +@pytest.mark.asyncio +async def test_without_a_breaker_nothing_was_paused(mcp_server, mock_adapter) -> None: + tool = await _reset_tool(mcp_server, mock_adapter) + + result = await tool() + + assert result["status"] == "success" + assert result["data"] == {"reset": []} + assert result["message"] == "No operation was paused" + + +@pytest.mark.asyncio +async def test_resets_every_open_operation(mcp_server) -> None: + breaker = _breaker_with_open("create_extrusion", "add_mate") + tool = await _reset_tool(mcp_server, breaker) + + result = await tool() + + assert result["data"] == {"reset": ["add_mate", "create_extrusion"]} + assert result["message"] == "Reset add_mate, create_extrusion" + assert breaker._get_bucket("add_mate").state == CircuitState.CLOSED + + +@pytest.mark.asyncio +async def test_resets_only_the_named_operation(mcp_server) -> None: + breaker = _breaker_with_open("create_extrusion", "add_mate") + tool = await _reset_tool(mcp_server, breaker) + + result = await tool(ResetCircuitBreakerInput(operation="create_extrusion")) + + assert result["data"] == {"reset": ["create_extrusion"]} + assert breaker._get_bucket("add_mate").state == CircuitState.OPEN + + +@pytest.mark.asyncio +async def test_a_failed_reset_is_an_error(mcp_server) -> None: + class _Broken: + async def reset_circuit_breakers( + self, operation: str | None + ) -> AdapterResult[Any]: + return AdapterResult(status=AdapterResultStatus.ERROR, error="pool closed") + + tool = await _reset_tool(mcp_server, _Broken()) + + result = await tool() + + assert result["status"] == "error" + assert "Failed to reset the circuit breaker" in result["message"]