Skip to content

Commit 3589113

Browse files
committed
fix(mcp): keep anticipated tool failures readable under mcp 2.1
The mcp 2.1 SDK narrowed which exceptions reach the client: only a ToolError keeps its message, and everything else is treated as a crash and reported as a bare "Error executing tool <name>". The server raised ValueError/RuntimeError for failures it fully anticipated, so all of them were masked -- an agent asking for a mistyped REQ_TYPO got "Error executing tool get_requirement" and no way to see the typo. Raise ToolError at the eight anticipated-failure sites instead, which also drops them from ERROR-with-traceback to INFO in the server log. Reverting only the source change fails eight tests, but main caught this with one: the others asserted is_error without asserting that the reason survives. They now assert the message too, so a future SDK bump cannot re-mask them silently. Refs reqstool/.github#111 Signed-off-by: Jimisola Laursen <jimisola@jimisola.com>
1 parent 27310c4 commit 3589113

3 files changed

Lines changed: 30 additions & 12 deletions

File tree

src/reqstool/mcp/server.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from reqstool_python_decorators.decorators.decorators import Requirements
88

9+
from reqstool.common.exceptions import SnapshotReloadError
910
from reqstool.common.project_session import ProjectSession
1011
from reqstool.common.enrichment.enricher import BUILT_IN_PRESETS, enrich_text
1112
from reqstool.common.queries.details import (
@@ -32,6 +33,11 @@ def start_server( # noqa: C901
3233
) -> None:
3334
try:
3435
from mcp.server.mcpserver import MCPServer
36+
37+
# SDK 2.1: only a ToolError's message reaches the model. Any other exception is
38+
# treated as a crash and reported as a bare "Error executing tool <name>", so
39+
# every anticipated failure below raises ToolError to keep its text.
40+
from mcp.server.mcpserver.exceptions import ToolError
3541
except ImportError as exc:
3642
raise ImportError("MCP server requires extra dependencies: pip install 'mcp>=2.0'") from exc
3743

@@ -51,10 +57,13 @@ def _repo() -> RequirementsRepository:
5157
Every tool must resolve the repository through this. Binding it once at startup is
5258
what let long-lived servers serve a snapshot from before the last build (#437).
5359
"""
54-
session.ensure_fresh()
60+
try:
61+
session.ensure_fresh()
62+
except SnapshotReloadError as exc:
63+
raise ToolError(str(exc)) from exc
5564
repo = session.repo
5665
if repo is None:
57-
raise RuntimeError(f"reqstool project is not loaded: {session.error}")
66+
raise ToolError(f"reqstool project is not loaded: {session.error}")
5867
return repo
5968

6069
@Requirements("MCP_0008")
@@ -87,7 +96,7 @@ async def get_requirement(id: str) -> dict:
8796
"""Get full details for a requirement by ID (e.g. REQ_010)."""
8897
result = get_requirement_details(id, _repo(), session.urn_source_paths)
8998
if result is None:
90-
raise ValueError(f"Requirement {id!r} not found")
99+
raise ToolError(f"Requirement {id!r} not found")
91100
return result
92101

93102
@mcp.tool()
@@ -109,7 +118,7 @@ async def get_svc(id: str) -> dict:
109118
"""Get full details for an SVC by ID (e.g. SVC_010)."""
110119
result = get_svc_details(id, _repo(), session.urn_source_paths)
111120
if result is None:
112-
raise ValueError(f"SVC {id!r} not found")
121+
raise ToolError(f"SVC {id!r} not found")
113122
return result
114123

115124
@mcp.tool()
@@ -122,7 +131,7 @@ async def get_mvr(id: str) -> dict:
122131
"""Get full details for an MVR by ID."""
123132
result = get_mvr_details(id, _repo(), session.urn_source_paths)
124133
if result is None:
125-
raise ValueError(f"MVR {id!r} not found")
134+
raise ToolError(f"MVR {id!r} not found")
126135
return result
127136

128137
@mcp.tool()
@@ -144,7 +153,7 @@ async def refresh() -> dict:
144153
unconditionally — after a build, for instance — or to confirm what is being served."""
145154
session.build()
146155
if not session.ready:
147-
raise RuntimeError(f"Failed to reload reqstool project: {session.error}")
156+
raise ToolError(f"Failed to reload reqstool project: {session.error}")
148157
return _snapshot_info()
149158

150159
@mcp.tool()
@@ -154,7 +163,7 @@ async def get_requirement_status(id: str, include_post_build: bool = False) -> d
154163
`status --with-post-tests` (scopes to post-build-phase SVCs too)."""
155164
result = _get_requirement_status(id, _repo(), include_post_build=include_post_build)
156165
if result is None:
157-
raise ValueError(f"Requirement {id!r} not found")
166+
raise ToolError(f"Requirement {id!r} not found")
158167
return result
159168

160169
@mcp.tool()
@@ -184,7 +193,7 @@ async def get_urn_details(urn: str) -> dict:
184193
"""Get details for a URN: variant, title, location, file paths, and entity counts."""
185194
result = _get_urn_details(urn, _repo(), session.urn_source_paths)
186195
if result is None:
187-
raise ValueError(f"URN {urn!r} not found")
196+
raise ToolError(f"URN {urn!r} not found")
188197
return result
189198

190199
@mcp.tool()
@@ -198,7 +207,7 @@ async def enrich_document(content: str, preset: str) -> str:
198207
openspec:proposal, openspec:tasks
199208
"""
200209
if preset not in BUILT_IN_PRESETS:
201-
raise ValueError(f"Unknown preset {preset!r}. Valid: {sorted(BUILT_IN_PRESETS)}")
210+
raise ToolError(f"Unknown preset {preset!r}. Valid: {sorted(BUILT_IN_PRESETS)}")
202211
config = BUILT_IN_PRESETS[preset]
203212
repo = _repo()
204213
return enrich_text(content, repo.get_all_requirements(), repo.get_all_svcs(), repo.get_all_mvrs(), config)

tests/integration/reqstool/mcp/test_mcp_integration.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ async def test_get_requirement_known(mcp_session):
7777
async def test_get_requirement_not_found(mcp_session):
7878
result = await mcp_session.call_tool("get_requirement", {"id": "REQ_NONEXISTENT"})
7979
assert result.is_error
80+
# The reason has to reach the model, not just the failure: an SDK that masks it
81+
# leaves a bare "Error executing tool get_requirement" and no way to spot a typo.
82+
assert "REQ_NONEXISTENT" in str(result.content)
8083

8184

8285
# ---------------------------------------------------------------------------
@@ -114,6 +117,7 @@ async def test_get_svc_known(mcp_session):
114117
async def test_get_svc_not_found(mcp_session):
115118
result = await mcp_session.call_tool("get_svc", {"id": "SVC_NONEXISTENT"})
116119
assert result.is_error
120+
assert "SVC_NONEXISTENT" in str(result.content)
117121

118122

119123
# ---------------------------------------------------------------------------
@@ -133,6 +137,7 @@ async def test_list_mvrs(mcp_session):
133137
async def test_get_mvr_not_found(mcp_session):
134138
result = await mcp_session.call_tool("get_mvr", {"id": "MVR_NONEXISTENT"})
135139
assert result.is_error
140+
assert "MVR_NONEXISTENT" in str(result.content)
136141

137142

138143
# ---------------------------------------------------------------------------
@@ -167,6 +172,7 @@ async def test_get_requirement_status(mcp_session):
167172
async def test_get_requirement_status_not_found(mcp_session):
168173
result = await mcp_session.call_tool("get_requirement_status", {"id": "REQ_NONEXISTENT"})
169174
assert result.is_error
175+
assert "REQ_NONEXISTENT" in str(result.content)
170176

171177

172178
@pytest.mark.parametrize("include_post_build", [False, True])
@@ -176,6 +182,7 @@ async def test_get_requirement_status_not_found_with_include_post_build(mcp_sess
176182
"get_requirement_status", {"id": "REQ_NONEXISTENT", "include_post_build": include_post_build}
177183
)
178184
assert result.is_error
185+
assert "REQ_NONEXISTENT" in str(result.content)
179186

180187

181188
async def test_get_requirement_status_missing_automated_test_not_met(mcp_session):

tests/unit/reqstool/mcp/test_server_freshness.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212

1313
import mcp.server.mcpserver
1414
import pytest
15+
from mcp.server.mcpserver.exceptions import ToolError
1516
from reqstool_python_decorators.decorators.decorators import SVCs
1617

17-
from reqstool.common.exceptions import SnapshotReloadError
1818
from reqstool.locations.local_location import LocalLocation
1919
from reqstool.mcp import server as mcp_server
2020

@@ -111,9 +111,11 @@ def test_a_tool_errors_when_the_changed_project_cannot_be_reloaded(project_copy)
111111
async def scenario(tools):
112112
(project_copy / "requirements.yml").write_text(": this is not: [ valid yaml")
113113

114-
with pytest.raises(SnapshotReloadError, match="sources changed but reloading them failed"):
114+
# ToolError, not the underlying SnapshotReloadError: only a ToolError's message
115+
# survives the SDK, so asserting the type is what proves the reason reaches the model.
116+
with pytest.raises(ToolError, match="sources changed but reloading them failed"):
115117
await tools["get_status"]()
116-
with pytest.raises(SnapshotReloadError):
118+
with pytest.raises(ToolError):
117119
await tools["list_requirements"]()
118120
return True
119121

0 commit comments

Comments
 (0)