Skip to content

fix(mcp): validate MCP tool prefix against registry instead of heuristic - #25088

Closed
klhq wants to merge 7 commits into
BerriAI:mainfrom
klhq:fix/semantic-filter-registry-prefix-check
Closed

fix(mcp): validate MCP tool prefix against registry instead of heuristic#25088
klhq wants to merge 7 commits into
BerriAI:mainfrom
klhq:fix/semantic-filter-registry-prefix-check

Conversation

@klhq

@klhq klhq commented Apr 3, 2026

Copy link
Copy Markdown

Relevant issues

Fixes #25081

Note: This PR is stacked on #24986 (MCP/non-MCP separation). The diff includes those commits until #24986 is merged.

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Type

🐛 Bug Fix

Changes

is_tool_name_prefixed() checks "-" in tool_name, which misclassifies non-MCP tools with hyphens (e.g., text-to-speech, code-review) as MCP tools. These tools get sent through the semantic filter and silently dropped.

  • Replace is_tool_name_prefixed() in the hook with _is_mcp_tool() that splits on the first - and validates the prefix against known MCP server names from the registry
  • Lazy-cache the registered server prefixes (queried once from global_mcp_server_manager.get_registry())
  • 2 new tests: hyphenated non-MCP tools pass through, _is_mcp_tool unit test
  • 3 existing hook tests updated with registry mock

klhq added 6 commits April 2, 2026 18:33
When the semantic tool filter finds no matches for a user query, it
previously returned all available tools. With many MCP servers this
can exceed provider tool limits. Now it drops MCP tools (prefixed)
and returns only non-MCP tools on zero matches.
The semantic filter treated all tools equally — it didn't distinguish
between MCP tools (which it should filter) and non-MCP built-in tools
(which should pass through untouched). This caused two failures:

- On match: non-MCP tools were dropped, losing built-in functionality
- On zero match: all tools returned unfiltered, exceeding provider limits

Move MCP/non-MCP separation into the hook. The hook now passes only
MCP tools (identified by server-name prefix) to filter_tools() and
recombines with non-MCP tools after. filter_tools() returns empty
list on zero matches instead of all tools.
…CP tools

When all tools are MCP and zero semantic matches are found, return the
first top_k MCP tools instead of an empty list. Avoids sending an empty
tool list to the LLM.
is_tool_name_prefixed() just checks if "-" exists in the name, which
misclassifies non-MCP tools with hyphens (e.g., text-to-speech). Replace
with _is_mcp_tool() that splits on the first "-" and checks if the
prefix is a known registered MCP server name from the registry.
@vercel

vercel Bot commented Apr 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 3, 2026 5:09pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing lazyskyline7:fix/semantic-filter-registry-prefix-check (45dbc8c) with main (d4a3a5e)

Open in CodSpeed

@klhq klhq changed the title Fix/semantic filter registry prefix check fix(mcp): validate MCP tool prefix against registry instead of heuristic Apr 3, 2026
@klhq

klhq commented Apr 3, 2026

Copy link
Copy Markdown
Author

@greptileai

@greptile-apps

greptile-apps Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a misclassification bug where non-MCP tools containing hyphens (e.g., text-to-speech, code-review) were incorrectly identified as MCP tools, passed to the semantic filter, and silently dropped.

Key changes:

  • _is_mcp_tool() now validates the tool-name prefix against the live MCP server registry (global_mcp_server_manager.get_registry()) instead of using the blunt \"-\" in tool_name heuristic.
  • filter_tools() in SemanticMCPToolFilter now returns [] on zero semantic matches (previously returned available_tools); the hook re-adds non-MCP tools afterwards.
  • The hook's async_pre_call_hook separates tools into MCP/non-MCP buckets, only semantically filters the MCP bucket, and always preserves non-MCP tools.
  • A fallback ensures an all-MCP request never returns an empty tool list (falls back to first top_k MCP tools).
  • 6 new unit tests cover: hyphenated non-MCP pass-through, zero-match fallback, mixed-tool preservation, and direct _is_mcp_tool unit assertions. All use mocks — no real network calls.

One minor concern: _get_registered_server_prefixes() is called once per tool inside the partition loop, so a request with N tools builds N separate prefix sets instead of one. The registry itself appears to be in-memory so correctness is not impacted, but it is unnecessary repeated allocation on the hot request path.

Confidence Score: 5/5

Safe to merge — the core bug fix is correct, tests are thorough and mock-only, and the only remaining issue is a minor per-request allocation inefficiency.

All findings are P2 (style/performance). The registry-queried-per-tool concern does not cause incorrect behavior — it is purely an efficiency issue on an already-hot path that could be addressed in a follow-up. No correctness, security, or data-integrity issues were found. The stale-cache concern from the previous review thread has been resolved (registry is now queried fresh). Tests are well-structured and cover the new logic branches.

hook.py — the partition loop calls _get_registered_server_prefixes() once per tool; trivial to hoist the call outside the loop.

Important Files Changed

