mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Handle args/kwargs appropriately for various objects
This commit is contained in:
parent
55e62a0c63
commit
c449f1d697
11 changed files with 188 additions and 21 deletions
|
|
@ -53,6 +53,9 @@ def generate_code_request(language: str, task_description: str) -> UserMessage:
|
|||
* **Inferred Metadata:** By default:
|
||||
* Prompt Name: Taken from the function name (`ask_about_topic`).
|
||||
* Prompt Description: Taken from the function's docstring.
|
||||
<Tip>
|
||||
Functions with `*args` or `**kwargs` are not supported as prompts. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
|
||||
</Tip>
|
||||
|
||||
### Return Values
|
||||
|
||||
|
|
@ -105,6 +108,7 @@ def generate_content_request(
|
|||
return prompt
|
||||
```
|
||||
|
||||
|
||||
### Required vs. Optional Parameters
|
||||
|
||||
Parameters in your function signature are considered **required** unless they have a default value.
|
||||
|
|
|
|||
|
|
@ -239,6 +239,10 @@ Resource templates share most configuration options with regular resources (name
|
|||
|
||||
Resource templates generate a new resource for each unique set of parameters, which means that resources can be dynamically created on-demand. For example, if the resource template `"user://profile/{name}"` is registered, MCP clients could request `"user://profile/ford"` or `"user://profile/marvin"` to retrieve either of those two user profiles as resources, without having to register each resource individually.
|
||||
|
||||
<Tip>
|
||||
Functions with `*args` are not supported as resource templates. However, unlike tools and prompts, resource templates do support `**kwargs` because the URI template defines specific parameter names that will be collected and passed as keyword arguments.
|
||||
</Tip>
|
||||
|
||||
Here is a complete example that shows how to define two resource templates:
|
||||
|
||||
```python
|
||||
|
|
|
|||
|
|
@ -43,9 +43,12 @@ When this tool is registered, FastMCP automatically:
|
|||
- Generates an input schema based on the function's parameters and type annotations.
|
||||
- Handles parameter validation and error reporting.
|
||||
|
||||
|
||||
The way you define your Python function dictates how the tool appears and behaves for the LLM client.
|
||||
|
||||
<Tip>
|
||||
Functions with `*args` or `**kwargs` are not supported as tools. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
|
||||
</Tip>
|
||||
|
||||
### Parameters
|
||||
|
||||
#### Annotations
|
||||
|
|
@ -90,6 +93,7 @@ def process_image(
|
|||
# Implementation...
|
||||
```
|
||||
|
||||
|
||||
You can also use the Field as a default value, though the Annotated approach is preferred:
|
||||
|
||||
```python
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@ class Prompt(BaseModel):
|
|||
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
# Reject functions with *args or **kwargs
|
||||
sig = inspect.signature(fn)
|
||||
for param in sig.parameters.values():
|
||||
if param.kind == inspect.Parameter.VAR_POSITIONAL:
|
||||
raise ValueError("Functions with *args are not supported as prompts")
|
||||
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
raise ValueError("Functions with **kwargs are not supported as prompts")
|
||||
|
||||
type_adapter = get_cached_typeadapter(fn)
|
||||
parameters = type_adapter.json_schema()
|
||||
|
|
|
|||
|
|
@ -109,6 +109,15 @@ class ResourceTemplate(BaseModel):
|
|||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
||||
# Reject functions with *args
|
||||
# (**kwargs is allowed because the URI will define the parameter names)
|
||||
sig = inspect.signature(fn)
|
||||
for param in sig.parameters.values():
|
||||
if param.kind == inspect.Parameter.VAR_POSITIONAL:
|
||||
raise ValueError(
|
||||
"Functions with *args are not supported as resource templates"
|
||||
)
|
||||
|
||||
# Auto-detect context parameter if not provided
|
||||
if context_kwarg is None:
|
||||
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
||||
|
|
@ -118,7 +127,7 @@ class ResourceTemplate(BaseModel):
|
|||
if not uri_params:
|
||||
raise ValueError("URI template must contain at least one parameter")
|
||||
|
||||
func_params = set(inspect.signature(fn).parameters.keys())
|
||||
func_params = set(sig.parameters.keys())
|
||||
if context_kwarg:
|
||||
func_params.discard(context_kwarg)
|
||||
|
||||
|
|
@ -126,20 +135,26 @@ class ResourceTemplate(BaseModel):
|
|||
required_params = {
|
||||
p
|
||||
for p in func_params
|
||||
if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
|
||||
if sig.parameters[p].default is inspect.Parameter.empty
|
||||
and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
|
||||
and p != context_kwarg
|
||||
}
|
||||
if context_kwarg and context_kwarg in required_params:
|
||||
required_params.discard(context_kwarg)
|
||||
|
||||
# Check if required parameters are a subset of the URI parameters
|
||||
if not required_params.issubset(uri_params):
|
||||
raise ValueError(
|
||||
f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}"
|
||||
f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}"
|
||||
)
|
||||
|
||||
if not uri_params.issubset(func_params):
|
||||
raise ValueError(
|
||||
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
|
||||
)
|
||||
# CHeck if the URI parameters are a subset of the function parameters (skip if **kwargs present)
|
||||
if not any(
|
||||
param.kind == inspect.Parameter.VAR_KEYWORD
|
||||
for param in sig.parameters.values()
|
||||
):
|
||||
if not uri_params.issubset(func_params):
|
||||
raise ValueError(
|
||||
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
|
||||
)
|
||||
|
||||
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
||||
parameters = TypeAdapter(fn).json_schema()
|
||||
|
|
|
|||
|
|
@ -67,6 +67,14 @@ class Tool(BaseModel):
|
|||
"""Create a Tool from a function."""
|
||||
from fastmcp import Context
|
||||
|
||||
# Reject functions with *args or **kwargs
|
||||
sig = inspect.signature(fn)
|
||||
for param in sig.parameters.values():
|
||||
if param.kind == inspect.Parameter.VAR_POSITIONAL:
|
||||
raise ValueError("Functions with *args are not supported as tools")
|
||||
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
raise ValueError("Functions with **kwargs are not supported as tools")
|
||||
|
||||
func_name = name or fn.__name__
|
||||
|
||||
if func_name == "<lambda>":
|
||||
|
|
|
|||
|
|
@ -189,6 +189,30 @@ class TestPromptManager:
|
|||
with pytest.raises(ValueError, match="Missing required arguments"):
|
||||
await manager.render_prompt("fn")
|
||||
|
||||
async def test_prompt_with_varargs_not_allowed(self):
|
||||
"""Test that a prompt with *args is not allowed."""
|
||||
|
||||
def fn(*args: int) -> str:
|
||||
return f"Hello, {args}!"
|
||||
|
||||
manager = PromptManager()
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*args are not supported as prompts"
|
||||
):
|
||||
manager.add_prompt(Prompt.from_function(fn))
|
||||
|
||||
async def test_prompt_with_varkwargs_not_allowed(self):
|
||||
"""Test that a prompt with **kwargs is not allowed."""
|
||||
|
||||
def fn(**kwargs: int) -> str:
|
||||
return f"Hello, {kwargs}!"
|
||||
|
||||
manager = PromptManager()
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*\*kwargs are not supported as prompts"
|
||||
):
|
||||
manager.add_prompt(Prompt.from_function(fn))
|
||||
|
||||
|
||||
class TestPromptTags:
|
||||
"""Test functionality related to prompt tags."""
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class TestResourceTemplate:
|
|||
# This should fail - 'unknown' is not a function parameter
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
match="Required function arguments .* must be a subset of the URI parameters",
|
||||
):
|
||||
ResourceTemplate.from_function(
|
||||
fn=my_func,
|
||||
|
|
@ -132,7 +132,7 @@ class TestResourceTemplate:
|
|||
# This should fail - required param is not in URI
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
match="Required function arguments .* must be a subset of the URI parameters",
|
||||
):
|
||||
ResourceTemplate.from_function(
|
||||
fn=func_with_required,
|
||||
|
|
@ -157,7 +157,7 @@ class TestResourceTemplate:
|
|||
# This fails - missing one required param
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
match="Required function arguments .* must be a subset of the URI parameters",
|
||||
):
|
||||
ResourceTemplate.from_function(
|
||||
fn=multi_required,
|
||||
|
|
@ -360,6 +360,31 @@ class TestResourceTemplate:
|
|||
params = template.matches("test://src/path/to/test.py")
|
||||
assert params == {"prefix": "src", "path": "path/to/test.py"}
|
||||
|
||||
async def test_function_with_varargs_not_allowed(self):
|
||||
def func(x: int, *args: int) -> int:
|
||||
return x + sum(args)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"Functions with \*args are not supported as resource templates",
|
||||
):
|
||||
ResourceTemplate.from_function(
|
||||
fn=func,
|
||||
uri_template="test://{x}/{args*}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
async def test_function_with_varkwargs_ok(self):
|
||||
def func(x: int, **kwargs: int) -> int:
|
||||
return x + sum(kwargs.values())
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=func,
|
||||
uri_template="test://{x}/{y}/{z}",
|
||||
name="test",
|
||||
)
|
||||
assert template.uri_template == "test://{x}/{y}/{z}"
|
||||
|
||||
|
||||
class TestMatchUriTemplate:
|
||||
"""Test match_uri_template function."""
|
||||
|
|
|
|||
|
|
@ -930,7 +930,7 @@ class TestResourceTemplates:
|
|||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
match="Required function arguments .* must be a subset of the URI parameters",
|
||||
):
|
||||
|
||||
@mcp.resource("resource://{name}/data")
|
||||
|
|
@ -958,7 +958,7 @@ class TestResourceTemplates:
|
|||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
match="Required function arguments .* must be a subset of the URI parameters",
|
||||
):
|
||||
|
||||
@mcp.resource("resource://{org}/{repo}/data")
|
||||
|
|
@ -977,6 +977,19 @@ class TestResourceTemplates:
|
|||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "Static data"
|
||||
|
||||
async def test_template_with_varkwargs(self):
|
||||
"""Test that a template can have **kwargs."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("test://{x}/{y}/{z}")
|
||||
def func(**kwargs: int) -> int:
|
||||
return sum(kwargs.values())
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource(AnyUrl("test://1/2/3"))
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "6"
|
||||
|
||||
async def test_template_with_default_params(self):
|
||||
"""Test that a template can have default parameters."""
|
||||
mcp = FastMCP()
|
||||
|
|
|
|||
|
|
@ -63,15 +63,15 @@ class TestToolFromFunction:
|
|||
assert tool.parameters["properties"]["data"]["type"] == "string"
|
||||
assert isinstance(result[0], ImageContent)
|
||||
|
||||
def test_add_invalid_tool(self):
|
||||
with pytest.raises(AttributeError):
|
||||
def test_non_callable_fn(self):
|
||||
with pytest.raises(TypeError, match="not a callable object"):
|
||||
Tool.from_function(1) # type: ignore
|
||||
|
||||
def test_add_lambda(self):
|
||||
def test_lambda(self):
|
||||
tool = Tool.from_function(lambda x: x, name="my_tool")
|
||||
assert tool.name == "my_tool"
|
||||
|
||||
def test_add_lambda_with_no_name(self):
|
||||
def test_lambda_with_no_name(self):
|
||||
with pytest.raises(
|
||||
ValueError, match="You must provide a name for lambda functions"
|
||||
):
|
||||
|
|
@ -86,6 +86,69 @@ class TestToolFromFunction:
|
|||
assert tool.parameters["properties"]["_a"]["type"] == "integer"
|
||||
assert tool.parameters["properties"]["_b"]["type"] == "integer"
|
||||
|
||||
def test_tool_with_varargs_not_allowed(self):
|
||||
def func(a: int, b: int, *args: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*args are not supported as tools"
|
||||
):
|
||||
Tool.from_function(func)
|
||||
|
||||
def test_tool_with_varkwargs_not_allowed(self):
|
||||
def func(a: int, b: int, **kwargs: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
|
||||
):
|
||||
Tool.from_function(func)
|
||||
|
||||
async def test_instance_method(self):
|
||||
class MyClass:
|
||||
def add(self, x: int, y: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return x + y
|
||||
|
||||
obj = MyClass()
|
||||
|
||||
tool = Tool.from_function(obj.add)
|
||||
assert tool.name == "add"
|
||||
assert tool.description == "Add two numbers."
|
||||
assert "self" not in tool.parameters["properties"]
|
||||
|
||||
async def test_instance_method_with_varargs_not_allowed(self):
|
||||
class MyClass:
|
||||
def add(self, x: int, y: int, *args: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return x + y
|
||||
|
||||
obj = MyClass()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*args are not supported as tools"
|
||||
):
|
||||
Tool.from_function(obj.add)
|
||||
|
||||
async def test_instance_method_with_varkwargs_not_allowed(self):
|
||||
class MyClass:
|
||||
def add(self, x: int, y: int, **kwargs: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return x + y
|
||||
|
||||
obj = MyClass()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
|
||||
):
|
||||
Tool.from_function(obj.add)
|
||||
|
||||
async def test_classmethod(self):
|
||||
class MyClass:
|
||||
x: int = 10
|
||||
|
||||
|
||||
class TestToolJsonParsing:
|
||||
"""Tests for Tool's JSON pre-parsing functionality."""
|
||||
|
|
|
|||
|
|
@ -84,9 +84,9 @@ class TestAddTools:
|
|||
assert tool.parameters["properties"]["data"]["type"] == "string"
|
||||
assert isinstance(result[0], ImageContent)
|
||||
|
||||
def test_add_invalid_tool(self):
|
||||
def test_add_noncallable_tool(self):
|
||||
manager = ToolManager()
|
||||
with pytest.raises(AttributeError):
|
||||
with pytest.raises(TypeError, match="not a callable object"):
|
||||
manager.add_tool_from_fn(1) # type: ignore
|
||||
|
||||
def test_add_lambda(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue