Skip to content

Commit 0a685af

Browse files
jimisolaJimisola Laursen
andauthored
fix(mcp): migrate to MCP SDK 2.x and pin all dependency versions (#436)
mcp 2.0.0 removed mcp.server.fastmcp, breaking test collection and the 0.12.0 release build. Migrate the server to the 2.x API: - FastMCP -> mcp.server.mcpserver.MCPServer; transport options (host, port, json_response, stateless_http) are run() kwargs instead of server settings - tools are async so they execute on the event loop thread; SDK 2.x runs sync tools on worker threads, which breaks the repository's thread-bound SQLite connection - client CallToolResult.isError -> is_error in integration tests Also pin the remaining ranged dependencies (rich, pygls, lsprotocol, mcp) to exact versions like the rest of pyproject.toml. Signed-off-by: Jimisola Laursen <jimisola.laursen@resurs.se> Co-authored-by: Jimisola Laursen <jimisola.laursen@resurs.se>
1 parent 14ffec6 commit 0a685af

6 files changed

Lines changed: 54 additions & 51 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,17 @@ dependencies = [
4747
"pygit2==1.20.0",
4848
"referencing==0.37.0",
4949
"requests-file==3.0.1",
50-
"rich>=13.0",
50+
"rich==15.0.0",
5151
"ruamel.yaml==0.19.1",
5252
"reqstool-python-decorators==0.1.0",
5353
"packaging==26.3",
5454
"requests==2.34.2",
5555
"beautifulsoup4==4.15.0",
5656
"defusedxml==0.7.1",
5757
"expandvars==1.1.2",
58-
"pygls>=2.0,<3.0",
59-
"lsprotocol>=2024.0.0",
60-
"mcp>=1.0",
58+
"pygls==2.1.1",
59+
"lsprotocol==2025.0.0",
60+
"mcp==2.0.0",
6161
]
6262

6363
[project.urls]

src/reqstool/command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,7 @@ def command_mcp(self, mcp_args: argparse.Namespace):
592592
from reqstool.mcp.server import start_server
593593
except ImportError:
594594
print(
595-
"MCP server requires extra dependencies: pip install 'mcp>=1.0'",
595+
"MCP server requires extra dependencies: pip install 'mcp>=2.0'",
596596
file=sys.stderr,
597597
)
598598
sys.exit(1)

src/reqstool/mcp/server.py

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ def start_server( # noqa: C901
2929
port: int = 8000,
3030
) -> None:
3131
try:
32-
from mcp.server.fastmcp import FastMCP
32+
from mcp.server.mcpserver import MCPServer
3333
except ImportError as exc:
34-
raise ImportError("MCP server requires extra dependencies: pip install 'mcp>=1.0'") from exc
34+
raise ImportError("MCP server requires extra dependencies: pip install 'mcp>=2.0'") from exc
3535

3636
session = ProjectSession(location)
3737
session.build()
@@ -44,69 +44,71 @@ def start_server( # noqa: C901
4444
repo: RequirementsRepository = session.repo
4545
urn_source_paths = session.urn_source_paths
4646

47-
mcp = FastMCP("reqstool")
48-
mcp.settings.host = host
49-
mcp.settings.port = port
47+
mcp = MCPServer(name="reqstool")
48+
49+
# SDK 2.x: transport options are run() kwargs instead of server settings.
50+
run_kwargs: dict = {}
51+
if transport in ("sse", "streamable-http"):
52+
run_kwargs.update(host=host, port=port)
5053
if transport == "streamable-http":
51-
mcp.settings.json_response = True
52-
mcp.settings.stateless_http = True
54+
run_kwargs.update(json_response=True, stateless_http=True)
5355

5456
@mcp.tool()
55-
def list_requirements(urn: str | None = None, lifecycle_state: str | None = None) -> list[dict]:
57+
async def list_requirements(urn: str | None = None, lifecycle_state: str | None = None) -> list[dict]:
5658
"""List requirements with id, title, and lifecycle state.
5759
Filter by urn and/or lifecycle_state (draft|effective|deprecated|obsolete)."""
5860
return get_requirements_list(repo, urn=urn, lifecycle_state=lifecycle_state)
5961

6062
@mcp.tool()
61-
def get_requirement(id: str) -> dict:
63+
async def get_requirement(id: str) -> dict:
6264
"""Get full details for a requirement by ID (e.g. REQ_010)."""
6365
result = get_requirement_details(id, repo, urn_source_paths)
6466
if result is None:
6567
raise ValueError(f"Requirement {id!r} not found")
6668
return result
6769

6870
@mcp.tool()
69-
def get_requirements_status(urn: str | None = None, include_post_build: bool = False) -> list[dict]:
71+
async def get_requirements_status(urn: str | None = None, include_post_build: bool = False) -> list[dict]:
7072
"""Batch status for all requirements: id, urn, lifecycle_state, completed, implementation_type,
7173
automated_tests, manual_tests. Use this to find requirements that are incomplete, partially
7274
tested, or not yet implemented. Optionally filter by URN. Set include_post_build=True for
7375
parity with `status --with-post-tests` (scopes to post-build-phase SVCs too)."""
7476
return _get_requirements_status_all(repo, urn=urn, include_post_build=include_post_build)
7577

7678
@mcp.tool()
77-
def list_svcs(urn: str | None = None, lifecycle_state: str | None = None) -> list[dict]:
79+
async def list_svcs(urn: str | None = None, lifecycle_state: str | None = None) -> list[dict]:
7880
"""List SVCs with id, title, lifecycle state, and verification type.
7981
Filter by urn and/or lifecycle_state (draft|effective|deprecated|obsolete)."""
8082
return get_svcs_list(repo, urn=urn, lifecycle_state=lifecycle_state)
8183

8284
@mcp.tool()
83-
def get_svc(id: str) -> dict:
85+
async def get_svc(id: str) -> dict:
8486
"""Get full details for an SVC by ID (e.g. SVC_010)."""
8587
result = get_svc_details(id, repo, urn_source_paths)
8688
if result is None:
8789
raise ValueError(f"SVC {id!r} not found")
8890
return result
8991

9092
@mcp.tool()
91-
def list_mvrs(urn: str | None = None, passed: bool | None = None) -> list[dict]:
93+
async def list_mvrs(urn: str | None = None, passed: bool | None = None) -> list[dict]:
9294
"""List MVRs with id and passed status. Filter by urn and/or passed (True|False)."""
9395
return get_mvrs_list(repo, urn=urn, passed=passed)
9496

9597
@mcp.tool()
96-
def get_mvr(id: str) -> dict:
98+
async def get_mvr(id: str) -> dict:
9799
"""Get full details for an MVR by ID."""
98100
result = get_mvr_details(id, repo, urn_source_paths)
99101
if result is None:
100102
raise ValueError(f"MVR {id!r} not found")
101103
return result
102104

103105
@mcp.tool()
104-
def get_status() -> dict:
106+
async def get_status() -> dict:
105107
"""Get overall traceability status — completion per requirement, test totals."""
106108
return StatisticsService(repo).to_status_dict()
107109

108110
@mcp.tool()
109-
def get_requirement_status(id: str, include_post_build: bool = False) -> dict:
111+
async def get_requirement_status(id: str, include_post_build: bool = False) -> dict:
110112
"""Status check for one requirement: lifecycle_state, completed, implementation_type,
111113
automated_tests, manual_tests. Set include_post_build=True for parity with
112114
`status --with-post-tests` (scopes to post-build-phase SVCs too)."""
@@ -116,7 +118,7 @@ def get_requirement_status(id: str, include_post_build: bool = False) -> dict:
116118
return result
117119

118120
@mcp.tool()
119-
def list_annotations(urn: str | None = None) -> list[dict]:
121+
async def list_annotations(urn: str | None = None) -> list[dict]:
120122
"""List implementation annotations (@Requirements) found in source code. Optionally filter by URN."""
121123
impl_annotations = repo.get_annotations_impls(urn=urn)
122124
result = []
@@ -133,20 +135,20 @@ def list_annotations(urn: str | None = None) -> list[dict]:
133135
return result
134136

135137
@mcp.tool()
136-
def list_urns() -> list[dict]:
138+
async def list_urns() -> list[dict]:
137139
"""List all URNs in the project graph with variant, title, url, location, and file paths."""
138140
return get_urns_list(repo, urn_source_paths)
139141

140142
@mcp.tool()
141-
def get_urn_details(urn: str) -> dict:
143+
async def get_urn_details(urn: str) -> dict:
142144
"""Get details for a URN: variant, title, location, file paths, and entity counts."""
143145
result = _get_urn_details(urn, repo, urn_source_paths)
144146
if result is None:
145147
raise ValueError(f"URN {urn!r} not found")
146148
return result
147149

148150
@mcp.tool()
149-
def enrich_document(content: str, preset: str) -> str:
151+
async def enrich_document(content: str, preset: str) -> str:
150152
"""Enrich an OpenSpec document by resolving requirement/SVC/MVR IDs.
151153
152154
Injects titles and further fields next to each known ID according to the
@@ -162,6 +164,6 @@ def enrich_document(content: str, preset: str) -> str:
162164

163165
try:
164166
logger.info("Starting reqstool MCP server (transport=%s, host=%s, port=%s)", transport, host, port)
165-
mcp.run(transport=transport)
167+
mcp.run(transport=transport, **run_kwargs)
166168
finally:
167169
session.close()

tests/integration/reqstool/mcp/test_mcp_integration.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414

1515
def _parse_result(result) -> list | dict:
16-
"""FastMCP returns each list item as a separate TextContent block."""
16+
"""The MCP server returns each list item as a separate TextContent block."""
1717
blocks = [json.loads(b.text) for b in result.content if hasattr(b, "text")]
1818
return blocks if len(blocks) != 1 else blocks[0]
1919

@@ -75,7 +75,7 @@ async def test_get_requirement_known(mcp_session):
7575

7676
async def test_get_requirement_not_found(mcp_session):
7777
result = await mcp_session.call_tool("get_requirement", {"id": "REQ_NONEXISTENT"})
78-
assert result.isError
78+
assert result.is_error
7979

8080

8181
# ---------------------------------------------------------------------------
@@ -112,7 +112,7 @@ async def test_get_svc_known(mcp_session):
112112

113113
async def test_get_svc_not_found(mcp_session):
114114
result = await mcp_session.call_tool("get_svc", {"id": "SVC_NONEXISTENT"})
115-
assert result.isError
115+
assert result.is_error
116116

117117

118118
# ---------------------------------------------------------------------------
@@ -131,7 +131,7 @@ async def test_list_mvrs(mcp_session):
131131

132132
async def test_get_mvr_not_found(mcp_session):
133133
result = await mcp_session.call_tool("get_mvr", {"id": "MVR_NONEXISTENT"})
134-
assert result.isError
134+
assert result.is_error
135135

136136

137137
# ---------------------------------------------------------------------------
@@ -165,7 +165,7 @@ async def test_get_requirement_status(mcp_session):
165165

166166
async def test_get_requirement_status_not_found(mcp_session):
167167
result = await mcp_session.call_tool("get_requirement_status", {"id": "REQ_NONEXISTENT"})
168-
assert result.isError
168+
assert result.is_error
169169

170170

171171
@pytest.mark.parametrize("include_post_build", [False, True])
@@ -174,7 +174,7 @@ async def test_get_requirement_status_not_found_with_include_post_build(mcp_sess
174174
result = await mcp_session.call_tool(
175175
"get_requirement_status", {"id": "REQ_NONEXISTENT", "include_post_build": include_post_build}
176176
)
177-
assert result.isError
177+
assert result.is_error
178178

179179

180180
async def test_get_requirement_status_missing_automated_test_not_met(mcp_session):
Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
# Copyright © LFV
22

3-
from types import SimpleNamespace
3+
4+
import asyncio
45
from unittest.mock import patch
56

6-
import mcp.server.fastmcp
7+
import mcp.server.mcpserver
78
from reqstool_python_decorators.decorators.decorators import SVCs
89

910
from reqstool.locations.local_location import LocalLocation
1011
from reqstool.mcp import server as mcp_server
1112

1213

13-
class _FakeFastMCP:
14-
"""Stand-in for mcp.server.fastmcp.FastMCP: captures registered tools and the run() call."""
14+
class _FakeMCPServer:
15+
"""Stand-in for mcp.server.mcpserver.MCPServer: captures registered tools and the run() call."""
1516

16-
instances: list["_FakeFastMCP"] = []
17+
instances: list["_FakeMCPServer"] = []
1718

18-
def __init__(self, name):
19+
def __init__(self, name=None, **kwargs):
1920
self.name = name
20-
self.settings = SimpleNamespace()
2121
self.tools = {}
2222
self.run_transport = None
23+
self.run_kwargs = None
2324
self.status_result = None
24-
_FakeFastMCP.instances.append(self)
25+
_FakeMCPServer.instances.append(self)
2526

2627
def tool(self):
2728
def decorator(fn):
@@ -30,10 +31,12 @@ def decorator(fn):
3031

3132
return decorator
3233

33-
def run(self, transport):
34+
def run(self, transport, **kwargs):
3435
"""Simulate a connected client calling a registered tool while the server is up."""
3536
self.run_transport = transport
36-
self.status_result = self.tools["get_status"]()
37+
self.run_kwargs = kwargs
38+
# Tools are async so they execute on the event loop thread (SQLite affinity).
39+
self.status_result = asyncio.run(self.tools["get_status"]())
3740

3841

3942
@SVCs("SVC_MCP_0001")
@@ -42,11 +45,12 @@ def test_start_server_serves_resolved_dataset(local_testdata_resources_rootdir_w
4245
and exposes its dataset through the registered tools."""
4346
location = LocalLocation(path=local_testdata_resources_rootdir_w_path("test_basic/baseline/ms-101"))
4447

45-
with patch.object(mcp.server.fastmcp, "FastMCP", _FakeFastMCP):
48+
with patch.object(mcp.server.mcpserver, "MCPServer", _FakeMCPServer):
4649
mcp_server.start_server(location=location, transport="stdio")
4750

48-
fake_mcp = _FakeFastMCP.instances[-1]
51+
fake_mcp = _FakeMCPServer.instances[-1]
4952
assert fake_mcp.run_transport == "stdio"
53+
assert fake_mcp.run_kwargs == {}
5054
assert fake_mcp.status_result is not None
5155
assert fake_mcp.status_result["totals"]["requirements"]["total"] > 0
5256

@@ -57,12 +61,9 @@ def test_start_server_streamable_http_configures_settings(local_testdata_resourc
5761
configured host and port."""
5862
location = LocalLocation(path=local_testdata_resources_rootdir_w_path("test_basic/baseline/ms-101"))
5963

60-
with patch.object(mcp.server.fastmcp, "FastMCP", _FakeFastMCP):
64+
with patch.object(mcp.server.mcpserver, "MCPServer", _FakeMCPServer):
6165
mcp_server.start_server(location=location, transport="streamable-http", host="0.0.0.0", port=9000)
6266

63-
fake_mcp = _FakeFastMCP.instances[-1]
67+
fake_mcp = _FakeMCPServer.instances[-1]
6468
assert fake_mcp.run_transport == "streamable-http"
65-
assert fake_mcp.settings.host == "0.0.0.0"
66-
assert fake_mcp.settings.port == 9000
67-
assert fake_mcp.settings.json_response is True
68-
assert fake_mcp.settings.stateless_http is True
69+
assert fake_mcp.run_kwargs == {"host": "0.0.0.0", "port": 9000, "json_response": True, "stateless_http": True}

tests/unit/reqstool/test_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ def test_mcp_missing_extra_reports_and_exits(capsys):
411411
with pytest.raises(SystemExit) as exc:
412412
cmd.command_mcp(mcp_args)
413413
assert exc.value.code == 1
414-
assert "pip install 'mcp>=1.0'" in capsys.readouterr().err
414+
assert "pip install 'mcp>=2.0'" in capsys.readouterr().err
415415

416416

417417
@SVCs("SVC_MCP_0003")

0 commit comments

Comments
 (0)