Filename Overview
litellm/proxy/hooks/mcp_semantic_filter/hook.py Core hook rewritten to separate MCP vs non-MCP tools via registry lookup; non-MCP tools now bypass semantic filtering entirely. Registry is queried once per tool in the loop (O(N) allocations per request) — a minor performance concern.
litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py Changed filter_tools() to return [] (instead of available_tools) when no semantic matches are found; hook is now responsible for adding non-MCP tools back, making the contract cleaner.
tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py Existing test correctly updated with registry mock and prefixed tool names; 6 new tests cover hyphenated non-MCP pass-through, fallback-to-top-k, zero-match semantics, and _is_mcp_tool unit cases. All tests are mock-only (no real network calls).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[async_pre_call_hook] --> B{tools present?}
    B -- No --> Z[return None]
    B -- Yes --> C{MCP refs to expand?}
    C -- Yes --> D[expand MCP references]
    D --> E[continue with expanded tools]
    C -- No --> E
    E --> F{messages present?}
    F -- No --> Z
    F -- Yes --> G[extract user query]
    G --> H{query found?}
    H -- No --> Z
    H -- Yes --> I[partition tools via _is_mcp_tool]
    I --> I1[_get_registered_server_prefixes called once per tool ⚠️]
    I --> J{any MCP tools?}
    J -- No --> Z
    J -- Yes --> K[filter_tools on MCP tools only]
    K --> L{zero MCP matches?}
    L -- No --> M[filtered_mcp_tools + non_mcp_tools]
    L -- Yes --> N{non_mcp_tools empty?}
    N -- Yes --> O[fallback: first top_k MCP tools]
    O --> M
    N -- No --> P[filtered_mcp_tools = empty]
    P --> M
    M --> Q[update data and return]
Loading

Reviews (2): Last reviewed commit: "fix: remove stale prefix cache and use s..." | Re-trigger Greptile

Comment on lines +58 to +71
def _get_registered_server_prefixes(self) -> set:
"""Get the set of known MCP server prefixes from the registry."""
if self._registered_server_prefixes is None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)

registry = global_mcp_server_manager.get_registry()
self._registered_server_prefixes = {
normalize_server_name(get_server_prefix(server))
for server in registry.values()
if get_server_prefix(server)
}
return self._registered_server_prefixes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Stale cache never invalidated after new servers are registered

_registered_server_prefixes is set once on the first request and never cleared. If a new MCP server is added dynamically via the admin API after that first call, its prefix will be absent from the cached set. The hook will then classify that server's tools as non_mcp_tools — they will bypass semantic filtering entirely and always reach the LLM.

The cache is populated lazily with:

if self._registered_server_prefixes is None:
    ...

But self._registered_server_prefixes is never reset to None after that. Any runtime POST /mcp/server call that adds a new server is invisible to this logic.

Consider one of:

  1. Clearing _registered_server_prefixes (reset to None) whenever global_mcp_server_manager's registry changes — e.g. hook into the registration/removal events.
  2. Using a TTL-based cache (e.g. re-query every N seconds).
  3. Querying the registry fresh on each call if the registry is cheap to read (it returns an in-memory dict).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 45dbc8c. Removed the cache entirely. get_registry() returns an in-memory dict, so querying it fresh each call is negligible compared to the embedding call that follows. No need to cache a copy of something already in memory.

Comment on lines +261 to +262
mcp_tools = [t for t in tools if self._is_mcp_tool(_tool_name(t))]
non_mcp_tools = [t for t in tools if not self._is_mcp_tool(_tool_name(t))]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Double iteration over tools list

Each tool has _is_mcp_tool(_tool_name(t)) called twice — once for each list comprehension — and both comprehensions iterate the full tools list. For large tool lists this is 2× the work needed.

A single pass that partitions tools would be cleaner and more efficient:

mcp_tools, non_mcp_tools = [], []
for t in tools:
    (mcp_tools if self._is_mcp_tool(_tool_name(t)) else non_mcp_tools).append(t)

This also makes the intent explicit: every tool goes into exactly one bucket.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 45dbc8c, replaced with a single-pass partition loop.

Query the MCP registry fresh each call so dynamically added servers
are recognized immediately. Replace double list comprehension with
a single-pass partition loop.
@klhq

klhq commented Apr 3, 2026

Copy link
Copy Markdown
Author

This PR builds on #24986 which separates MCP from non-MCP tools in the hook. #24986 used is_tool_name_prefixed() (checks if - exists in the name) to tell them apart, which works for most cases but misclassifies hyphenated non-MCP tools like text-to-speech.

This PR replaces that heuristic with a registry lookup: split on the first -, check if the prefix is a registered MCP server name. No cache, the registry is an in-memory dict queried fresh each call. Single-pass partition replaces the double list comprehension.

@codecov

codecov Bot commented Apr 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@klhq

klhq commented Apr 15, 2026

Copy link
Copy Markdown
Author

Closing — no longer needed.

#25085 (which fixes the same underlying is_tool_name_prefixed() hyphen
false-positive) landed in main via #25192 on 2026-04-14, with a
different (and cleaner) shape than this PR: the fix lives in
utils.py with an optional known_server_prefixes parameter, instead
of adding a local helper in the semantic filter hook.

#24986 picks up the new known_server_prefixes signature in the hook,
so the semantic filter benefits from the root-level fix. That
supersedes this PR entirely.

cc @voidborne-d @krrish-berri-2

@klhq klhq closed this Apr 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: is_tool_name_prefixed can misclassify non-MCP tools with hyphens

1 participant