feat(mcp): add mcp_tool_search virtual tools for large tool catalogs - #31777
Conversation
When mcp_tool_search_enabled is set on a key's object_permission, tools/list returns only mcp_tool_search and mcp_tool_call instead of the full catalog. The LLM searches by keyword then calls discovered tools by name, avoiding context bloat with 100+ tool deployments.
The mcp_tool_search_enabled flag existed on the Pydantic models but the
Prisma schema lacked the column, so keys generated with the flag never
persisted it and tools/list kept returning the full catalog. Add the
column across all three schema.prisma copies plus a migration.
handle_mcp_tool_call passed server_name="" into call_tool, which built a
malformed prefixed name ("-<tool>") and failed to resolve the server.
Resolve the caller's allowed servers and dispatch through execute_mcp_tool
instead, matching how the normal /tools/call path routes.
The REST surface (/mcp-rest/tools/list) returned only the two virtual tools when mcp_tool_search_enabled was set, but the MCP protocol handler (handle_list_tools, used by real MCP clients over streamable-http/SSE) still returned the full catalog. Apply the same early return there so an actual MCP client sees mcp_tool_search and mcp_tool_call instead of every tool. call_tool was already intercepted on this path.
Review flagged that the virtual mcp_tool_search/mcp_tool_call path skipped access controls the normal MCP flow applies. mcp_tool_call resolved allowed servers from key permissions only, never applying IP filtering, so a caller on a public IP could invoke a tool on a server marked available_on_public_internet: false. mcp_tool_search listed the raw catalog via global_mcp_server_manager.list_tools, exposing tool names/schemas that /tools/list would hide and ignoring per-key/per-server tool filters. Route both virtual handlers through the same filtered paths used by the normal MCP flow: search now calls _list_mcp_tools and call resolves servers via _get_allowed_mcp_servers, both threaded with the request client IP so filter_server_ids_by_ip applies. execute_mcp_tool then enforces the server allowlist and per-key tool permissions. Thread client_ip through _list_mcp_tools/_get_tools_from_mcp_servers and pass it from the REST and SSE call sites.
ruff format normalizes the list_tools client_ip changes in server.py, and schema.d.ts gains the mcp_tool_search_enabled object-permission field so the generated dashboard types match the proxy OpenAPI spec.
Clears UP037 on the virtual tool handler signatures (redundant with from __future__ import annotations) and I001 on the list_tools import block.
…orktree-mcp-tool-search-support
Pulls the mcp_tool_search/mcp_tool_call interception and the host progress-callback setup out of mcp_server_tool_call into helpers, keeping that handler under the strict cyclomatic-complexity ceiling after the client_ip threading. No behavior change.
Adds unit tests for _dispatch_virtual_mcp_tool (non-virtual passthrough, flag-disabled rejection, search/call routing with client_ip), _capture_host_progress_callback, and the protocol list_tools virtual early-return, covering the new server.py paths.
The virtual mcp_tool_search/mcp_tool_call path intercepted the request before the normal header extraction ran, so client-supplied per-request auth (Authorization for upstream pass-through, x-mcp-auth-<alias>) was dropped and execute_mcp_tool/_list_mcp_tools received None. Thread mcp_auth_header, mcp_server_auth_headers, oauth2_headers, and raw_headers from both the REST and SSE call sites through the handlers so upstream MCP servers that require pass-through auth can be listed and called.
A scoped MCP session (/mcp/<server>/ or header-scoped) carries an mcp_servers scope that the normal call path passes into routing so the session can only reach that server. The virtual-tool branch dropped it and resolved with mcp_servers=None, letting a scoped session call mcp_tool_call for any server the key can access. Thread the context mcp_servers scope through _dispatch_virtual_mcp_tool into both handlers so search and call resolve against the same scoped server set.
The virtual-tool dispatch ran before the protocol handler's HTTPException and guardrail handling, so a rejected virtual call (e.g. an out-of-scope 403 from execute_mcp_tool) raised out of mcp_server_tool_call and broke the MCP JSON-RPC stream instead of returning an isError CallToolResult. Move the dispatch inside the same try that wraps call_mcp_tool so virtual-tool errors get the same isError conversion as normal tool calls.
The REST virtual-tool branch returned before common_processing_pre_call_logic, so execute_mcp_tool ran without a litellm_logging_obj and virtual mcp_tool_call invocations were not spend-logged or guardrail-checked like normal calls. Run the same pre-call pipeline in the call branch and thread the resulting litellm_logging_obj through handle_mcp_tool_call into execute_mcp_tool.
handle_mcp_tool_call passed an empty allowed_mcp_servers list into execute_mcp_tool; an unprefixed local tool name then fell through to the local registry, which has no server permission check, so a key with only mcp_tool_search_enabled and no server grants could run operator-configured local tools by name. Reject with 403 before dispatch when no servers are accessible, matching call_mcp_tool.
…orktree-mcp-tool-search-support # Conflicts: # litellm/proxy/_experimental/mcp_server/server.py
…tomer test fixture
|
|
|
@greptile review |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c4fd3125e8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Greptile SummaryIntroduces two virtual MCP tools (
Confidence Score: 5/5Safe to merge; the feature is strictly additive, flag-gated, and leaves all existing key flows untouched. The core access-control path (IP filtering, server allowlists, per-key tool permissions, spend logging) reuses proven helpers rather than reimplementing them, and every security boundary is covered by targeted tests. The two findings are quality concerns: a negative-top_k slice edge case that produces too many search results (not a security bypass), and an incomplete URL string in spend-log metadata for the SSE path. Neither affects correctness of tool dispatch or authorization. tool_search.py — negative top_k coercion and missing minimum constraint in the JSON Schema; server.py — incomplete synthetic ASGI scope in _build_virtual_call_logging_obj.
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/tool_search.py | New module implementing the virtual tool pair (mcp_tool_search, mcp_tool_call); clean separation of concerns, but coerce_top_k allows negative values through which causes incorrect slice behavior in search_tools. |
| litellm/proxy/_experimental/mcp_server/server.py | Adds list_tools early-return, _dispatch_virtual_mcp_tool, and _build_virtual_call_logging_obj; progress-callback extraction refactored; synthetic ASGI scope in _build_virtual_call_logging_obj is missing standard fields that produce incomplete URL strings in spend logs. |
| litellm/proxy/_experimental/mcp_server/rest_endpoints.py | Adds flag-gated early-returns for list and call REST endpoints; virtual tool interception correctly placed before server_id validation; flag check for mcp_tool_call correctly runs common_processing_pre_call_logic for spend logging. |
| litellm-proxy-extras/litellm_proxy_extras/migrations/20260626120000_add_mcp_tool_search_enabled/migration.sql | Adds nullable Boolean column mcp_tool_search_enabled to LiteLLM_ObjectPermissionTable; correct additive migration with no column removals. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py | Comprehensive mock-only test suite covering 19 scenarios: unit tests for search_tools and coerce_top_k, integration mocks for list/call REST endpoints, SSE dispatch helper, and error conversion. No real network calls. |
| litellm/proxy/_types.py | Adds mcp_tool_search_enabled Optional[bool] field to LiteLLM_ObjectPermissionBase; consistent with other optional permission fields. |
| schema.prisma | Adds mcp_tool_search_enabled Boolean? to LiteLLM_ObjectPermissionTable in all three schema copies consistently. |
Reviews (6): Last reviewed commit: "fix(mcp): mirror pre-call pipeline, guar..." | Re-trigger Greptile
Greptile SummaryThis PR introduces two virtual MCP tools (
Confidence Score: 3/5Safe to merge for the REST surface, but the SSE/streamable-HTTP surface for The SSE handler in
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/tool_search.py | New module implementing search_tools, get_virtual_tool_definitions, handle_mcp_tool_search, and handle_mcp_tool_call; top-level MCP import lacks a guard, and litellm_logging_obj is omitted on the SSE dispatch path |
| litellm/proxy/_experimental/mcp_server/server.py | Adds _dispatch_virtual_mcp_tool and _capture_host_progress_callback; virtual tool call via SSE path omits litellm_logging_obj (no pre-call pipeline), and top_k coercion lacks error handling |
| litellm/proxy/_experimental/mcp_server/rest_endpoints.py | Early-return for list_tool_rest_api and virtual tool intercept in call_tool_rest_api are correct; REST path properly runs pre-call pipeline for mcp_tool_call, but top_k coercion can raise unhandled ValueError |
| litellm/proxy/_types.py | Adds mcp_tool_search_enabled: Optional[bool] = None to LiteLLM_ObjectPermissionBase; straightforward field addition |
| litellm/models/object_permission.py | Adds mcp_tool_search_enabled to the Pydantic ORM model; matches schema and types changes |
| litellm-proxy-extras/litellm_proxy_extras/migrations/20260626120000_add_mcp_tool_search_enabled/migration.sql | Additive nullable column migration; no data loss risk |
| tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py | 19 tests covering search logic, virtual tool definitions, REST and SSE paths; all mocked, no real network calls |
Reviews (2): Last reviewed commit: "chore: trigger CI" | Re-trigger Greptile
|
@greptileai review |
…r include_disabled_tools - SSE mcp_tool_call now runs common_processing_pre_call_logic so it spend-logs and runs guardrails like the REST path (P1) - coerce_top_k avoids ValueError on non-integer top_k from clients (both REST and SSE) - guard mcp.types import in tool_search behind runtime/TYPE_CHECKING per package convention - admin list with include_disabled_tools returns the real catalog even when mcp_tool_search_enabled is set
|
@greptileai review |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
|
@greptileai review |
1 similar comment
|
@greptileai review |
…erriAI#31777) * feat(mcp): add tool search virtual tools for large catalogs When mcp_tool_search_enabled is set on a key's object_permission, tools/list returns only mcp_tool_search and mcp_tool_call instead of the full catalog. The LLM searches by keyword then calls discovered tools by name, avoiding context bloat with 100+ tool deployments. * fix(mcp): persist mcp_tool_search_enabled and route tool_call by name The mcp_tool_search_enabled flag existed on the Pydantic models but the Prisma schema lacked the column, so keys generated with the flag never persisted it and tools/list kept returning the full catalog. Add the column across all three schema.prisma copies plus a migration. handle_mcp_tool_call passed server_name="" into call_tool, which built a malformed prefixed name ("-<tool>") and failed to resolve the server. Resolve the caller's allowed servers and dispatch through execute_mcp_tool instead, matching how the normal /tools/call path routes. * fix(mcp): filter list_tools to virtual tools on the protocol path The REST surface (/mcp-rest/tools/list) returned only the two virtual tools when mcp_tool_search_enabled was set, but the MCP protocol handler (handle_list_tools, used by real MCP clients over streamable-http/SSE) still returned the full catalog. Apply the same early return there so an actual MCP client sees mcp_tool_search and mcp_tool_call instead of every tool. call_tool was already intercepted on this path. * fix(mcp): enforce IP + server filtering on virtual tool search/call Review flagged that the virtual mcp_tool_search/mcp_tool_call path skipped access controls the normal MCP flow applies. mcp_tool_call resolved allowed servers from key permissions only, never applying IP filtering, so a caller on a public IP could invoke a tool on a server marked available_on_public_internet: false. mcp_tool_search listed the raw catalog via global_mcp_server_manager.list_tools, exposing tool names/schemas that /tools/list would hide and ignoring per-key/per-server tool filters. Route both virtual handlers through the same filtered paths used by the normal MCP flow: search now calls _list_mcp_tools and call resolves servers via _get_allowed_mcp_servers, both threaded with the request client IP so filter_server_ids_by_ip applies. execute_mcp_tool then enforces the server allowlist and per-key tool permissions. Thread client_ip through _list_mcp_tools/_get_tools_from_mcp_servers and pass it from the REST and SSE call sites. * fix(ci): ruff format server.py and sync dashboard API types ruff format normalizes the list_tools client_ip changes in server.py, and schema.d.ts gains the mcp_tool_search_enabled object-permission field so the generated dashboard types match the proxy OpenAPI spec. * style(mcp): drop quoted annotations and sort imports Clears UP037 on the virtual tool handler signatures (redundant with from __future__ import annotations) and I001 on the list_tools import block. * refactor(mcp): extract virtual-tool dispatch and host progress capture Pulls the mcp_tool_search/mcp_tool_call interception and the host progress-callback setup out of mcp_server_tool_call into helpers, keeping that handler under the strict cyclomatic-complexity ceiling after the client_ip threading. No behavior change. * test(mcp): cover SSE virtual-tool dispatch and host progress helpers Adds unit tests for _dispatch_virtual_mcp_tool (non-virtual passthrough, flag-disabled rejection, search/call routing with client_ip), _capture_host_progress_callback, and the protocol list_tools virtual early-return, covering the new server.py paths. * fix(mcp): forward per-request auth headers through virtual tool handlers The virtual mcp_tool_search/mcp_tool_call path intercepted the request before the normal header extraction ran, so client-supplied per-request auth (Authorization for upstream pass-through, x-mcp-auth-<alias>) was dropped and execute_mcp_tool/_list_mcp_tools received None. Thread mcp_auth_header, mcp_server_auth_headers, oauth2_headers, and raw_headers from both the REST and SSE call sites through the handlers so upstream MCP servers that require pass-through auth can be listed and called. * fix(mcp): preserve requested server scope in virtual tool calls A scoped MCP session (/mcp/<server>/ or header-scoped) carries an mcp_servers scope that the normal call path passes into routing so the session can only reach that server. The virtual-tool branch dropped it and resolved with mcp_servers=None, letting a scoped session call mcp_tool_call for any server the key can access. Thread the context mcp_servers scope through _dispatch_virtual_mcp_tool into both handlers so search and call resolve against the same scoped server set. * fix(mcp): convert virtual tool errors to isError on the protocol path The virtual-tool dispatch ran before the protocol handler's HTTPException and guardrail handling, so a rejected virtual call (e.g. an out-of-scope 403 from execute_mcp_tool) raised out of mcp_server_tool_call and broke the MCP JSON-RPC stream instead of returning an isError CallToolResult. Move the dispatch inside the same try that wraps call_mcp_tool so virtual-tool errors get the same isError conversion as normal tool calls. * fix(mcp): spend-log virtual tool calls on the REST path The REST virtual-tool branch returned before common_processing_pre_call_logic, so execute_mcp_tool ran without a litellm_logging_obj and virtual mcp_tool_call invocations were not spend-logged or guardrail-checked like normal calls. Run the same pre-call pipeline in the call branch and thread the resulting litellm_logging_obj through handle_mcp_tool_call into execute_mcp_tool. * fix(mcp): reject virtual tool call when key has no accessible servers handle_mcp_tool_call passed an empty allowed_mcp_servers list into execute_mcp_tool; an unprefixed local tool name then fell through to the local registry, which has no server permission check, so a key with only mcp_tool_search_enabled and no server grants could run operator-configured local tools by name. Reject with 403 before dispatch when no servers are accessible, matching call_mcp_tool. * docs(mcp): document virtual tool_search module and parity rule in AGENTS.md * style(mcp): apply ruff format at repo line-length (120) * fix(mcp): add mcp_tool_search_enabled to ObjectPermissionDict and customer test fixture * chore: trigger CI * fix(mcp): mirror pre-call pipeline, guard imports, coerce top_k, honor include_disabled_tools - SSE mcp_tool_call now runs common_processing_pre_call_logic so it spend-logs and runs guardrails like the REST path (P1) - coerce_top_k avoids ValueError on non-integer top_k from clients (both REST and SSE) - guard mcp.types import in tool_search behind runtime/TYPE_CHECKING per package convention - admin list with include_disabled_tools returns the real catalog even when mcp_tool_search_enabled is set
Relevant issues
N/A
Linear ticket
N/A
Pre-Submission checklist
make test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewType
New Feature
Changes
Adds two virtual MCP tools (
mcp_tool_search,mcp_tool_call) that activate whenmcp_tool_search_enabled: trueis set on a key'sobject_permission.The problem: With 100+ MCP tools, sending the full catalog to the LLM wastes context and hurts tool selection accuracy.
The solution: When the flag is on,
GET /mcp-rest/tools/listreturns only two tools instead of the full catalog. The LLM callsmcp_tool_searchwith a keyword query to discover relevant tools, then callsmcp_tool_callwith the discovered tool name to execute it. Both SSE (/mcp/tools/call) and REST (/mcp-rest/tools/call) handlers intercept these virtual tool names before the normal server routing.mcp_tool_searchuses token-based scoring against tool name and description fields. No new dependencies.mcp_tool_callresolves the caller's allowed MCP servers and dispatches through the sameexecute_mcp_toolpath the normal/tools/callroute uses, so a tool discovered by search (e.g.math-add) executes with identical routing, auth and logging.Setup:
Keys without the flag get the existing behavior unchanged.
Files changed
litellm/proxy/_experimental/mcp_server/tool_search.py- new module:search_tools(),get_virtual_tool_definitions(),handle_mcp_tool_search(),handle_mcp_tool_call()litellm/proxy/_experimental/mcp_server/rest_endpoints.py- early-return inlist_tool_rest_api; intercept incall_tool_rest_apilitellm/proxy/_experimental/mcp_server/server.py- intercept in SSEmcp_server_tool_calllitellm/proxy/_types.py+litellm/models/object_permission.py- addmcp_tool_search_enabledfieldschema.prisma,litellm/proxy/schema.prisma,litellm-proxy-extras/litellm_proxy_extras/schema.prisma- addmcp_tool_search_enabled Boolean?column toLiteLLM_ObjectPermissionTablelitellm-proxy-extras/litellm_proxy_extras/migrations/20260626120000_add_mcp_tool_search_enabled/migration.sql- DB migration for the new columntests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py- 19 teststests/test_litellm/proxy/management_helpers/test_object_permission_utils.py- regression test that the flag is carried into the key-generation persist pathScreenshots / Proof of Fix
Live proxy with a math MCP server (
add,multiply) configured, hitting the real server end to end.Key with tool search enabled -
tools/listreturns only the two virtual tools, search finds the real tools, and a discovered tool executes:Key without the flag is unchanged - full catalog on
tools/list, and the virtual tools are rejected:The flag persists to the DB through
/key/generate:Access control on the virtual tools
Both virtual handlers go through the same filtered flow the normal MCP path uses, so the feature does not widen the access surface. Search lists tools via the filtered catalog (
_list_mcp_tools) rather than the raw manager list, and call resolves the caller's allowed servers via_get_allowed_mcp_serversand dispatches throughexecute_mcp_tool. Both are threaded with the request client IP, sofilter_server_ids_by_ipapplies and a server markedavailable_on_public_internet: falsestays unreachable from a public IP.execute_mcp_toolthen enforces the server allowlist and per-keymcp_tool_permissions.A key scoped to one server cannot call a tool on another server through
mcp_tool_call:Real MCP client over the protocol endpoint
The above uses the REST surface. A real MCP client connects over the streamable-http protocol endpoint (
/mcp/), handled separately inserver.py. Verified with themcpPython SDK client against the same live math server:Flagged key:
No-flag key over the same protocol endpoint is unchanged:
list_tools -> ['math-add', 'math-multiply']