Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/mcp/server/mcpserver/prompts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/mcp/server/mcpserver/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions src/mcp/server/mcpserver/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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; "
Expand Down
38 changes: 38 additions & 0 deletions src/mcp/shared/_callable_inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 120 additions & 0 deletions tests/server/mcpserver/test_posonly_handlers.py
Original file line number Diff line number Diff line change
@@ -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"
Loading