Jeremiah Lowin commited on
Commit
6a92d5e
·
unverified ·
2 Parent(s): e29e8a6e58ace8

Merge pull request #626 from deepak-stratforge/feature/exclude-args-support

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -226,6 +226,7 @@ class OpenAPITool(Tool):
226
  tags: set[str] = set(),
227
  timeout: float | None = None,
228
  annotations: ToolAnnotations | None = None,
 
229
  serializer: Callable[[Any], str] | None = None,
230
  ):
231
  super().__init__(
@@ -235,6 +236,7 @@ class OpenAPITool(Tool):
235
  fn=self._execute_request, # We'll use an instance method instead of a global function
236
  tags=tags,
237
  annotations=annotations,
 
238
  serializer=serializer,
239
  )
240
  self._client = client
 
226
  tags: set[str] = set(),
227
  timeout: float | None = None,
228
  annotations: ToolAnnotations | None = None,
229
+ exclude_args: list[str] | None = None,
230
  serializer: Callable[[Any], str] | None = None,
231
  ):
232
  super().__init__(
 
236
  fn=self._execute_request, # We'll use an instance method instead of a global function
237
  tags=tags,
238
  annotations=annotations,
239
+ exclude_args=exclude_args,
240
  serializer=serializer,
241
  )
242
  self._client = client
src/fastmcp/server/server.py CHANGED
@@ -501,6 +501,7 @@ class FastMCP(Generic[LifespanResultT]):
501
  description: str | None = None,
502
  tags: set[str] | None = None,
503
  annotations: ToolAnnotations | dict[str, Any] | None = None,
 
504
  ) -> None:
505
  """Add a tool to the server.
506
 
@@ -523,6 +524,7 @@ class FastMCP(Generic[LifespanResultT]):
523
  description=description,
524
  tags=tags,
525
  annotations=annotations,
 
526
  )
527
  self._cache.clear()
528
 
@@ -544,6 +546,7 @@ class FastMCP(Generic[LifespanResultT]):
544
  description: str | None = None,
545
  tags: set[str] | None = None,
546
  annotations: ToolAnnotations | dict[str, Any] | None = None,
 
547
  ) -> Callable[[AnyFunction], AnyFunction]:
548
  """Decorator to register a tool.
549
 
@@ -587,6 +590,7 @@ class FastMCP(Generic[LifespanResultT]):
587
  description=description,
588
  tags=tags,
589
  annotations=annotations,
 
590
  )
591
  return fn
592
 
 
501
  description: str | None = None,
502
  tags: set[str] | None = None,
503
  annotations: ToolAnnotations | dict[str, Any] | None = None,
504
+ exclude_args: list[str] | None = None,
505
  ) -> None:
506
  """Add a tool to the server.
507
 
 
524
  description=description,
525
  tags=tags,
526
  annotations=annotations,
527
+ exclude_args=exclude_args,
528
  )
529
  self._cache.clear()
530
 
 
546
  description: str | None = None,
547
  tags: set[str] | None = None,
548
  annotations: ToolAnnotations | dict[str, Any] | None = None,
549
+ exclude_args: list[str] | None = None,
550
  ) -> Callable[[AnyFunction], AnyFunction]:
551
  """Decorator to register a tool.
552
 
 
590
  description=description,
591
  tags=tags,
592
  annotations=annotations,
593
+ exclude_args=exclude_args,
594
  )
595
  return fn
596
 
src/fastmcp/tools/tool.py CHANGED
@@ -46,6 +46,10 @@ class Tool(BaseModel):
46
  annotations: ToolAnnotations | None = Field(
47
  None, description="Additional annotations about the tool"
48
  )
 
 
 
 
49
  serializer: Callable[[Any], str] | None = Field(
50
  None, description="Optional custom serializer for tool results"
51
  )
@@ -58,6 +62,7 @@ class Tool(BaseModel):
58
  description: str | None = None,
59
  tags: set[str] | None = None,
60
  annotations: ToolAnnotations | None = None,
 
61
  serializer: Callable[[Any], str] | None = None,
62
  ) -> Tool:
63
  """Create a Tool from a function."""
@@ -71,6 +76,18 @@ class Tool(BaseModel):
71
  if param.kind == inspect.Parameter.VAR_KEYWORD:
72
  raise ValueError("Functions with **kwargs are not supported as tools")
73
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
75
 
76
  if func_name == "<lambda>":
@@ -86,10 +103,14 @@ class Tool(BaseModel):
86
  schema = type_adapter.json_schema()
87
 
88
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
 
89
  if context_kwarg:
90
- prune_params = [context_kwarg]
91
- else:
92
- prune_params = None
 
 
 
93
 
94
  schema = compress_schema(schema, prune_params=prune_params)
95
 
@@ -100,6 +121,7 @@ class Tool(BaseModel):
100
  parameters=schema,
101
  tags=tags or set(),
102
  annotations=annotations,
 
103
  serializer=serializer,
104
  )
105
 
 
46
  annotations: ToolAnnotations | None = Field(
47
  None, description="Additional annotations about the tool"
48
  )
49
+ exclude_args: list[str] | None = Field(
50
+ None,
51
+ description="Arguments to exclude from the tool schema, such as State, Memory, or Credential",
52
+ )
53
  serializer: Callable[[Any], str] | None = Field(
54
  None, description="Optional custom serializer for tool results"
55
  )
 
62
  description: str | None = None,
63
  tags: set[str] | None = None,
64
  annotations: ToolAnnotations | None = None,
65
+ exclude_args: list[str] | None = None,
66
  serializer: Callable[[Any], str] | None = None,
67
  ) -> Tool:
68
  """Create a Tool from a function."""
 
76
  if param.kind == inspect.Parameter.VAR_KEYWORD:
