Skip to content

Commit 005e7e6

Browse files
cliffhallclaude
andcommitted
fix(python): serialize git tool calls, delegate malformed names, require mcp 2.3
Copilot round 1 on #5175: - git: MCPServer runs a synchronous tool in a worker thread, so overlapping calls could interleave a check and a change on the shared index. A lock keeps one tool call at a time, as when tools ran on the event loop. - fetch: the unknown-name middleware sees raw params; a non-string name or non-mapping prompt arguments now go to the SDK's validation (-32602) instead of raising TypeError (-32603). - fetch, git, time: subscriptions=False needs SDK 2.3, so the floor is mcp>=2.3,<3 in pyproject, uv.lock and the READMEs. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]>
1 parent a5587f2 commit 005e7e6

14 files changed

Lines changed: 159 additions & 16 deletions

File tree

‎src/fetch/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ A Model Context Protocol server that provides web content fetching capabilities.
66

77
Source: https://github.com/modelcontextprotocol/servers/tree/main/src/fetch
88

9-
Requires MCP Python SDK 2.x (`mcp>=2.2,<3`).
9+
Requires MCP Python SDK 2.x (`mcp>=2.3,<3`).
1010

1111
> [!CAUTION]
1212
> This server can access local/internal IP addresses and may represent a security risk. Exercise caution when using this MCP server to ensure this does not expose any sensitive data.

