Skip to content

feat(mcp): scan and pin upstream tool descriptions - #43283

Merged
joshua-berri merged 12 commits into
mainfrom
litellm_mcp_tool_description_scan_and_pin
Sep 29, 2026
Merged

joshua-berri merged 12 commits into
mainfrom
litellm_mcp_tool_description_scan_and_pin

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Discovery exposes upstream tool descriptions before content guardrails inspect them
  • Upstream catalog changes can silently alter clients' tool definitions

How it solves it:

  • Scan tool and nested schema descriptions through pre_mcp_call
  • Omit blocked tools and forward masked descriptions
  • Preserve pinned catalogs, description overrides and drift alerts
  • Scan eight tools concurrently per catalog, bounding request amplification
  • Hide pinned catalogs from restricted management views

User Flow

Before: a developer receives poisoned tool descriptions despite enabling an MCP guardrail

  1. They configure a prompt-injection guardrail for pre_mcp_call on their gateway
  2. Their MCP server changes a tool description to include unwanted instructions
  3. They send POST https://litellm-domain/mcp with {"jsonrpc":"2.0","id":2,"method":"tools/list"} after initialization
  4. The response is HTTP 200 and includes the poisoned tool description and nested parameter description
  5. Their app sends POST https://litellm-domain/v1/responses with an MCP tool using "server_url":"litellm_proxy"; the provider receives the same poisoned definitions
  6. An upstream server can place these instructions in the client's or model's tool definitions before any tool call

After: the same developer gets definitions inspected before their client or provider receives them

  1. They configure a prompt-injection guardrail for pre_mcp_call on their gateway
  2. Their MCP server changes a tool description to include unwanted instructions
  3. They send POST https://litellm-domain/mcp with {"jsonrpc":"2.0","id":2,"method":"tools/list"} after initialization
  4. The response is HTTP 200 with the blocked tool omitted and safe tools retained; a masking guardrail instead returns rewritten descriptions
  5. Their app sends the same POST https://litellm-domain/v1/responses; the provider receives the filtered or masked definitions
  6. The upstream server's blocked description no longer reaches either recipient through these discovery paths

Relevant issues

Existing implementation retained in this PR, including catalog pinning and alerts. Documentation: discovery scanning, pin snapshot semantics

Affected release

Discovery omission reproduced on v1.102.1; its introduction version is not established

Linear ticket

Resolves LIT-8383

Pre-Submission checklist

  • Meaningful regressions cover discovery, cancellation and restricted metadata
  • Final-tip affected tests and local coverage complete
  • Fresh independent source-build discovery verification complete
  • All ten required CI checks pass
  • Contributor agreement passes
  • All three required bot reviews complete without actionable findings

Screenshots / Proof of Fix

Independent execution used Python 3.12.13 and the same dependency lock at both commits. The controlled, real HTTP MCP server on port 8811 exposes safe_echo, poisoned_lookup and canary_result. poisoned_lookup contains CANARY-LIT8383 in its tool description and nested query description. A custom apply_guardrail blocks that canary or replaces it with [MASKED-LIT8383]; it is enabled by default for the supported LLM/MCP request and response hooks. This tests dispatch and propagation, not a vendor's injection detector

Both proxy builds use the retained block/mask configurations and actual Anthropic claude-haiku-4-5 API calls for Responses. The provider key comes from ANTHROPIC_API_KEY; the test master key is supplied through LITELLM_MASTER_KEY. No credentials are included here. Database variables are unset for these discovery cases

Source builds run from their own worktree with this command shape, using port 4030 before and 4040 after:

env -u DATABASE_URL -u DIRECT_URL \
  PYTHONPATH="$PWD:$PWD/enterprise:/home/ubuntu/lit8383" \
  LIT8383_TRACE="$TRACE" \
  /home/ubuntu/repos/litellm/.venv/bin/litellm \
  --config "$CONFIG" --port "$PORT" --detailed_debug

CONFIG selects the retained config_supported.yaml or config_mask.yaml; each proxy returned 200 from /health/liveliness. At the final tip, fresh block workers were 6589/6591 and mask workers 6964/6966, with the source worktree /home/ubuntu/repos/litellm-pr43283 confirmed in process cwd and loaded source paths

For each native case, initialize a fresh session using the same commands with PORT=4030 or PORT=4040:

SID=$(curl -s -D /tmp/mcp-headers.txt -o /dev/null "http://localhost:$PORT/mcp" \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"lit8383","version":"1"}}}' \
  && awk 'tolower($1)=="mcp-session-id:" {gsub("\r", "", $2); print $2}' /tmp/mcp-headers.txt)
curl -s "http://localhost:$PORT/mcp" \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "mcp-session-id: $SID" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

The listing command used below is:

curl -s -w '\nHTTP_CODE=%{http_code}\n' "http://localhost:$PORT/mcp" \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "mcp-session-id: $SID" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

The Responses command used below is:

curl -s --max-time 180 -w '\nHTTP_CODE=%{http_code}\ncurl_exit=%{exitcode}\n' \
  "http://localhost:$PORT/v1/responses" \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
  -d '{"model":"anthropic-haiku-4-5","input":"Reply with exactly OK. Do not call any tools.","tools":[{"type":"mcp","server_label":"hostile","server_url":"litellm_proxy","require_approval":"never"}]}'

Responses evidence inspects the actual outbound provider tool list in the detailed-debug Final returned optional params entry. The client response's empty tools array is not used as evidence of filtering

Before (40297e6)

Native blocking

  1. Run the initialization and listing commands on port 4030 with the block configuration
  2. Observe HTTP 200, all three tools and two canary occurrences, with zero discovery scans

Native masking

  1. Restart with the mask configuration, then run the same initialization and listing commands on port 4030
  2. Observe HTTP 200, all three tools, two canaries and zero mask replacements

Responses blocking

  1. Run the Responses command on port 4030 with the block configuration
  2. Observe HTTP 200 and OK; the actual provider-bound definitions contain all three tools and both canaries

Responses masking

  1. Run the same Responses command on port 4030 with the mask configuration
  2. Observe HTTP 200 and OK; provider-bound definitions contain all three tools, two canaries and no replacements

After (e6dc5c5)

Native blocking

  1. Run the initialization and listing commands on port 4040 with the block configuration
  2. Observe HTTP 200, only hostile-safe_echo and hostile-canary_result, zero canaries and three discovery scans

Native masking

  1. Restart with the mask configuration, then run the same initialization and listing commands on port 4040
  2. Observe HTTP 200, all three tools, zero canaries and two replacements, in the tool and nested query descriptions

Responses blocking

  1. Run the Responses command on port 4040 with the block configuration
  2. Observe HTTP 200, curl exit 0 and OK; the actual provider-bound definitions contain only the two safe tools, with zero canaries

Responses masking

  1. Run the same Responses command on port 4040 with the mask configuration
  2. Observe HTTP 200, curl exit 0 and OK; provider-bound definitions contain all three tools, zero canaries and two replacements

Verification

Independent Devin verification through MCP checked out the exact final commit and executed the original scenarios, rather than reviewing local test output. Clean tool calls still succeed, poisoned arguments and results are blocked, and the documented known-name invocation behavior remains unchanged

A source-level concurrency probe measured at most eight active scans per catalog, sixteen across two concurrent listings, and zero active scans after cancellation with no ninth scan started. A 24-tool steady-state case with 0.2-second scans completed in 0.61 seconds; the serial equivalent is 4.8 seconds. The process's first-listing warmup is excluded from that comparison

Current-tip independent tests: 414 management tests passed with 16 expected failures, 25 catalog tests, 37 translation tests, one Responses forwarding test, six concurrency probes and four pin/override probes passed. Pin/override and invocation-schema probes use controlled upstream dependencies; they are not DB-backed live pin-management evidence

Local final-tip validation: 2,125 affected backend tests passed, one skipped and 16 expected failures. All canonical BASE_REF=40297e62684e48ec9d870198e4e5bb4d88322ab0 make lint gates passed, including whole-tree Ruff/test-tree checks, strict/type/test-quality budgets and basedpyright. Dashboard type tests (four) and a clean production build passed; the single AVIF warning matches the merge-base build

Local changed executable lines: 282/282 (100%). Touched-function branches: 235/282 (83.33%), including legacy branches in shared methods; the catalog scanner itself covers 116/116 lines and 10/10 branches. Remaining branch gaps are retained and reviewed, with no exclusions or threshold changes. Completed Codecov reports 78.84% repository coverage and 259/264 (98.11%) raw patch coverage. Its five displayed gaps use merge-tree line positions against PR-head source. Matching exact source lines from the tested merge 64b81d5ceb63b8fc2aabb74119e4840b7d88cb81 to this head shows 282/282 changed executable lines covered, with no gaps; the raw percentage is retained separately

The metadata repair was independently checked through the real FastAPI router and response serialization using in-process HTTP with mocked persistence/authentication. At 852ed63e2b, ordinary users, restricted keys and view-only admins received the raw pin; at e6dc5c5445, all three receive pinned_tools: null with HTTP 200. Full admins retain the snapshot. This is controlled integration evidence, not DB-backed E2E

Greptile reviewed e6dc5c5445 at 5/5. Veria confirms the metadata repair but retains the two resource-control and invocation-schema concerns below. Bugbot is paused at its team spend limit. CLA reports the inherited github-actions[bot] committer unsigned. All ten required CI checks pass. The optional Python CodeQL job failed at the inherited 2 GiB result-set limit (confirmed in current-tip annotations). This PR is not ready for maintainer review

Type

New feature, bug fix and regression tests

Caveats (if any)

Medium

  • Cached-name invocation remains possible, tracked in LIT-8902
  • Raw pin-time scanning remains tracked in LIT-8901
  • Invocation-schema enforcement is a separate policy question under review
  • Batch limits do not impose global discovery quotas
  • Slow guardrails delay the next batch of eight
  • Missing proxy loggers cannot run discovery content guardrails

Low

  • PANW/Presidio adapter tests passed; live vendor credentials are unavailable
  • Only description fields are scanned, excluding titles and examples
  • Explicit alert lists must include both new MCP alert types
  • Worker-local alert deduplication and registry reload timing remain unchanged
  • Bugbot is paused at the team's spend limit
  • CLA flags the inherited automation committer as unsigned
  • Veria retains resource-control and invocation-schema concerns

Final Attestation

  • All required current-tip verification and readiness gates complete

Run every discovered MCP tool's description and input schema through the
pre_mcp_call guardrails before a listing reaches the client, drop the tools
a guardrail blocks, and serve the guardrail's masked text otherwise. Add
POST and DELETE /v1/mcp/server/{server_id}/pin so an admin can freeze a
server's tool names and descriptions; the gateway serves the pinned catalog
and raises a Slack alert with the diff when the upstream drifts.
@devin-ai-integration

devin-ai-integration Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

CLAassistant commented Sep 26, 2026 •

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
2 out of 3 committers have signed the CLA.