77
  raise ValueError("Functions with **kwargs are not supported as tools")
78
 
79
+ if exclude_args:
80
+ for arg_name in exclude_args:
81
+ if arg_name not in sig.parameters:
82
+ raise ValueError(
83
+ f"Parameter '{arg_name}' in exclude_args does not exist in function."
84
+ )
85
+ param = sig.parameters[arg_name]
86
+ if param.default == inspect.Parameter.empty:
87
+ raise ValueError(
88
+ f"Parameter '{arg_name}' in exclude_args must have a default value."
89
+ )
90
+
91
  func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
92
 
93
  if func_name == "<lambda>":
 
103
  schema = type_adapter.json_schema()
104
 
105
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
106
+ temp_prune_params: list[str] = []
107
  if context_kwarg:
108
+ temp_prune_params.append(context_kwarg)
109
+ if exclude_args:
110
+ temp_prune_params.extend(exclude_args)
111
+ prune_params: list[str] | None = (
112
+ None if not temp_prune_params else temp_prune_params
113
+ )
114
 
115
  schema = compress_schema(schema, prune_params=prune_params)
116
 
 
121
  parameters=schema,
122
  tags=tags or set(),
123
  annotations=annotations,
124
+ exclude_args=exclude_args,
125
  serializer=serializer,
126
  )
127
 
src/fastmcp/tools/tool_manager.py CHANGED
@@ -66,6 +66,7 @@ class ToolManager:
66
  description: str | None = None,
67
  tags: set[str] | None = None,
68
  annotations: ToolAnnotations | None = None,
 
69
  ) -> Tool:
70
  """Add a tool to the server."""
71
  tool = Tool.from_function(
@@ -75,6 +76,7 @@ class ToolManager:
75
  tags=tags,
76
  annotations=annotations,
77
  serializer=self._serializer,
 
78
  )
79
  return self.add_tool(tool)
80
 
 
66
  description: str | None = None,
67
  tags: set[str] | None = None,
68
  annotations: ToolAnnotations | None = None,
69
+ exclude_args: list[str] | None = None,
70
  ) -> Tool:
71
  """Add a tool to the server."""
72
  tool = Tool.from_function(
 
76
  tags=tags,
77
  annotations=annotations,
78
  serializer=self._serializer,
79
+ exclude_args=exclude_args,
80
  )
81
  return self.add_tool(tool)
82
 
tests/server/test_tool_exclude_args.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ import pytest
4
+ from mcp.types import TextContent
5
+
6
+ from fastmcp import Client, FastMCP
7
+
8
+
9
+ async def test_tool_exclude_args_in_tool_manager():
10
+ """Test that tool args are excluded in the tool manager."""
11
+ mcp = FastMCP("Test Server")
12
+
13
+ @mcp.tool(exclude_args=["state"])
14
+ def echo(message: str, state: dict[str, Any] | None = None) -> str:
15
+ """Echo back the message provided."""
16
+ if state:
17
+ # State was read
18
+ pass
19
+ return message
20
+
21
+ tools = mcp._tool_manager.list_tools()
22
+ assert len(tools) == 1
23
+ assert tools[0].exclude_args is not None
24
+ for args in tools[0].exclude_args:
25
+ assert args not in tools[0].parameters
26
+
27
+
28
+ async def test_tool_exclude_args_without_default_value_raises_error():
29
+ """Test that excluding args without default values raises ValueError"""
30
+ mcp = FastMCP("Test Server")
31
+
32
+ with pytest.raises(ValueError):
33
+
34
+ @mcp.tool(exclude_args=["state"])
35
+ def echo(message: str, state: dict[str, Any] | None) -> str:
36
+ """Echo back the message provided."""
37
+ if state:
38
+ # State was read
39
+ pass
40
+ return message
41
+
42
+
43
+ async def test_add_tool_method_exclude_args():
44
+ """Test that tool exclude_args work with the add_tool method."""
45
+ mcp = FastMCP("Test Server")
46
+
47
+ def create_item(
48
+ name: str, value: int, state: dict[str, Any] | None = None
49
+ ) -> dict[str, Any]:
50
+ """Create a new item."""
51
+ if state:
52
+ # State was read
53
+ pass
54
+ return {"name": name, "value": value}
55
+
56
+ mcp.add_tool(create_item, name="create_item", exclude_args=["state"])
57
+
58
+ # Check internal tool objects directly
59
+ tools = mcp._tool_manager.list_tools()
60
+ assert len(tools) == 1
61
+ assert tools[0].exclude_args is not None
62
+ assert tools[0].exclude_args == ["state"]
63
+ for args in tools[0].exclude_args:
64
+ assert args not in tools[0].parameters
65
+
66
+
67
+ async def test_tool_functionality_with_exclude_args():
68
+ """Test that tool functionality is preserved when using exclude_args."""
69
+ mcp = FastMCP("Test Server")
70
+
71
+ def create_item(
72
+ name: str, value: int, state: dict[str, Any] | None = None
73
+ ) -> dict[str, Any]:
74
+ """Create a new item."""
75
+ if state:
76
+ # state was read
77
+ pass
78
+ return {"name": name, "value": value}
79
+
80
+ mcp.add_tool(create_item, name="create_item", exclude_args=["state"])
81
+
82
+ # Use the tool to verify functionality is preserved
83
+ async with Client(mcp) as client:
84
+ result = await client.call_tool(
85
+ "create_item", {"name": "test_item", "value": 42}
86
+ )
87
+ assert len(result) == 1
88
+ assert isinstance(result[0], TextContent)
89
+
90
+ # The result should contain the expected JSON
91
+ assert '"name": "test_item"' in result[0].text
92
+ assert '"value": 42' in result[0].text