diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 32372a527a..2f379261aa 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -14,7 +14,7 @@ from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.server.mcpserver.utilities.types import Audio, Image -from mcp.shared._callable_inspection import is_async_callable +from mcp.shared._callable_inspection import is_async_callable, split_positional_args from mcp.shared.exceptions import MCPError if TYPE_CHECKING: @@ -183,10 +183,11 @@ async def render( call_args = inject_context(self.fn, arguments or {}, context, self.context_kwarg) fn = self.fn + args, kwargs = split_positional_args(fn, call_args) if is_async_callable(fn): - result = await fn(**call_args) + result = await fn(*args, **kwargs) else: - result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args)) + result = await anyio.to_thread.run_sync(functools.partial(self.fn, *args, **kwargs)) if isinstance(result, InputRequiredResult): return result diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index 2afbc516e3..c539864094 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -81,7 +81,7 @@ from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError from mcp.server.request_state import compact_json from mcp.server.validation import validate_tool_use_result_messages, wants_sampling_tools -from mcp.shared._callable_inspection import is_async_callable +from mcp.shared._callable_inspection import is_async_callable, split_positional_args from mcp.shared.exceptions import MCPError from mcp.shared.message import ServerMessageMetadata @@ -550,10 +550,11 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul raise _Pending result: Any + args, kwargs = split_positional_args(fn, kwargs) if plan.is_async: - result = await fn(**kwargs) + result = await fn(*args, **kwargs) else: - result = await anyio.to_thread.run_sync(lambda: fn(**kwargs)) + result = await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs)) if _is_marker(result): outcome = await _fulfil(result, wire_key, res) diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 621b2e9448..8d4fb4edae 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -15,7 +15,7 @@ from mcp.server.mcpserver.resources.types import FunctionResource, Resource from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context from mcp.server.mcpserver.utilities.func_metadata import func_metadata -from mcp.shared._callable_inspection import is_async_callable +from mcp.shared._callable_inspection import is_async_callable, split_positional_args from mcp.shared.exceptions import MCPError from mcp.shared.path_security import contains_path_traversal, is_absolute_path from mcp.shared.uri_template import UriTemplate @@ -223,10 +223,11 @@ async def create_resource( params = inject_context(self.fn, params, context, self.context_kwarg) fn = self.fn + args, kwargs = split_positional_args(fn, params) if is_async_callable(fn): - result = await fn(**params) + result = await fn(*args, **kwargs) else: - result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params)) + result = await anyio.to_thread.run_sync(functools.partial(self.fn, *args, **kwargs)) if isinstance(result, InputRequiredResult): return result diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index e5721488f5..5206019d34 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -34,6 +34,7 @@ from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.utilities.logging import get_logger from mcp.server.mcpserver.utilities.types import Audio, Image +from mcp.shared._callable_inspection import split_positional_args from mcp.shared.exceptions import MCPDeprecationWarning logger = get_logger(__name__) @@ -171,9 +172,10 @@ async def call_fn( worker thread. """ kwargs = arguments | (arguments_to_pass_directly or {}) + args, kwargs = split_positional_args(fn, kwargs) if fn_is_async: - return await fn(**kwargs) - return await anyio.to_thread.run_sync(functools.partial(fn, **kwargs)) + return await fn(*args, **kwargs) + return await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs)) @deprecated( "FuncMetadata.call_fn_with_arg_validation() is deprecated and will be removed in 3.0; " diff --git a/src/mcp/shared/_callable_inspection.py b/src/mcp/shared/_callable_inspection.py index 0e89e446f8..feb6f671c1 100644 --- a/src/mcp/shared/_callable_inspection.py +++ b/src/mcp/shared/_callable_inspection.py @@ -31,3 +31,41 @@ def is_async_callable(obj: Any) -> Any: return inspect.iscoroutinefunction(obj) or ( callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None)) ) + + +def split_positional_args( + fn: Callable[..., Any], kwargs: dict[str, Any] +) -> tuple[tuple[Any, ...], dict[str, Any]]: + """Split kwargs into positional-only arguments and remaining keyword arguments. + + If `fn` accepts positional-only parameters, extracts matching arguments from + `kwargs` in positional order. + """ + try: + sig = inspect.signature(fn) + except (ValueError, TypeError): + return (), kwargs + + pos_only_params = [ + p for p in sig.parameters.values() + if p.kind == inspect.Parameter.POSITIONAL_ONLY + ] + if not pos_only_params: + return (), kwargs + + pos_args: list[Any] = [] + remaining_kwargs = dict(kwargs) + for p in pos_only_params: + if p.name in remaining_kwargs: + pos_args.append(remaining_kwargs.pop(p.name)) + elif p.default is not inspect.Parameter.empty: + idx = pos_only_params.index(p) + has_later = any(later.name in remaining_kwargs for later in pos_only_params[idx + 1 :]) + if has_later: + pos_args.append(p.default) + else: + break + else: + break + + return tuple(pos_args), remaining_kwargs diff --git a/tests/server/mcpserver/test_posonly_handlers.py b/tests/server/mcpserver/test_posonly_handlers.py new file mode 100644 index 0000000000..b939a0112f --- /dev/null +++ b/tests/server/mcpserver/test_posonly_handlers.py @@ -0,0 +1,120 @@ +import anyio +import pytest +from mcp.client.client import Client +from mcp.server.mcpserver import MCPServer +from mcp.shared._callable_inspection import split_positional_args + + +def test_split_positional_args(): + # 1. Pure positional-only + def f1(x: str, /): + return x + + args, kwargs = split_positional_args(f1, {"x": "hello"}) + assert args == ("hello",) + assert kwargs == {} + + # 2. Positional-only with defaults + def f2(x: int, y: str = "default_y", /): + return x, y + + # Default not overridden + args, kwargs = split_positional_args(f2, {"x": 42}) + assert args == (42,) + assert kwargs == {} + + # Default overridden + args, kwargs = split_positional_args(f2, {"x": 42, "y": "custom"}) + assert args == (42, "custom") + assert kwargs == {} + + # 3. Mixed positional-only, regular, and kw-only + def f3(a: str, /, b: int, *, c: bool = True): + return a, b, c + + args, kwargs = split_positional_args(f3, {"a": "pos", "b": 10, "c": False}) + assert args == ("pos",) + assert kwargs == {"b": 10, "c": False} + + # 4. No positional-only parameters + def f4(a: str, b: int = 1): + return a, b + + args, kwargs = split_positional_args(f4, {"a": "val", "b": 2}) + assert args == () + assert kwargs == {"a": "val", "b": 2} + + # 5. Method + class Greeter: + def greet(self, msg: str, /): + return f"hi {msg}" + + g = Greeter() + args, kwargs = split_positional_args(g.greet, {"msg": "there"}) + assert args == ("there",) + assert kwargs == {} + + +@pytest.mark.anyio +async def test_mcpserver_posonly_tools_prompts_resources(): + mcp = MCPServer("test_posonly") + + @mcp.tool() + def sync_tool(msg: str, /) -> str: + return f"sync:{msg}" + + @mcp.tool() + async def async_tool(msg: str, /) -> str: + return f"async:{msg}" + + @mcp.tool() + def mixed_tool(x: int, /, y: str = "world") -> str: + return f"{x}:{y}" + + @mcp.prompt() + def sync_prompt(name: str, /) -> str: + return f"hello {name}" + + @mcp.prompt() + async def async_prompt(name: str, /) -> str: + return f"async hello {name}" + + @mcp.resource("test://{item}") + def sync_res(item: str, /) -> str: + return f"content:{item}" + + @mcp.resource("async://{item}") + async def async_res(item: str, /) -> str: + return f"async_content:{item}" + + async with Client(mcp) as client: + # Tool tests + t1 = await client.call_tool("sync_tool", {"msg": "apple"}) + assert not t1.is_error + assert t1.content[0].text == "sync:apple" + + t2 = await client.call_tool("async_tool", {"msg": "banana"}) + assert not t2.is_error + assert t2.content[0].text == "async:banana" + + t3 = await client.call_tool("mixed_tool", {"x": 7, "y": "universe"}) + assert not t3.is_error + assert t3.content[0].text == "7:universe" + + t3_default = await client.call_tool("mixed_tool", {"x": 9}) + assert not t3_default.is_error + assert t3_default.content[0].text == "9:world" + + # Prompt tests + p1 = await client.get_prompt("sync_prompt", {"name": "carol"}) + assert p1.messages[0].content.text == "hello carol" + + p2 = await client.get_prompt("async_prompt", {"name": "dave"}) + assert p2.messages[0].content.text == "async hello dave" + + # Resource tests + r1 = await client.read_resource("test://gadget") + assert r1.contents[0].text == "content:gadget" + + r2 = await client.read_resource("async://widget") + assert r2.contents[0].text == "async_content:widget"