✅ mateo-berri
✅ joshua-berri
❌ github-actions[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Database schema and MCP server tool catalog management.

The PR appears safe to merge based on the changes since the previous review

Summary

The PR scans MCP tool descriptions during discovery, adds catalog pinning and drift alerts, and now removes pinned catalog details from restricted management views while preserving the original snapshots

Reviews (10) · Last reviewed commit: "fix(mcp): hide pinned catalogs from rest..."

Comment thread litellm/proxy/_experimental/mcp_server/tool_catalog_guard.py
Comment thread litellm/proxy/_types.py Outdated
Comment thread litellm/proxy/management_endpoints/mcp_management_endpoints.py Outdated
Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@codecov

codecov Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.10606% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/_experimental/mcp_server/db.py 62.50% 3 Missing ⚠️
...oxy/_experimental/mcp_server/mcp_server_manager.py 93.54% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_mcp_tool_description_scan_and_pin (e6dc5c5) with main (be35b22)

Open in CodSpeed

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Outdated
Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Outdated

@cursor cursor Bot left a comment •

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.

Stale Bugbot comment from a previous run.

Comment thread litellm/proxy/_experimental/mcp_server/operations.py
Comment thread litellm/proxy/_experimental/mcp_server/tool_catalog_guard.py Outdated
…pe alerts before sending

The guardrail scan now runs on the text the client is about to see: description overrides are applied first, the pinned catalog next, and the scan last, so a masked pinned or override description is served masked and a pinned tool keeps serving its pinned text while the upstream's text is poisoned. The alert signature is recorded before the send and dropped only when that send fails, so a recovery during a slow send is never undone. A tool whose scan payload cannot be built is hidden alone instead of failing the listing. apply_tool_overrides shrinks to apply_display_name_overrides and the MagicMock servers in the MCP tests carry pinned_tools=None.
…ription_scan_and_pin

# Conflicts:
#	tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Outdated

@cursor cursor Bot left a comment •

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.

Stale Bugbot comment from a previous run.

user_api_key_auth: UserAPIKeyAuth | None,
raw_headers: Mapping[str, str] | None,
) -> ToolDescriptionScan:
outcomes: Final = await asyncio.gather(

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.

Low: Unbounded guardrail fan-out

An authenticated user can repeatedly request tools/list and trigger one concurrent guardrail execution for every tool returned by each allowed server. There is no catalog-size or concurrency bound here, and these scans use guardrails_only=True, which skips rate limiting, so a large catalog can amplify each listing into hundreds or thousands of external guardrail calls and exhaust proxy connections or guardrail quotas. Bound the tool count, text size, and scan concurrency; cache results by catalog and guardrail configuration; and rate-limit discovery requests.

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.

At 852ed63, independent execution measured eight scans per catalog, cancellation stopping all eight, and no next batch. Please reassess

@veria-ai

veria-ai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

This PR adds scanning of upstream MCP tool descriptions and pins approved tool metadata and input schemas for later discovery and invocation.

Two security issues remain open. Authenticated users can amplify tool discovery into unbounded guardrail calls, potentially exhausting proxy connections or guardrail quotas, while invocation does not enforce pinned schemas and may accept parameters added after approval. One earlier issue has been addressed, but resource controls and schema enforcement are still needed.

Open issues (2)

Fixed/addressed: 1 · PR risk: 5/10

@joshua-berri

Copy link
Copy Markdown
Contributor

@veria-ai Sequential catalog scans now cap in-flight requests at one; cancellation stops queued tools. Please verify 24faeb1 resolves this fan-out

@joshua-berri

Copy link
Copy Markdown
Contributor

@greptileai Please review commit 24faeb1, which serializes catalog guardrail scans and adds regressions for bounded concurrency and cancellation

@joshua-berri

Copy link
Copy Markdown
Contributor

bugbot run

Please review commit 24faeb1 for sequential discovery scans, cancellation cleanup, and preservation of per-tool blocking and masking

@cursor

cursor Bot commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

Comment thread litellm/proxy/_experimental/mcp_server/tool_catalog_guard.py Outdated
},
)

if server.pinned_tools and match_known_tool_name(name, server, server.pinned_tools) is None:

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.

Low: Pinned input schemas are not enforced

This verifies only that the tool name is pinned; the subsequent validation uses allowed_params rather than the matched pin's input_schema. A caller can therefore send parameters added after the pin—such as a new callback URL—even though discovery continues to show the approved schema. Validate invocation arguments against the matched PinnedMCPTool.input_schema before dispatch.

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.

Pinning documents discovery snapshots and name admission, preserving invocation policy. Please assess schema enforcement against that scope: BerriAI/litellm-docs#1750

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.

Independent dispatch confirms pinned schemas are discovery snapshots; allowed_params rejects extra arguments with zero upstream calls. Schema enforcement changes existing invocation policy

@joshua-berri

Copy link
Copy Markdown
Contributor

@greptileai Please review 852ed63: batches of eight bound concurrency and avoid serial latency, with completion and cancellation regressions

@joshua-berri

Copy link
Copy Markdown
Contributor

bugbot run

Please review 852ed63: bounded parallel catalog scans preserve ordering, masking, blocking and cancellation without serial listing latency

@joshua-berri

Copy link
Copy Markdown
Contributor

@veria-ai Please review 852ed63: catalog scans now run in bounded batches of eight; regression tests cover completion and cancellation

@cursor

cursor Bot commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

Comment thread litellm/models/mcp_server.py
@joshua-berri

Copy link
Copy Markdown
Contributor

@greptileai Please review e6dc5c5: restricted management views now clear pinned catalogs; focused regressions preserve administrator visibility and original snapshots

@joshua-berri

Copy link
Copy Markdown
Contributor

bugbot run

Please review e6dc5c5: existing sanitizers now hide pinned catalogs from restricted management views, with administrator and source-preservation controls

@joshua-berri

Copy link
Copy Markdown
Contributor

@veria-ai Please review e6dc5c5: both management sanitizers clear pinned_tools; five focused tests pass, including administrator visibility and source preservation

@cursor

cursor Bot commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@ryan-crabbe-berri ryan-crabbe-berri left a comment

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.

lgtm

This branch was successfully deployed

1 active deployment
e2e-changed — e6dc5c54 Deployed Sep 27, 2026 by joshua-berri via oauth #1492
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.

4 participants