‎src/fetch/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ classifiers = [
1818
dependencies = [
1919
"httpx[socks]>=0.27",
2020
"markdownify>=0.13.1",
21-
"mcp>=2.2,<3",
21+
"mcp>=2.3,<3",
2222
"protego>=0.6.2",
2323
"pydantic>=2.0.0",
2424
"readabilipy>=0.2.0",

‎src/fetch/src/mcp_server_fetch/server.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,16 +359,21 @@ def reject_unknown_names(server: MCPServer) -> ServerMiddleware[Any]:
359359
async def middleware(
360360
ctx: ServerRequestContext[Any, Any], call_next: CallNext
361361
) -> HandlerResult:
362-
params: Mapping[str, Any] = ctx.params or {}
362+
# Raw params, not yet validated: only a string name and a mapping of
363+
# arguments are inspected here. Anything malformed is left to the
364+
# SDK's own params validation, which answers it with -32602.
365+
params = ctx.params if isinstance(ctx.params, Mapping) else {}
363366
name = params.get("name")
367+
if not isinstance(name, str):
368+
return await call_next(ctx)
364369
if ctx.method == "tools/call":
365370
if name not in {tool.name for tool in await server.list_tools()}:
366371
raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {name}")
367372
elif ctx.method == "prompts/get":
368373
if name not in {prompt.name for prompt in await server.list_prompts()}:
369374
raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}")
370375
arguments = params.get("arguments") or {}
371-
if "url" not in arguments:
376+
if isinstance(arguments, Mapping) and "url" not in arguments:
372377
raise MCPError(code=INVALID_PARAMS, message="URL is required")
373378
return await call_next(ctx)
374379

‎src/fetch/tests/harness.py‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@
3030
import readabilipy.simple_json
3131
from mcp import ClientSession
3232
from mcp.shared.memory import create_client_server_memory_streams
33-
from mcp.types import InitializeResult
33+
from mcp.shared.message import SessionMessage
34+
from mcp.types import (
35+
InitializeResult,
36+
JSONRPCError,
37+
JSONRPCNotification,
38+
JSONRPCRequest,
39+
JSONRPCResponse,
40+
)
3441

3542
import mcp_server_fetch.server as server_module
3643

@@ -152,3 +159,70 @@ def private_tempdir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
152159
each other's pages.
153160
"""
154161
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
162+
163+
164+
async def raw_request(
165+
method: str, params: dict[str, Any]
166+
) -> Union[JSONRPCResponse, JSONRPCError]:
167+
"""Send one request exactly as given, bypassing the client's own checks.
168+
169+
`ClientSession` validates what it sends, so a malformed request (a list as
170+
a tool name, a number as the arguments) can only reach the server as raw
171+
JSON-RPC: a 2025-11-25 handshake, then the request, and its response.
172+
"""
173+
async with create_client_server_memory_streams() as (
174+
(client_read, client_write),
175+
server_streams,
176+
):
177+
178+
@asynccontextmanager
179+
async def fake_stdio_server() -> AsyncIterator[Any]:
180+
yield server_streams
181+
182+
async def receive(request_id: int) -> Union[JSONRPCResponse, JSONRPCError]:
183+
async for item in client_read:
184+
assert isinstance(item, SessionMessage)
185+
message = item.message
186+
if (
187+
isinstance(message, (JSONRPCResponse, JSONRPCError))
188+
and message.id == request_id
189+
):
190+
return message
191+
raise AssertionError("the server closed the stream") # pragma: no cover
192+
193+
with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server):
194+
async with anyio.create_task_group() as tg:
195+
tg.start_soon(lambda: server_module.serve())
196+
await client_write.send(
197+
SessionMessage(
198+
JSONRPCRequest(
199+
jsonrpc="2.0",
200+
id=1,
201+
method="initialize",
202+
params={
203+
"protocolVersion": "2025-11-25",
204+
"capabilities": {},
205+
"clientInfo": {"name": "raw", "version": "0"},
206+
},
207+
)
208+
)
209+
)
210+
await receive(1)
211+
await client_write.send(
212+
SessionMessage(
213+
JSONRPCNotification(
214+
jsonrpc="2.0", method="notifications/initialized"
215+
)
216+
)
217+
)
218+
await client_write.send(
219+
SessionMessage(
220+
JSONRPCRequest(
221+
jsonrpc="2.0", id=2, method=method, params=params
222+
)
223+
)
224+
)
225+
response = await receive(2)
226+
tg.cancel_scope.cancel()
227+
return response
228+
raise AssertionError("unreachable") # pragma: no cover

‎src/fetch/tests/test_protocol.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@
2929
from mcp.shared.exceptions import MCPError
3030
from mcp.shared.memory import create_client_server_memory_streams
3131
from mcp.shared.message import SessionMessage
32-
from mcp.types import INVALID_PARAMS, CallToolResult, JSONRPCRequest
32+
from mcp.types import INVALID_PARAMS, CallToolResult, JSONRPCError, JSONRPCRequest
3333

3434
from mcp_server_fetch.server import (
3535
DEFAULT_USER_AGENT_AUTONOMOUS,
3636
DEFAULT_USER_AGENT_MANUAL,
3737
serve,
3838
)
3939

40-
from .harness import FakeWeb, connect
40+
from .harness import FakeWeb, connect, raw_request
4141

4242
PAGE = "https://example.com/page"
4343
ROBOTS = "https://example.com/robots.txt"
@@ -287,6 +287,33 @@ async def test_unknown_tool_name_is_a_jsonrpc_error(web: FakeWeb) -> None:
287287
assert web.requests == []
288288

289289

290+
@pytest.mark.parametrize(
291+
("method", "params"),
292+
[
293+
("tools/call", {"name": [], "arguments": {}}),
294+
("tools/call", {"name": {}, "arguments": {}}),
295+
("prompts/get", {"name": [], "arguments": {}}),
296+
("prompts/get", {"name": "fetch", "arguments": 1}),
297+
],
298+
ids=[
299+
"tool-name-list",
300+
"tool-name-object",
301+
"prompt-name-list",
302+
"prompt-arguments-number",
303+
],
304+
)
305+
async def test_malformed_request_is_invalid_params_not_an_internal_error(
306+
web: FakeWeb, method: str, params: dict[str, Any]
307+
) -> None:
308+
# #5175 review: the unknown-name middleware sees raw params, so it leaves
309+
# a non-string name or non-mapping arguments to the SDK's validation,
310+
# which answers -32602, instead of failing on them with -32603.
311+
response = await raw_request(method, params)
312+
assert isinstance(response, JSONRPCError), response
313+
assert response.error.code == INVALID_PARAMS
314+
assert web.requests == []
315+
316+
290317
async def test_unknown_tool_name_is_rejected_before_argument_validation(
291318
web: FakeWeb,
292319
) -> None:

‎src/fetch/uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/git/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A Model Context Protocol server for Git repository interaction and automation. T
88

99
Source: https://github.com/modelcontextprotocol/servers/tree/main/src/git
1010

11-
Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). The server is built on the SDK's high-level `MCPServer`: each tool is a typed Python function, and the SDK derives its input schema from the signature.
11+
Requires MCP Python SDK 2.x (`mcp>=2.3,<3`). The server is built on the SDK's high-level `MCPServer`: each tool is a typed Python function, and the SDK derives its input schema from the signature.
1212

1313
Please note that mcp-server-git is currently in early development. The functionality and available tools are subject to change and expansion as we continue to develop and improve the server.
1414

‎src/git/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ classifiers = [
1818
dependencies = [
1919
"click>=8.1.7",
2020
"gitpython>=3.1.60",
21-
"mcp>=2.2,<3",
21+
"mcp>=2.3,<3",
2222
"pydantic>=2.0.0",
2323
]
2424

‎src/git/src/mcp_server_git/server.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# logging), so nothing differs between them.
1818

1919
import logging
20+
import threading
2021
from collections.abc import Callable, Iterator
2122
from contextlib import contextmanager
2223
from enum import Enum
@@ -445,16 +446,23 @@ def reported_to_model() -> Iterator[None]:
445446
_P = ParamSpec("_P")
446447

447448

449+
# `MCPServer` runs a synchronous tool in a worker thread, so two calls can
450+
# overlap. A git operation is a check followed by a change (staged changes,
451+
# then the commit), and the index is shared, so one call at a time: the lock
452+
# keeps the serial behavior the server had when tools ran on the event loop.
453+
_GIT_LOCK = threading.Lock()
454+
455+
448456
def reports_failures(fn: Callable[_P, str]) -> Callable[_P, str]:
449-
"""Run a tool function inside `reported_to_model()`.
457+
"""Run a tool function alone, inside `reported_to_model()`.
450458
451459
`wraps` keeps the signature, which is what `MCPServer` reads to build the
452460
tool's input schema.
453461
"""
454462

455463
@wraps(fn)
456464
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> str:
457-
with reported_to_model():
465+
with _GIT_LOCK, reported_to_model():
458466
return fn(*args, **kwargs)
459467

460468
return wrapper

‎src/git/tests/test_protocol.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import json
1212
import re
13+
import threading
14+
import time
1315
from collections.abc import AsyncIterator
1416
from contextlib import asynccontextmanager
1517
from importlib.metadata import version
@@ -704,6 +706,33 @@ async def test_an_unanticipated_exception_is_reported_generically(repo: git.Repo
704706
assert result == text_result("Error executing tool git_status", is_error=True)
705707

706708

709+
async def test_overlapping_tool_calls_run_one_at_a_time(repo: git.Repo):
710+
# #5175 review: MCPServer runs a synchronous tool in a worker thread, so
711+
# two calls can overlap, and a git operation is a check and then a change
712+
# on a shared index. The server runs one tool call at a time.
713+
active = 0
714+
peak = 0
715+
guard = threading.Lock()
716+
717+
def slow_status(_repo: git.Repo) -> str:
718+
nonlocal active, peak
719+
with guard:
720+
active += 1
721+
peak = max(peak, active)
722+
time.sleep(0.2)
723+
with guard:
724+
active -= 1
725+
return "clean"
726+
727+
root = str(root_of(repo))
728+
with mock.patch("mcp_server_git.server.git_status", side_effect=slow_status):
729+
async with connect(None) as session:
730+
async with anyio.create_task_group() as tg:
731+
for _ in range(3):
732+
tg.start_soon(session.call_tool, "git_status", {"repo_path": root})
733+
assert peak == 1
734+
735+
707736
async def test_git_create_branch_rejects_flag_injection(repo: git.Repo):
708737
root = str(root_of(repo))
709738
assert await call(

0 commit comments

Comments
 (0)