Skip to content

Commit ffa25d9

Browse files
author
EroNiiChan
committed
fix(mcpserver): bind positional-only handler parameters in tools, prompts, resources (#3760)
1 parent d7845c0 commit ffa25d9

6 files changed

Lines changed: 174 additions & 11 deletions

File tree

‎src/mcp/server/mcpserver/prompts/base.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
1515
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1616
from mcp.server.mcpserver.utilities.types import Audio, Image
17-
from mcp.shared._callable_inspection import is_async_callable
17+
from mcp.shared._callable_inspection import is_async_callable, split_positional_args
1818
from mcp.shared.exceptions import MCPError
1919

2020
if TYPE_CHECKING:
@@ -183,10 +183,11 @@ async def render(
183183
call_args = inject_context(self.fn, arguments or {}, context, self.context_kwarg)
184184

185185
fn = self.fn
186+
args, kwargs = split_positional_args(fn, call_args)
186187
if is_async_callable(fn):
187-
result = await fn(**call_args)
188+
result = await fn(*args, **kwargs)
188189
else:
189-
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))
190+
result = await anyio.to_thread.run_sync(functools.partial(self.fn, *args, **kwargs))
190191

191192
if isinstance(result, InputRequiredResult):
192193
return result

‎src/mcp/server/mcpserver/resolve.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
8282
from mcp.server.request_state import compact_json
8383
from mcp.server.validation import validate_tool_use_result_messages, wants_sampling_tools
84-
from mcp.shared._callable_inspection import is_async_callable
84+
from mcp.shared._callable_inspection import is_async_callable, split_positional_args
8585
from mcp.shared.exceptions import MCPError
8686
from mcp.shared.message import ServerMessageMetadata
8787

@@ -550,10 +550,11 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
550550
raise _Pending
551551

552552
result: Any
553+
args, kwargs = split_positional_args(fn, kwargs)
553554
if plan.is_async:
554-
result = await fn(**kwargs)
555+
result = await fn(*args, **kwargs)
555556
else:
556-
result = await anyio.to_thread.run_sync(lambda: fn(**kwargs))
557+
result = await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs))
557558

558559
if _is_marker(result):
559560
outcome = await _fulfil(result, wire_key, res)

‎src/mcp/server/mcpserver/resources/templates.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
1616
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
1717
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
18-
from mcp.shared._callable_inspection import is_async_callable
18+
from mcp.shared._callable_inspection import is_async_callable, split_positional_args
1919
from mcp.shared.exceptions import MCPError
2020
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
2121
from mcp.shared.uri_template import UriTemplate
@@ -223,10 +223,11 @@ async def create_resource(
223223
params = inject_context(self.fn, params, context, self.context_kwarg)
224224

225225
fn = self.fn
226+
args, kwargs = split_positional_args(fn, params)
226227
if is_async_callable(fn):
227-
result = await fn(**params)
228+
result = await fn(*args, **kwargs)
228229
else:
229-
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params))
230+
result = await anyio.to_thread.run_sync(functools.partial(self.fn, *args, **kwargs))
230231

231232
if isinstance(result, InputRequiredResult):
232233
return result

‎src/mcp/server/mcpserver/utilities/func_metadata.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from mcp.server.mcpserver.exceptions import InvalidSignature
3535
from mcp.server.mcpserver.utilities.logging import get_logger
3636
from mcp.server.mcpserver.utilities.types import Audio, Image
37+
from mcp.shared._callable_inspection import split_positional_args
3738
from mcp.shared.exceptions import MCPDeprecationWarning
3839

3940
logger = get_logger(__name__)
@@ -171,9 +172,10 @@ async def call_fn(
171172
worker thread.
172173
"""
173174
kwargs = arguments | (arguments_to_pass_directly or {})
175+
args, kwargs = split_positional_args(fn, kwargs)
174176
if fn_is_async:
175-
return await fn(**kwargs)
176-
return await anyio.to_thread.run_sync(functools.partial(fn, **kwargs))
177+
return await fn(*args, **kwargs)
178+
return await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs))
177179

178180
@deprecated(
179181
"FuncMetadata.call_fn_with_arg_validation() is deprecated and will be removed in 3.0; "

‎src/mcp/shared/_callable_inspection.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,41 @@ def is_async_callable(obj: Any) -> Any:
3131
return inspect.iscoroutinefunction(obj) or (
3232
callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None))
3333
)
34+
35+
36+
def split_positional_args(
37+
fn: Callable[..., Any], kwargs: dict[str, Any]
38+
) -> tuple[tuple[Any, ...], dict[str, Any]]:
39+
"""Split kwargs into positional-only arguments and remaining keyword arguments.
40+
41+
If `fn` accepts positional-only parameters, extracts matching arguments from
42+
`kwargs` in positional order.
43+
"""
44+
try:
45+
sig = inspect.signature(fn)
46+
except (ValueError, TypeError):
47+
return (), kwargs
48+
49+
pos_only_params = [
50+
p for p in sig.parameters.values()
51+
if p.kind == inspect.Parameter.POSITIONAL_ONLY
52+
]
53+
if not pos_only_params:
54+
return (), kwargs
55+
56+
pos_args: list[Any] = []
57+
remaining_kwargs = dict(kwargs)
58+
for p in pos_only_params:
59+
if p.name in remaining_kwargs:
60+
pos_args.append(remaining_kwargs.pop(p.name))
61+
elif p.default is not inspect.Parameter.empty:
62+
idx = pos_only_params.index(p)
63+
has_later = any(later.name in remaining_kwargs for later in pos_only_params[idx + 1 :])
64+
if has_later:
65+
pos_args.append(p.default)
66+
else:
67+
break
68+
else:
69+
break
70+
71+
return tuple(pos_args), remaining_kwargs
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import anyio
2+
import pytest
3+
from mcp.client.client import Client
4+
from mcp.server.mcpserver import MCPServer
5+
from mcp.shared._callable_inspection import split_positional_args
6+
7+
8+
def test_split_positional_args():
9+
# 1. Pure positional-only
10+
def f1(x: str, /):
11+
return x
12+
13+
args, kwargs = split_positional_args(f1, {"x": "hello"})
14+
assert args == ("hello",)
15+
assert kwargs == {}
16+
17+
# 2. Positional-only with defaults
18+
def f2(x: int, y: str = "default_y", /):
19+
return x, y
20+
21+
# Default not overridden
22+
args, kwargs = split_positional_args(f2, {"x": 42})
23+
assert args == (42,)
24+
assert kwargs == {}
25+
26+
# Default overridden
27+
args, kwargs = split_positional_args(f2, {"x": 42, "y": "custom"})
28+
assert args == (42, "custom")
29+
assert kwargs == {}
30+
31+
# 3. Mixed positional-only, regular, and kw-only
32+
def f3(a: str, /, b: int, *, c: bool = True):
33+
return a, b, c
34+
35+
args, kwargs = split_positional_args(f3, {"a": "pos", "b": 10, "c": False})
36+
assert args == ("pos",)
37+
assert kwargs == {"b": 10, "c": False}
38+
39+
# 4. No positional-only parameters
40+
def f4(a: str, b: int = 1):
41+
return a, b
42+
43+
args, kwargs = split_positional_args(f4, {"a": "val", "b": 2})
44+
assert args == ()
45+
assert kwargs == {"a": "val", "b": 2}
46+
47+
# 5. Method
48+
class Greeter:
49+
def greet(self, msg: str, /):
50+
return f"hi {msg}"
51+
52+
g = Greeter()
53+
args, kwargs = split_positional_args(g.greet, {"msg": "there"})
54+
assert args == ("there",)
55+
assert kwargs == {}
56+
57+
58+
@pytest.mark.anyio
59+
async def test_mcpserver_posonly_tools_prompts_resources():
60+
mcp = MCPServer("test_posonly")
61+
62+
@mcp.tool()
63+
def sync_tool(msg: str, /) -> str:
64+
return f"sync:{msg}"
65+
66+
@mcp.tool()
67+
async def async_tool(msg: str, /) -> str:
68+
return f"async:{msg}"
69+
70+
@mcp.tool()
71+
def mixed_tool(x: int, /, y: str = "world") -> str:
72+
return f"{x}:{y}"
73+
74+
@mcp.prompt()
75+
def sync_prompt(name: str, /) -> str:
76+
return f"hello {name}"
77+
78+
@mcp.prompt()
79+
async def async_prompt(name: str, /) -> str:
80+
return f"async hello {name}"
81+
82+
@mcp.resource("test://{item}")
83+
def sync_res(item: str, /) -> str:
84+
return f"content:{item}"
85+
86+
@mcp.resource("async://{item}")
87+
async def async_res(item: str, /) -> str:
88+
return f"async_content:{item}"
89+
90+
async with Client(mcp) as client:
91+
# Tool tests
92+
t1 = await client.call_tool("sync_tool", {"msg": "apple"})
93+
assert not t1.is_error
94+
assert t1.content[0].text == "sync:apple"
95+
96+
t2 = await client.call_tool("async_tool", {"msg": "banana"})
97+
assert not t2.is_error
98+
assert t2.content[0].text == "async:banana"
99+
100+
t3 = await client.call_tool("mixed_tool", {"x": 7, "y": "universe"})
101+
assert not t3.is_error
102+
assert t3.content[0].text == "7:universe"
103+
104+
t3_default = await client.call_tool("mixed_tool", {"x": 9})
105+
assert not t3_default.is_error
106+
assert t3_default.content[0].text == "9:world"
107+
108+
# Prompt tests
109+
p1 = await client.get_prompt("sync_prompt", {"name": "carol"})
110+
assert p1.messages[0].content.text == "hello carol"
111+
112+
p2 = await client.get_prompt("async_prompt", {"name": "dave"})
113+
assert p2.messages[0].content.text == "async hello dave"
114+
115+
# Resource tests
116+
r1 = await client.read_resource("test://gadget")
117+
assert r1.contents[0].text == "content:gadget"
118+
119+
r2 = await client.read_resource("async://widget")
120+
assert r2.contents[0].text == "async_content:widget"

0 commit comments

Comments
 (0)