strawgate commited on
Commit
09d8104
·
1 Parent(s): 066be76

Initial Implementation

Browse files
src/contrib/bulk_tool_caller/README.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bulk Tool Caller
2
+
3
+ This module provides the `BulkToolCaller` class, which extends the `MCPMixin` to offer tools for performing multiple tool calls in a single request to a FastMCP server. This can be useful for optimizing interactions with the server by reducing the overhead of individual tool calls.
4
+
5
+ ## Usage
6
+
7
+ To use the `BulkToolCaller`, see the example [example.py](./example.py) file. The `BulkToolCaller` can be instantiated and then registered with a FastMCP server URL. It provides methods to call multiple tools in bulk, either different tools or the same tool with different arguments.
8
+
9
+
10
+ ## Provided Tools
11
+
12
+ The `BulkToolCaller` provides the following tools:
13
+
14
+ ### `call_tools_bulk`
15
+
16
+ Calls multiple different tools registered on the MCP server in a single request.
17
+
18
+ - **Arguments:**
19
+ - `tool_calls` (list of `CallToolRequest`): A list of objects, where each object specifies the `tool` name and `arguments` for an individual tool call.
20
+ - `continue_on_error` (bool, optional): If `True`, continue executing subsequent tool calls even if a previous one resulted in an error. Defaults to `True`.
21
+
22
+ - **Returns:**
23
+ A list of `CallToolRequestResult` objects, each containing the result (`isError`, `content`) and the original `tool` name and `arguments` for each call.
24
+
25
+ ### `call_tool_bulk`
26
+
27
+ Calls a single tool registered on the MCP server multiple times with different arguments in a single request.
28
+
29
+ - **Arguments:**
30
+ - `tool` (str): The name of the tool to call.
31
+ - `tool_arguments` (list of dict): A list of dictionaries, where each dictionary contains the arguments for an individual run of the tool.
32
+ - `continue_on_error` (bool, optional): If `True`, continue executing subsequent tool calls even if a previous one resulted in an error. Defaults to `True`.
33
+
34
+ - **Returns:**
35
+ A list of `CallToolRequestResult` objects, each containing the result (`isError`, `content`) and the original `tool` name and `arguments` for each call.
src/contrib/bulk_tool_caller/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .bulk_tool_caller import BulkToolCaller
2
+
3
+ __all__ = ["BulkToolCaller"]
src/contrib/bulk_tool_caller/bulk_tool_caller.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from mcp.types import CallToolResult
4
+ from pydantic import BaseModel, Field
5
+
6
+ from contrib.mcp_mixin.mcp_mixin import _DEFAULT_SEPARATOR_TOOL, MCPMixin, mcp_tool
7
+ from fastmcp import FastMCP
8
+ from fastmcp.client import Client
9
+ from fastmcp.client.transports import FastMCPTransport
10
+
11
+
12
+ class CallToolRequest(BaseModel):
13
+ """A class to represent a request to call a tool with specific arguments."""
14
+
15
+ tool: str = Field(description="The name of the tool to call.")
16
+ arguments: dict[str, Any] = Field(
17
+ description="A dictionary containing the arguments for the tool call."
18
+ )
19
+
20
+
21
+ class CallToolRequestResult(CallToolResult):
22
+ """
23
+ A class to represent the result of a bulk tool call.
24
+ It extends CallToolResult to include information about the requested tool call.
25
+ """
26
+
27
+ tool: str = Field(description="The name of the tool that was called.")
28
+ arguments: dict[str, Any] = Field(
29
+ description="The arguments used for the tool call."
30
+ )
31
+
32
+ @classmethod
33
+ def from_call_tool_result(
34
+ cls, result: CallToolResult, tool: str, arguments: dict[str, Any]
35
+ ) -> "CallToolRequestResult":
36
+ """
37
+ Create a CallToolRequestResult from a CallToolResult.
38
+ """
39
+ return cls(
40
+ tool=tool,
41
+ arguments=arguments,
42
+ isError=result.isError,
43
+ content=result.content,
44
+ )
45
+
46
+
47
+ class BulkToolCaller(MCPMixin):
48
+ """
49
+ A class to provide a "bulk tool call" tool for a FastMCP server
50
+ """
51
+
52
+ def register_tools(
53
+ self,
54
+ mcp_server: "FastMCP",
55
+ prefix: str | None = None,
56
+ separator: str = _DEFAULT_SEPARATOR_TOOL,
57
+ ) -> None:
58
+ """
59
+ Register the tools provided by this class with the given MCP server.
60
+ """
61
+ self.connection = FastMCPTransport(mcp_server)
62
+
63
+ super().register_tools(mcp_server=mcp_server)
64
+
65
+ @mcp_tool()
66
+ async def call_tools_bulk(
67
+ self, tool_calls: list[CallToolRequest], continue_on_error: bool = True
68
+ ) -> list[CallToolRequestResult]:
69
+ """
70
+ Call multiple tools registered on this MCP server in a single request. Each call can
71
+ be for a different tool and can include different arguments. Useful for speeding up
72
+ what would otherwise take several individual tool calls.
73
+ """
74
+ results = []
75
+
76
+ for tool_call in tool_calls:
77
+ result = await self._call_tool(tool_call.tool, tool_call.arguments)
78
+
79
+ results.append(result)
80
+
81
+ if result.isError and not continue_on_error:
82
+ return results
83
+
84
+ return results
85
+
86
+ @mcp_tool()
87
+ async def call_tool_bulk(
88
+ self,
89
+ tool: str,
90
+ tool_arguments: list[dict[str, str | int | float | bool | None]],
91
+ continue_on_error: bool = True,
92
+ ) -> list[CallToolRequestResult]:
93
+ """
94
+ Call a single tool registered on this MCP server multiple times with a single request.
95
+ Each call can include different arguments. Useful for speeding up what would otherwise
96
+ take several individual tool calls.
97
+
98
+ Args:
99
+ tool: The name of the tool to call.
100
+ tool_arguments: A list of dictionaries, where each dictionary contains the arguments for an individual run of the tool.
101
+ """
102
+ results = []
103
+
104
+ for tool_call_arguments in tool_arguments:
105
+ result = await self._call_tool(tool, tool_call_arguments)
106
+
107
+ results.append(result)
108
+
109
+ if result.isError and not continue_on_error:
110
+ return results
111
+
112
+ return results
113
+
114
+ async def _call_tool(
115
+ self, tool: str, arguments: dict[str, Any]
116
+ ) -> CallToolRequestResult:
117
+ """
118
+ Helper method to call a tool with the provided arguments.
119
+ """
120
+
121
+ async with Client(self.connection) as client:
122
+ result = await client.call_tool(
123
+ name=tool, arguments=arguments, _return_raw_result=True
124
+ )
125
+
126
+ return CallToolRequestResult(
127
+ tool=tool,
128
+ arguments=arguments,
129
+ isError=result.isError,
130
+ content=result.content,
131
+ )
src/contrib/bulk_tool_caller/example.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sample code for FastMCP using MCPMixin."""
2
+
3
+ from contrib.bulk_tool_caller import BulkToolCaller
4
+ from fastmcp import FastMCP
5
+
6
+ mcp = FastMCP()
7
+
8
+
9
+ @mcp.tool()
10
+ def echo_tool(text: str) -> str:
11
+ """Echo the input text"""
12
+ return text
13
+
14
+
15
+ bulk_tool_caller = BulkToolCaller()
16
+
17
+ bulk_tool_caller.register_tools(mcp)
tests/contrib/test_bulk_tool_caller.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ import pytest
4
+ from mcp.types import EmbeddedResource, ImageContent, TextContent
5
+
6
+ from contrib.bulk_tool_caller.bulk_tool_caller import (
7
+ BulkToolCaller,
8
+ CallToolRequest,
9
+ CallToolRequestResult,
10
+ )
11
+ from fastmcp import FastMCP
12
+
13
+ ContentType = TextContent | ImageContent | EmbeddedResource
14
+
15
+
16
+ class ToolException(Exception):
17
+ """Custom exception for tool errors."""
18
+
19
+ pass
20
+
21
+
22
+ async def error_tool(arg1: str) -> dict[str, Any]:
23
+ """A tool that raises an error for testing purposes."""
24
+ raise ToolException(f"Error in tool with arg1: {arg1}")
25
+
26
+
27
+ def error_tool_result_factory(arg1: str) -> CallToolRequestResult:
28
+ """Generates the expected error result for error_tool."""
29
+ # Mimic the error message format generated by BulkToolCaller when catching ToolException
30
+ exception_message = f"Error in tool with arg1: {arg1}"
31
+ formatted_error_text = f"Error executing tool error_tool: {exception_message}"
32
+ return CallToolRequestResult(
33
+ isError=True,
34
+ content=[TextContent(text=formatted_error_text, type="text")],
35
+ tool="error_tool",
36
+ arguments={"arg1": arg1},
37
+ )
38
+
39
+
40
+ async def echo_tool(arg1: str) -> str:
41
+ """A simple tool that echoes arguments or raises an error."""
42
+ return arg1
43
+
44
+
45
+ def echo_tool_result_factory(arg1: str) -> CallToolRequestResult:
46
+ """A tool that returns a result based on the input arguments."""
47
+ return CallToolRequestResult(
48
+ isError=False,
49
+ content=[TextContent(text=f"{arg1}", type="text")],
50
+ tool="echo_tool",
51
+ arguments={"arg1": arg1},
52
+ )
53
+
54
+
55
+ async def no_return_tool(arg1: str) -> None:
56
+ """A simple tool that echoes arguments or raises an error."""
57
+
58
+
59
+ def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult:
60
+ """A tool that returns a result based on the input arguments."""
61
+ return CallToolRequestResult(
62
+ isError=False, content=[], tool="no_return_tool", arguments={"arg1": arg1}
63
+ )
64
+
65
+
66
+ @pytest.fixture(scope="module")
67
+ def live_server_with_tool() -> FastMCP:
68
+ """Fixture to create a FastMCP server instance with the echo_tool registered."""
69
+ server = FastMCP()
70
+ server.add_tool(echo_tool)
71
+ server.add_tool(error_tool)
72
+ server.add_tool(no_return_tool)
73
+ return server
74
+
75
+
76
+ @pytest.fixture
77
+ def bulk_caller_live(live_server_with_tool: FastMCP) -> BulkToolCaller:
78
+ """Fixture to create a BulkToolCaller instance connected to the live server."""
79
+ bulk_tool_caller = BulkToolCaller()
80
+ bulk_tool_caller.register_tools(live_server_with_tool)
81
+ return bulk_tool_caller
82
+
83
+
84
+ ECHO_TOOL_NAME = "echo_tool"
85
+ ERROR_TOOL_NAME = "error_tool"
86
+ NO_RETURN_TOOL_NAME = "no_return_tool"
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
91
+ """Test single successful call via call_tool_bulk using echo_tool."""
92
+ tool_arguments = [{"arg1": "value1"}]
93
+ expected_result = echo_tool_result_factory(**tool_arguments[0])
94
+
95
+ results = await bulk_caller_live.call_tool_bulk(ECHO_TOOL_NAME, tool_arguments)
96
+
97
+ assert len(results) == 1
98
+ result = results[0]
99
+ assert result == expected_result
100
+
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
104
+ """Test multiple successful calls via call_tool_bulk using echo_tool."""
105
+ tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}]
106
+ expected_results = [echo_tool_result_factory(**args) for args in tool_arguments]
107
+
108
+ results = await bulk_caller_live.call_tool_bulk(ECHO_TOOL_NAME, tool_arguments)
109
+
110
+ assert len(results) == 2
111
+ assert results == expected_results
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
116
+ """Test call_tool_bulk stops on first error using error_tool."""
117
+ tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}]
118
+ expected_result = error_tool_result_factory(**tool_arguments[0])
119
+
120
+ results = await bulk_caller_live.call_tool_bulk(
121
+ ERROR_TOOL_NAME, tool_arguments, continue_on_error=False
122
+ )
123
+
124
+ assert len(results) == 1
125
+ result = results[0]
126
+ assert result == expected_result
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
131
+ """Test call_tool_bulk continues on error using error_tool and echo_tool."""
132
+ tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}]
133
+ expected_error_result = error_tool_result_factory(**tool_arguments[0])
134
+ expected_success_result = echo_tool_result_factory(**tool_arguments[1])
135
+
136
+ tool_calls = [
137
+ CallToolRequest(tool=ERROR_TOOL_NAME, arguments=tool_arguments[0]),
138
+ CallToolRequest(tool=ECHO_TOOL_NAME, arguments=tool_arguments[1]),
139
+ ]
140
+
141
+ results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True)
142
+
143
+ assert len(results) == 2
144
+
145
+ error_result = results[0]
146
+ assert error_result == expected_error_result
147
+
148
+ success_result = results[1]
149
+ assert success_result == expected_success_result
150
+
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
154
+ """Test single successful call via call_tools_bulk using echo_tool."""
155
+ tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})]
156
+ expected_result = echo_tool_result_factory(**tool_calls[0].arguments)
157
+
158
+ results = await bulk_caller_live.call_tools_bulk(tool_calls)
159
+
160
+ assert len(results) == 1
161
+ result = results[0]
162
+ assert result == expected_result
163
+
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
167
+ """Test multiple successful calls via call_tools_bulk with different tools."""
168
+ tool_calls = [
169
+ CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "echo_value"}),
170
+ CallToolRequest(
171
+ tool=NO_RETURN_TOOL_NAME, arguments={"arg1": "no_return_value"}
172
+ ),
173
+ ]
174
+ expected_results = [
175
+ echo_tool_result_factory(**tool_calls[0].arguments),
176
+ no_return_tool_result_factory(**tool_calls[1].arguments),
177
+ ]
178
+
179
+ results = await bulk_caller_live.call_tools_bulk(tool_calls)
180
+
181
+ assert len(results) == 2
182
+ assert results == expected_results
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
187
+ """Test call_tools_bulk stops on first error using error_tool."""
188
+ tool_calls = [
189
+ CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}),
190
+ CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "skipped_value"}),
191
+ ]
192
+ expected_result = error_tool_result_factory(**tool_calls[0].arguments)
193
+
194
+ results = await bulk_caller_live.call_tools_bulk(
195
+ tool_calls, continue_on_error=False
196
+ )
197
+
198
+ assert len(results) == 1
199
+ result = results[0]
200
+ assert result == expected_result
201
+
202
+
203
+ @pytest.mark.asyncio
204
+ async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller):
205
+ """Test call_tools_bulk continues on error using error_tool and echo_tool."""
206
+ tool_calls = [
207
+ CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}),
208
+ CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "success_value"}),
209
+ ]
210
+ expected_error_result = error_tool_result_factory(**tool_calls[0].arguments)
211
+ expected_success_result = echo_tool_result_factory(**tool_calls[1].arguments)
212
+
213
+ results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True)
214
+
215
+ assert len(results) == 2
216
+
217
+ error_result = results[0]
218
+ assert error_result == expected_error_result
219
+
220
+ success_result = results[1]
221
+ assert success_result == expected_success_result