Skip to content

Conversation

@strawgate
Copy link
Collaborator

@strawgate strawgate commented Oct 26, 2025

Summary

Converts the BulkToolCaller from an MCPMixin-based implementation to a ToolInjectionMiddleware, following the pattern established in #2142. This simplifies usage and aligns with other tool injection middleware like PromptToolMiddleware and ResourceToolMiddleware.

Changes

  • Created BulkToolCallerMiddleware extending ToolInjectionMiddleware
  • Added bulk_tool_caller_types.py to avoid circular imports
  • Deprecated original BulkToolCaller with clear migration guidance
  • Maintained backward compatibility by re-exporting types
  • Added comprehensive test suite for middleware

Usage

New approach:

mcp = FastMCP(middleware=[BulkToolCallerMiddleware()])

Old approach (deprecated):

bulk_tool_caller = BulkToolCaller()
bulk_tool_caller.register_tools(mcp)

Closes #2262

🤖 Generated with Claude Code) | View branch | [View job run](https://github.com/jlowin/fastmcp/actions/runs/18819279688

Summary by CodeRabbit

  • New Features

    • Added bulk tool calling: execute multiple tools or repeated calls with varied arguments; middleware exposes call_tools_bulk and call_tool_bulk with configurable error handling.
  • Deprecations

    • BulkToolCaller class marked deprecated; middleware is the recommended approach. Public request/result types re-exported for backward compatibility.
  • Tests

    • Comprehensive tests covering success, error handling, tag-filter behavior, no-return tools, and deprecation path.

- Create BulkToolCallerMiddleware extending ToolInjectionMiddleware
- Add bulk_tool_caller_types.py to avoid circular imports
- Deprecate original BulkToolCaller class with migration guidance
- Maintain backward compatibility by re-exporting types
- Add comprehensive test suite for middleware implementation
- Use lazy import in middleware __init__.py to prevent circular dependencies

Both approaches are now available:
- New: FastMCP(middleware=[BulkToolCallerMiddleware()])
- Old (deprecated): BulkToolCaller().register_tools(mcp)

Co-authored-by: William Easton <[email protected]>
@strawgate strawgate requested a review from Copilot October 26, 2025 14:52
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR converts the BulkToolCaller from an MCPMixin-based implementation to a ToolInjectionMiddleware, simplifying usage and aligning with the architecture established in prior middleware implementations.

Key Changes:

  • Introduced BulkToolCallerMiddleware that extends ToolInjectionMiddleware
  • Deprecated the original BulkToolCaller class with migration guidance
  • Created a new types module to avoid circular imports

Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/server/middleware/test_bulk_tool_caller.py Comprehensive test suite for the new middleware implementation and deprecation behavior
src/fastmcp/server/middleware/bulk_tool_caller_types.py New types module containing CallToolRequest and CallToolRequestResult to avoid circular dependencies
src/fastmcp/server/middleware/bulk_tool_caller.py New middleware implementation with call_tools_bulk and call_tool_bulk functions
src/fastmcp/server/middleware/init.py Lazy loading support for BulkToolCallerMiddleware
src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py Deprecation warnings added and types re-exported for backward compatibility

Comment on lines 32 to 33
"""A simple tool that returns nothing."""

Copy link

Copilot AI Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The no_return_tool function is missing a docstring body. While it has a summary line, functions that intentionally return None should document this behavior explicitly.

Suggested change
"""A simple tool that returns nothing."""
"""A simple tool that returns nothing.
Returns:
None: This tool does not return any value.
"""

Copilot uses AI. Check for mistakes.
tool=tool,
arguments=arguments,
isError=result.isError,
content=result.content,
Copy link

Copilot AI Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The from_call_tool_result method doesn't copy the _meta or structuredContent fields from the source CallToolResult. If these fields contain values, they will be lost in the conversion. Consider including these fields: _meta=result._meta, structuredContent=result.structuredContent

Suggested change
content=result.content,
content=result.content,
_meta=getattr(result, "_meta", None),
structuredContent=getattr(result, "structuredContent", None),

Copilot uses AI. Check for mistakes.
Comment on lines 51 to 53
call_tool_result = CallToolResult(
content=tool_result.content,
)
Copy link

Copilot AI Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intermediate CallToolResult creation is unnecessary. You can pass tool_result.content directly to CallToolRequestResult.from_call_tool_result or create the CallToolRequestResult directly from tool_result, eliminating this temporary object.

Copilot uses AI. Check for mistakes.
Comment on lines 116 to 118
call_tool_result = CallToolResult(
content=tool_result.content,
)
Copy link

Copilot AI Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intermediate CallToolResult creation is unnecessary. You can pass tool_result.content directly to CallToolRequestResult.from_call_tool_result or create the CallToolRequestResult directly from tool_result, eliminating this temporary object.

Copilot uses AI. Check for mistakes.
@marvin-context-protocol marvin-context-protocol bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. contrib Related to community contributions in src/contrib/ directory. server Related to FastMCP server implementation or server-side functionality. labels Oct 26, 2025
@strawgate
Copy link
Collaborator Author

@coderabbitai please review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 27, 2025

@strawgate: I'll review the changes in this PR right away.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 27, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Converts the bulk tool caller into middleware: introduces BulkToolCallerMiddleware with injectable tools call_tools_bulk and call_tool_bulk, new Pydantic types CallToolRequest/CallToolRequestResult, deprecates the old BulkToolCaller while re-exporting types for backward compatibility, and adds tests covering behavior and error handling.

Changes

Cohort / File(s) Summary
New Middleware Types
src/fastmcp/server/middleware/bulk_tool_caller_types.py
Adds CallToolRequest and CallToolRequestResult Pydantic models; CallToolRequestResult.from_call_tool_result() converts a CallToolResult into the richer result that includes tool and arguments.
Middleware Implementation
src/fastmcp/server/middleware/bulk_tool_caller.py
Adds async call_tools_bulk() and call_tool_bulk() functions that sequentially invoke tools via the server tool manager, convert results to CallToolRequestResult, handle per-call exceptions, and honor continue_on_error. Adds BulkToolCallerMiddleware which injects these functions as tools.
Middleware Module Exports
src/fastmcp/server/middleware/__init__.py
Adds __getattr__ and __dir__ to lazily expose BulkToolCallerMiddleware and includes it in __all__ for discovery and type-checking friendliness.
Deprecated Public API
src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py
Introduces deprecated BulkToolCaller class that re-exports CallToolRequest/CallToolRequestResult from middleware types, emits deprecation warnings, updates register_tools() signature to accept mcp_server, and provides call_tools_bulk(), call_tool_bulk(), and _call_tool() methods that proxy to the new behavior.
Tests
tests/server/middleware/test_bulk_tool_caller.py
Adds comprehensive tests for middleware injection, single/multiple call scenarios, error handling with continue_on_error, behavior for tools returning no value, tag-filtering interactions, and the deprecated BulkToolCaller path including deprecation warnings.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Server
    participant BulkMiddleware as BulkToolCallerMiddleware
    participant ToolManager

    rect rgb(230, 245, 255)
    Note over Client,ToolManager: Middleware flow (call_tool_bulk / call_tools_bulk)
    Client->>Server: invoke bulk tool (call_tool_bulk / call_tools_bulk)
    Server->>BulkMiddleware: dispatch to injected tool
    loop for each call
        BulkMiddleware->>ToolManager: call tool(name, arguments)
        alt success
            ToolManager-->>BulkMiddleware: ToolResult
            BulkMiddleware->>BulkMiddleware: convert ToolResult -> CallToolRequestResult
        else exception
            ToolManager-->>BulkMiddleware: Exception
            BulkMiddleware->>BulkMiddleware: create error CallToolRequestResult
            alt continue_on_error = false
                BulkMiddleware->>BulkMiddleware: break loop
            end
        end
    end
    BulkMiddleware-->>Server: list[CallToolRequestResult]
    Server-->>Client: aggregated results
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Review points:
    • Ensure CallToolRequestResult.from_call_tool_result correctly maps isError, content, _meta, and structuredContent.
    • Verify continue_on_error semantics stop iteration when False in both middleware functions.
    • Confirm __getattr__ lazy export does not create import cycles or hide errors in runtime import.
    • Check deprecated BulkToolCaller.register_tools signature and warning behavior for backward compatibility.

Poem

🐰
Tools in a row, I hop and I call,
Batch by batch, I catch each fall.
Old path bows out with a gentle chime,
Middleware hops in, one call at a time. ✨

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Title Check ✅ Passed The pull request title "[Draft] Convert BulkToolCaller to middleware" clearly and concisely summarizes the primary change in the changeset. The title accurately describes the main objective of converting the BulkToolCaller from an MCPMixin-based implementation to a ToolInjectionMiddleware, which is the central focus of all modifications. The title is specific enough for developers scanning the repository history to understand the nature of the change without requiring additional context.
Linked Issues Check ✅ Passed The PR successfully addresses all coding requirements from linked issue #2262. The changes create BulkToolCallerMiddleware extending ToolInjectionMiddleware (#2262), implement bulk tool calling as injected middleware functions (call_tools_bulk and call_tool_bulk), maintain backward compatibility through re-exports and deprecation guidance, and include comprehensive test coverage validating the new middleware implementation. The new middleware usage pattern (FastMCP(middleware=[BulkToolCallerMiddleware()])) aligns with other tool injection middleware as requested in the issue, while the deprecated BulkToolCaller path remains functional with proper warnings.
Out of Scope Changes Check ✅ Passed All code changes in the pull request are directly related to the stated objective of converting BulkToolCaller to middleware. The modifications include the new middleware implementation (bulk_tool_caller.py), supporting types module (bulk_tool_caller_types.py), middleware initialization updates (middleware/init.py), deprecation of the original class (bulk_tool_caller/bulk_tool_caller.py), and comprehensive test coverage. No out-of-scope changes or unrelated functionality modifications were identified; all alterations support the core goal of migrating the BulkToolCaller from MCPMixin to ToolInjectionMiddleware pattern.
Description Check ✅ Passed The pull request description contains all essential information about the changes, including a summary section explaining the migration to middleware, a detailed list of changes made, usage examples comparing the new and deprecated approaches, and proper attribution to Claude Code as the AI tool used. Although the description does not strictly follow the template's checklist structure with checked boxes, it provides the critical information needed to understand the PR's purpose, scope, and implementation, including the issue reference (#2262) and testing approach (comprehensive test suite added).
Docstring Coverage ✅ Passed Docstring coverage is 89.66% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/issue-2262-20251026-1426

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (4)
tests/server/middleware/test_bulk_tool_caller.py (1)

31-33: Document the None return explicitly in the tool docstring

Note that the tool intentionally returns None to aid schema/docs clarity.

     async def no_return_tool(arg1: str) -> None:
-        """A simple tool that returns nothing."""
+        """A simple tool that returns nothing.
+
+        Returns:
+            None: This tool does not return any value.
+        """
src/fastmcp/server/middleware/bulk_tool_caller_types.py (1)

29-39: Preserve structuredContent and _meta when converting results

from_call_tool_result drops structuredContent and _meta, losing upstream data. Copy them through.

     @classmethod
     def from_call_tool_result(
         cls, result: CallToolResult, tool: str, arguments: dict[str, Any]
     ) -> "CallToolRequestResult":
         """Create a CallToolRequestResult from a CallToolResult."""
-        return cls(
+        return cls(
             tool=tool,
             arguments=arguments,
             isError=result.isError,
-            content=result.content,
+            content=result.content,
+            _meta=getattr(result, "_meta", None),
+            # Support either camelCase or snake_case attribute names
+            structuredContent=getattr(
+                result, "structuredContent", getattr(result, "structured_content", None)
+            ),
         )
src/fastmcp/server/middleware/bulk_tool_caller.py (2)

51-60: Avoid intermediate CallToolResult and preserve all fields

The temporary CallToolResult drops structuredContent/_meta. Build CallToolRequestResult directly from tool_result to keep everything intact.

-            # Convert ToolResult to CallToolResult
-            # Don't set isError - it defaults to None for successful calls
-            call_tool_result = CallToolResult(
-                content=tool_result.content,
-            )
-
-            # Convert to CallToolRequestResult
-            results.append(
-                CallToolRequestResult.from_call_tool_result(
-                    call_tool_result, tool_call.tool, tool_call.arguments
-                )
-            )
+            # Convert to CallToolRequestResult, preserving all available fields
+            results.append(
+                CallToolRequestResult(
+                    tool=tool_call.tool,
+                    arguments=tool_call.arguments,
+                    isError=getattr(tool_result, "isError", None),
+                    content=tool_result.content,
+                    structuredContent=getattr(
+                        tool_result,
+                        "structuredContent",
+                        getattr(tool_result, "structured_content", None),
+                    ),
+                    _meta=getattr(tool_result, "_meta", None),
+                )
+            )

116-125: Apply the same preservation fix in call_tool_bulk

Mirror the direct construction to keep structuredContent/_meta here as well.

-            # Convert ToolResult to CallToolResult
-            # Don't set isError - it defaults to None for successful calls
-            call_tool_result = CallToolResult(
-                content=tool_result.content,
-            )
-
-            # Convert to CallToolRequestResult
-            results.append(
-                CallToolRequestResult.from_call_tool_result(
-                    call_tool_result, tool, args
-                )
-            )
+            results.append(
+                CallToolRequestResult(
+                    tool=tool,
+                    arguments=args,
+                    isError=getattr(tool_result, "isError", None),
+                    content=tool_result.content,
+                    structuredContent=getattr(
+                        tool_result,
+                        "structuredContent",
+                        getattr(tool_result, "structured_content", None),
+                    ),
+                    _meta=getattr(tool_result, "_meta", None),
+                )
+            )
🧹 Nitpick comments (4)
src/fastmcp/server/middleware/__init__.py (1)

8-13: Improve static typing and discoverability for the lazy export

Add a TYPE_CHECKING import and a dir implementation so type checkers and IDEs see BulkToolCallerMiddleware, and dir() lists it.

+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:  # Help static analyzers/IDEs
+    from .bulk_tool_caller import BulkToolCallerMiddleware
+
 def __getattr__(name: str):
     if name == "BulkToolCallerMiddleware":
         from .bulk_tool_caller import BulkToolCallerMiddleware
 
         return BulkToolCallerMiddleware
     raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
 
+def __dir__() -> list[str]:
+    # Ensure "BulkToolCallerMiddleware" shows up in dir(fastmcp.server.middleware)
+    return sorted(list(globals().keys()) + ["BulkToolCallerMiddleware"])
 
 __all__ = [
     "BulkToolCallerMiddleware",
     "CallNext",
     "Middleware",
     "MiddlewareContext",
 ]

Also applies to: 16-21

tests/server/middleware/test_bulk_tool_caller.py (1)

52-58: Avoid brittle count assertion in tool listing

The exact length check can flake if the server injects extra tools. Keep name membership checks and drop the length assertion.

-        # Should have: echo_tool, error_tool, no_return_tool, add, call_tools_bulk, call_tool_bulk
-        assert len(tools) == 6
+        # Expect these tools to be present regardless of other server-injected tools
         assert "call_tools_bulk" in tool_names
         assert "call_tool_bulk" in tool_names
         assert "echo_tool" in tool_names
         assert "error_tool" in tool_names
         assert "no_return_tool" in tool_names
         assert "add" in tool_names
src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py (1)

118-126: Preserve structuredContent/_meta by using the helper constructor

Leverage CallToolRequestResult.from_call_tool_result to keep all fields (including structuredContent and _meta) instead of re‑constructing manually.

-        async with Client(self.connection) as client:
-            result = await client.call_tool_mcp(name=tool, arguments=arguments)
-
-            return CallToolRequestResult(
-                tool=tool,
-                arguments=arguments,
-                isError=result.isError,
-                content=result.content,
-            )
+        async with Client(self.connection) as client:
+            call_result = await client.call_tool_mcp(name=tool, arguments=arguments)
+            return CallToolRequestResult.from_call_tool_result(
+                call_result, tool=tool, arguments=arguments
+            )
src/fastmcp/server/middleware/bulk_tool_caller.py (1)

16-26: Optional: add bounded concurrency to speed up bulk calls

If tools are I/O‑bound and independent, consider an optional max_concurrency to run calls with asyncio.Semaphore/gather.

-async def call_tools_bulk(
+async def call_tools_bulk(
     context: Context,
     tool_calls: Annotated[
         list[CallToolRequest],
         "List of tool calls to execute. Each call can be for a different tool with different arguments.",
     ],
     continue_on_error: Annotated[
         bool,
         "If True, continue executing remaining tools even if one fails. If False, stop on first error.",
-    ] = True,
+    ] = True,
+    max_concurrency: int | None = None,
 ) -> list[CallToolRequestResult]:
@@
-    results = []
-
-    for tool_call in tool_calls:
+    results: list[CallToolRequestResult] = []
+    sem = None if not max_concurrency else __import__("asyncio").Semaphore(max_concurrency)
+
+    async def _one(tc: CallToolRequest) -> CallToolRequestResult:
+        if sem:
+            async with sem:
+                return await _call_single(context, tc.tool, tc.arguments)
+        return await _call_single(context, tc.tool, tc.arguments)
+
+    for tool_call in tool_calls:
         try:
-            # Call the tool directly through the tool manager
-            tool_result = await context.fastmcp._tool_manager.call_tool(
-                key=tool_call.tool, arguments=tool_call.arguments
-            )
+            tool_result = await context.fastmcp._tool_manager.call_tool(
+                key=tool_call.tool, arguments=tool_call.arguments
+            )

Note: Same pattern can be applied to call_tool_bulk.

Also applies to: 79-90

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7999dcb and 009772a.

📒 Files selected for processing (5)
  • src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py (3 hunks)
  • src/fastmcp/server/middleware/__init__.py (1 hunks)
  • src/fastmcp/server/middleware/bulk_tool_caller.py (1 hunks)
  • src/fastmcp/server/middleware/bulk_tool_caller_types.py (1 hunks)
  • tests/server/middleware/test_bulk_tool_caller.py (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run tests: Python 3.10 on windows-latest
  • GitHub Check: Run tests with lowest-direct dependencies
🔇 Additional comments (2)
src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py (1)

23-37: Confirm the deprecation version tag

The docstring marks deprecation as 2.1.0. Please confirm this matches the planned release that ships the middleware, or adjust before merge.

tests/server/middleware/test_bulk_tool_caller.py (1)

1-10: Async test configuration is correct

The pytest configuration in pyproject.toml explicitly sets asyncio_mode = "auto" and includes pytest-asyncio>=1.2.0. This configuration automatically detects and runs async tests without requiring @pytest.mark.asyncio decorators. The async tests in tests/server/middleware/test_bulk_tool_caller.py will execute correctly as configured.

@strawgate
Copy link
Collaborator Author

/marvin can you please address the PR feedback

@marvin-context-protocol
Copy link
Contributor

marvin-context-protocol bot commented Oct 27, 2025

Claude finished @strawgate's task —— View job


Addressing PR Feedback ✅

Successfully addressed all code review feedback from both reviewers.

Changes Made

  • Fixed no_return_tool docstring to document None return value
  • Updated from_call_tool_result to preserve _meta and structuredContent fields
  • Removed unnecessary intermediate CallToolResult creation in both call_tools_bulk and call_tool_bulk
  • Updated deprecated _call_tool method to use the helper properly
  • Added TYPE_CHECKING import and __dir__ function for better IDE/type checker support
  • Updated test snapshots to reflect proper structuredContent preservation

Summary

All reviewer feedback has been addressed:

  1. Field Preservation: The middleware now properly preserves structuredContent from tool results, ensuring no data is lost during conversion. The _meta field is also preserved when present.

  2. Code Simplification: Removed the unnecessary intermediate CallToolResult objects and instead directly create CallToolRequestResult instances with the appropriate fields.

  3. Type Hints: Added TYPE_CHECKING import and __dir__() function to improve static type checking and IDE autocomplete for the lazy-loaded BulkToolCallerMiddleware.

  4. Documentation: Enhanced docstring for no_return_tool test fixture to explicitly document the None return value.

All pre-commit checks pass and all 13 tests pass successfully.

--- |

- Fix no_return_tool docstring to document None return
- Preserve _meta and structuredContent in CallToolRequestResult
- Remove intermediate CallToolResult creation
- Use from_call_tool_result helper in deprecated code
- Add TYPE_CHECKING import and __dir__ for better IDE support
- Update test snapshots to reflect structuredContent preservation

Co-authored-by: William Easton <[email protected]>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/fastmcp/server/middleware/bulk_tool_caller.py (1)

109-111: Same filtering bypass issue as in call_tools_bulk.

This call has the same security implications as noted in call_tools_bulk (lines 45-47).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 009772a and a11e3ab.

📒 Files selected for processing (5)
  • src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py (4 hunks)
  • src/fastmcp/server/middleware/__init__.py (1 hunks)
  • src/fastmcp/server/middleware/bulk_tool_caller.py (1 hunks)
  • src/fastmcp/server/middleware/bulk_tool_caller_types.py (1 hunks)
  • tests/server/middleware/test_bulk_tool_caller.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/fastmcp/server/middleware/bulk_tool_caller_types.py
🧰 Additional context used
📓 Path-based instructions (3)
src/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

Use Python ≥ 3.10 and provide full type annotations for library code

Files:

  • src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py
  • src/fastmcp/server/middleware/bulk_tool_caller.py
  • src/fastmcp/server/middleware/__init__.py
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

Never use bare except; always catch specific exception types

Files:

  • src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py
  • src/fastmcp/server/middleware/bulk_tool_caller.py
  • tests/server/middleware/test_bulk_tool_caller.py
  • src/fastmcp/server/middleware/__init__.py
tests/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

tests/**/*.py: Tests must be atomic, self-contained, and cover a single functionality
Use pytest parameterization for multiple examples of the same functionality
Use separate tests for distinct pieces of functionality
Always put imports at the top of test files; do not import inside test bodies
Do not add @pytest.mark.asyncio; asyncio_mode = "auto" is set globally
Prefer in-memory transport for tests; use HTTP transport only when explicitly testing networking
For slow/long-running tests, mark them as integration or optimize (default timeout is 5s)
In tests, use # type: ignore[attr-defined] for MCP results instead of type assertions

Files:

  • tests/server/middleware/test_bulk_tool_caller.py
🧬 Code graph analysis (4)
src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py (4)
src/fastmcp/server/middleware/bulk_tool_caller_types.py (3)
  • CallToolRequest (9-15)
  • CallToolRequestResult (18-41)
  • from_call_tool_result (30-41)
src/fastmcp/contrib/mcp_mixin/mcp_mixin.py (1)
  • MCPMixin (118-276)
src/fastmcp/server/server.py (1)
  • _call_tool_middleware (1074-1092)
tests/contrib/test_bulk_tool_caller.py (1)
  • bulk_caller_live (81-85)
src/fastmcp/server/middleware/bulk_tool_caller.py (6)
src/fastmcp/server/context.py (1)
  • Context (103-692)
src/fastmcp/server/middleware/bulk_tool_caller_types.py (2)
  • CallToolRequest (9-15)
  • CallToolRequestResult (18-41)
src/fastmcp/server/middleware/tool_injection.py (1)
  • ToolInjectionMiddleware (21-51)
src/fastmcp/tools/tool.py (1)
  • Tool (105-241)
src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py (2)
  • call_tools_bulk (63-81)
  • call_tool_bulk (84-109)
src/fastmcp/server/server.py (2)
  • _call_tool_mcp (1046-1072)
  • _call_tool_middleware (1074-1092)
tests/server/middleware/test_bulk_tool_caller.py (3)
src/fastmcp/server/server.py (2)
  • FastMCP (148-2693)
  • name (359-360)
src/fastmcp/server/middleware/bulk_tool_caller.py (1)
  • BulkToolCallerMiddleware (142-192)
src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py (2)
  • BulkToolCaller (20-123)
  • register_tools (39-60)
src/fastmcp/server/middleware/__init__.py (1)
src/fastmcp/server/middleware/bulk_tool_caller.py (1)
  • BulkToolCallerMiddleware (142-192)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run tests: Python 3.10 on windows-latest
  • GitHub Check: Run tests with lowest-direct dependencies
🔇 Additional comments (6)
src/fastmcp/server/middleware/__init__.py (1)

1-24: LGTM! Well-implemented lazy import pattern.

The lazy import implementation using __getattr__ and __dir__ follows PEP 562 conventions correctly, enabling optional middleware without forcing import-time dependencies.

tests/server/middleware/test_bulk_tool_caller.py (1)

1-305: LGTM! Comprehensive test coverage.

The test suite follows all coding guidelines:

  • Proper imports at the top
  • Atomic, self-contained tests
  • Correct use of # type: ignore[attr-defined] for MCP results
  • No unnecessary @pytest.mark.asyncio decorators
  • Good coverage of success, error, filtering, and deprecation scenarios
src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py (1)

1-123: LGTM! Well-executed deprecation strategy.

The deprecation is properly implemented with:

  • Clear version notice in docstring (.. deprecated:: 2.1.0)
  • Runtime warning with migration guidance
  • Backward compatibility via type re-exports
  • Correct stacklevel=2 pointing to the caller
src/fastmcp/server/middleware/bulk_tool_caller.py (3)

60-70: Appropriate exception handling for bulk operations.

Catching Exception (not bare except:) is acceptable here because:

  • Bulk operations need to handle errors from arbitrary tools
  • Errors are properly reported to the caller with isError=True
  • The error message preserves the original exception information

142-192: Well-structured middleware with clear documentation.

The class structure and documentation are excellent:

  • Clear inheritance from ToolInjectionMiddleware
  • Comprehensive docstring with usage examples
  • Proper tool registration via Tool.from_function()

45-47: Bypass of tool filtering is confirmed but appears intentional—test coverage incomplete.

The review comment is correct: bulk_tool_caller.py line 45-47 directly calls context.fastmcp._tool_manager.call_tool(), which bypasses the _should_enable_component() filtering that the proper middleware chain (_call_tool_middleware()) applies.

The test file includes test_bulk_tools_bypass_filtering(), which explicitly acknowledges this behavior. However, the test is incomplete: it verifies that filtered tools don't appear in the tool list while bulk tools remain available, but it does not verify whether:

  1. Calling a filtered-out tool through bulk tools actually succeeds (security gap)
  2. This bypass is intentional or a bug
  3. What the expected behavior should be

Recommendation: Update the test to clarify intent by adding an assertion that verifies whether calling a filtered tool via bulk tools succeeds or fails. If the bypass is intentional, document it prominently with a comment explaining why filtering is not applied to bulk operations. If filtering should be respected, update the implementation to use _call_tool_middleware() instead.

Comment on lines +306 to +327
async def test_bulk_tools_bypass_filtering(self):
"""Test that bulk caller tools bypass tag filtering."""
mcp = FastMCP(
"FilteredServer",
middleware=[BulkToolCallerMiddleware()],
exclude_tags={"math"},
)

@mcp.tool(tags={"math"})
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b

async with Client(mcp) as client:
tools = await client.list_tools()

tool_names = [tool.name for tool in tools]
# The multiply tool should be filtered out, but bulk tools should still be available
assert "call_tools_bulk" in tool_names
assert "call_tool_bulk" in tool_names
assert "multiply" not in tool_names

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify that bulk tools respect filtering and disabled tool states.

The test confirms bulk tools themselves bypass filtering, but doesn't verify whether bulk tools can invoke filtered-out or disabled tools. This could be a security concern if tools are disabled for a reason (e.g., security, authorization).

Run the following script to check how bulk tools interact with filtering:

Consider adding a test case that attempts to call a filtered-out tool via bulk tools to document the expected behavior.


🏁 Script executed:

#!/bin/bash
# Description: Check if bulk tools can call disabled/filtered tools

# Search for _should_enable_component usage in bulk tool implementation
rg -nP --type=py -C5 '_should_enable_component|_tool_manager\.call_tool' src/fastmcp/server/middleware/bulk_tool_caller.py

# Check if filtering logic is applied in the tool manager
ast-grep --pattern 'def call_tool($$$) {
  $$$
}'

Length of output: 939


I need to investigate the tool manager's implementation to determine if filtering is applied when tools are called directly. Let me search for the relevant code.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find the tool manager implementation and its call_tool method
fd --type=f -e py "tool_manager" src/ | head -20

# Look for the call_tool method definition
rg -nP --type=py 'def call_tool\s*\(' src/

Length of output: 325


🏁 Script executed:

#!/bin/bash
# Search for _should_enable_component or filtering checks in call_tool
rg -nP --type=py -A20 'def call_tool\s*\(' src/

Length of output: 4908


I need to check the get_tool method and understand how filtering is implemented.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find get_tool method implementation
rg -nP --type=py -A30 'def get_tool\s*\(' src/fastmcp/tools/tool_manager.py

# Search for filtering logic related to exclude_tags or disabled
rg -nP --type=py 'exclude_tags|disabled|_should_enable' src/fastmcp/tools/tool_manager.py

Length of output: 1191


This is revealing. The get_tools() method explicitly returns "unfiltered" tools. Let me find where filtering actually happens.


---

🏁 Script executed:

```shell
#!/bin/bash
# Search for where exclude_tags filtering is actually applied
rg -nP --type=py 'exclude_tags' src/

# Search for list_tools implementation (this is what's called by MCP protocol)
rg -nP --type=py -A20 'async def list_tools' src/

# Search for _should_enable or similar filtering checks
rg -nP --type=py '_should_enable|_is_component_enabled' src/

Length of output: 6320


I found the filtering logic. Let me check if filtering is applied in the call_tool path on the server.


---

🏁 Script executed:

```shell
#!/bin/bash
# Get context around line 1114 to understand the call_tool filtering
rg -nP --type=py -B5 -A15 '^\s*if not self._should_enable_component\(tool\)' src/fastmcp/server/server.py | head -50

# Find the actual call_tool method in server.py
rg -nP --type=py -A30 'def call_tool\s*\(' src/fastmcp/server/server.py | head -60

Length of output: 2081


Now I need to verify if this is intentional behavior or if the test actually covers this scenario.


---

🏁 Script executed:

```shell
#!/bin/bash
# Check the complete call_tool method in server.py to see the full picture
rg -nP --type=py -B5 -A50 'async def call_tool\s*\(' src/fastmcp/server/server.py | head -80

# Look at the bulk tool caller test to see if it attempts to call filtered tools
cat tests/server/middleware/test_bulk_tool_caller.py | grep -A 50 "test_bulk_tools_bypass_filtering"

Length of output: 1788


Add test verifying bulk tools respect filtering when calling tools.

The current test confirms bulk tools bypass list filtering, but doesn't verify whether they can invoke filtered-out tools. The server's normal call_tool path applies filtering via _should_enable_component() (line 1127 in server.py), but bulk tools call _tool_manager.call_tool() directly (lines 45, 109 in bulk_tool_caller.py), bypassing this check. This is a security gap if tools are excluded for authorization/security reasons.

Add a test case that attempts to call a filtered-out tool via call_tool_bulk and verify it raises an appropriate error (e.g., NotFoundError or ToolError).

🤖 Prompt for AI Agents
In tests/server/middleware/test_bulk_tool_caller.py around lines 306 to 327, add
a new test that ensures bulk tool invocation respects server filtering by
attempting to call a tool tagged with an excluded tag via the bulk caller and
asserting it raises the expected error; specifically, after registering the
"multiply" tool tagged {"math"} and creating Client with exclude_tags={"math"},
use client.call_tool_bulk (or call_tools_bulk) to invoke "multiply" and assert
the call fails with the same error type the server uses for disallowed/missing
tools (e.g., NotFoundError or ToolError) and a suitable message, mirroring the
normal call_tool path behavior so bulk callers do not bypass
_should_enable_component().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contrib Related to community contributions in src/contrib/ directory. enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert Bulk Tool Caller to Middleware

2 participants