Skip to content

feat(mcp): per-server env vars with global + per-user scopes - #28401

Closed
mateo-berri wants to merge 12 commits into
litellm_internal_stagingfrom
claude/mcp-server-env-vars-p89wT
Closed

feat(mcp): per-server env vars with global + per-user scopes#28401
mateo-berri wants to merge 12 commits into
litellm_internal_stagingfrom
claude/mcp-server-env-vars-p89wT

Conversation

@mateo-berri

Copy link
Copy Markdown
Contributor

Summary

Adds admin-configurable environment variables on MCP servers, interpolated into static_headers (and any other ${NAME} reference) at request time. Two scopes:

  • Global: value supplied by the admin once, used for every user.
  • Per-user: each user fills in their own value via the MCP Gateway dashboard.

The "Add MCP Server" form gains a new section with three columns — variable name | value | scope (Global / Per-user) — and reference syntax in headers is e.g. ${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@${DB_HOST}/db.

When a user invokes an MCP server before filling in their required values:

  • The proxy returns a friendly user-facing error including the missing variable names and a setup URL (/ui/?page=mcp-servers&fill_env_vars=<server_id>).
  • The dashboard server list highlights that server in red with a clickable chip showing the count of missing fields. Clicking it opens a modal pre-loaded with the required slots.
  • Saving values clears the red highlight; the next MCP call succeeds.

Demo flow

  1. Admin creates a new MCP server with a mix of global and per-user env vars.
  2. A user tries to use the MCP server in Claude Code → gets a clear error with a link.
  3. They follow the link to the dashboard, fill in their per-user fields, and the red highlight disappears.
  4. Trying the MCP server in Claude Code again now works.

Implementation

Backend

  • New LiteLLM_MCPServerTable.env_vars JSON column ([{name, value, scope, description}]) and per-user LiteLLM_MCPUserEnvVars table with encrypted values.
  • Schema migration in litellm-proxy-extras/.../migrations/20260520120000_add_mcp_env_vars/.
  • Interpolation helpers in _experimental.mcp_server.utils:
    • parse_admin_env_vars — split globals from per-user specs
    • interpolate_headers / interpolate_env_vars${NAME} substitution
    • collect_env_var_references — discover which vars a header references
    • MCPMissingUserEnvVarsError — friendly user-facing error with setup URL
    • build_env_var_setup_url — frontend deep-link URL
  • MCPServerManager._resolve_static_headers_with_env_vars merges globals + per-user values and raises when a required user var is unset. Wired into both call_tool and _get_tools_from_server.
  • REST endpoints under /v1/mcp:
    • GET / POST / DELETE /server/{id}/user-env-vars
    • GET /user-env-vars/status — dashboard summary across all accessible servers
  • Both the MCP-protocol call_tool (server.py) and REST /tools/call (rest_endpoints.py) surface MCPMissingUserEnvVarsError as a user-facing message with the setup URL (REST returns HTTP 412 with structured detail).

UI

  • New EnvVarsSection.tsx form component in the Add / Edit MCP Server modal: three columns (name, value, scope) plus a help tooltip explaining ${NAME} syntax and Global vs Per-user behavior. Validates variable names with the same identifier regex the backend uses.
  • New user-facing UserEnvVarsModal.tsx for filling in per-user values, with descriptions surfaced as placeholders.
  • mcp_server_columns.tsx now renders a red highlight + clickable "N missing fields" chip on each server with missing per-user values; clicking opens the modal.
  • mcp_servers.tsx fetches /user-env-vars/status and supports a ?fill_env_vars=<server_id> deep-link so the error message's URL lands the user in the right place.
  • create_mcp_server.tsx and mcp_server_edit.tsx serialize the form list to the backend payload (filtering invalid identifiers and de-duplicating).
  • networking.tsx adds getMCPUserEnvVars, storeMCPUserEnvVars, clearMCPUserEnvVars, and listMCPUserEnvVarStatus.

Test plan

  • New unit tests under tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py (15 tests) covering:
    • parse_admin_env_vars (mixed scopes, empty, malformed)
    • ${NAME} reference detection and interpolation
    • build_env_var_setup_url with and without PROXY_BASE_URL
    • MCPMissingUserEnvVarsError message formatting (singular + plural)
    • _resolve_static_headers_with_env_vars end-to-end: interpolation with mixed scopes, raises with friendly URL when missing, passthrough when no env vars, unreferenced per-user vars are not blocking.
  • Full MCP test suite passes (158/158 in tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py + test_mcp_server_manager.py).
  • uv run black clean on all modified Python files.
  • npx tsc --noEmit reports zero new errors on the dashboard.
  • Manual verification of the four-step demo flow in a running proxy + dashboard.

Generated by Claude Code

Adds admin-configurable environment variables on MCP servers, interpolated
into static_headers (and any other ${NAME} reference) at request time.

- Global vars: value supplied by admin, used for every user.
- Per-user vars: each user fills in their own value via the MCP Gateway
  dashboard. The proxy returns a friendly 412 with a setup URL when a
  user invokes a server before filling in their required values.

Backend
- New ``LiteLLM_MCPServerTable.env_vars`` JSON column and per-user
  ``LiteLLM_MCPUserEnvVars`` table (encrypted values).
- Interpolation helpers in ``_experimental.mcp_server.utils``
  (parse_admin_env_vars, interpolate_headers, MCPMissingUserEnvVarsError,
  build_env_var_setup_url).
- ``MCPServerManager._resolve_static_headers_with_env_vars`` merges
  globals + per-user values and raises when a required user var is
  unset. Wired into both the call_tool and list_tools paths.
- REST endpoints under ``/v1/mcp``:
  - GET/POST/DELETE ``/server/{id}/user-env-vars``
  - GET ``/user-env-vars/status`` (dashboard summary)
- MCP-protocol call_tool and REST /tools/call surface the missing-vars
  error as a user-facing message with the setup URL.

UI
- ``EnvVarsSection`` form component in the Add / Edit MCP Server modal:
  three columns (name, value, scope). Scope is a Global / Per-user
  select.
- User-facing ``UserEnvVarsModal`` for filling in per-user values.
- Dashboard server list highlights servers with missing per-user vars
  in red with a clickable missing-count chip that opens the modal.
- Deep-link ``?fill_env_vars=<server_id>`` auto-opens the modal so the
  link in the proxy's friendly error lands the user in the right place.
@CLAassistant

CLAassistant commented May 20, 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.
1 out of 2 committers have signed the CLA.

✅ mateo-berri
❌ claude
You have signed the CLA already but the status is still pending? Let us recheck it.

@codspeed-hq

codspeed-hq Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing claude/mcp-server-env-vars-p89wT (accecaf) with main (79b4578)

Open in CodSpeed

@codecov

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

Expands env-vars test suite from 15 to 25 tests, adding coverage for:

- ``_load_user_env_vars`` guard paths (no user, no user_id, no prisma_client)
- DB helpers ``store_user_env_vars`` / ``get_user_env_vars`` /
  ``get_user_env_vars_bulk`` / ``delete_user_env_vars``: round-trip,
  encrypted-at-rest, missing row, empty server_ids short-circuit,
  delete unique key.
- ``MCPMissingUserEnvVarsError`` rendering as a friendly
  ``CallToolResult`` for the MCP-protocol path.
@mateo-berri
mateo-berri changed the base branch from main to litellm_internal_staging May 24, 2026 00:36
claude and others added 6 commits May 24, 2026 02:41
Adopts the desired UI from the prototype PR while keeping the working
backend (bulk /user-env-vars/status, scope global/user):

- Replace the MCP servers table with a card grid (MCPServerCard) plus
  search and sort. Per-user status renders as a red "N user fields
  missing / Set" footer on each card, driven by the bulk status endpoint
  (no per-card N+1 fetch).
- Restyle EnvVarsSection as a purple 3-column editor (name / value /
  scope) with scope labeled Instance / Per-user; value disabled for
  per-user rows. Surface it as a top-level section in the create and
  edit forms instead of inside the collapsed Permission panel.
- Restyle UserEnvVarsModal to match the prototype fill modal
  (Per-user tag, masked inputs, "Save Credentials").
- Revert the now-unused env-var chip in mcp_server_columns to baseline.

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
Adds unit tests for the four /v1/mcp per-user env-var handlers and the
_compute_user_env_var_status helper, which were the dominant codecov/patch
gap (72 uncovered lines in mcp_management_endpoints.py). Covers happy paths,
missing-user-id (400), unknown-server (404), allowed/non-empty value
filtering, the swallowed delete error, and the bulk status filter.

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
Previously a server whose static_headers referenced an unfilled per-user
var contributed 0 tools to the listing (the resolver raised and the list
path swallowed it). Now listing is best-effort: available vars interpolate,
unfilled ${NAME} refs are left untouched, so the server's tools still appear.
The friendly MCPMissingUserEnvVarsError is raised only on the tool-call path,
matching the intended demo flow (connect/list works; you see the missing-vars
error when you actually invoke a tool).

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
Renames the admin/per-user MCP variable concept from "env vars" /
"environment variables" to just "variables" across Pydantic types, DB
helpers, utils, the server manager, management endpoints + routes, error
codes, all three schema.prisma copies + the migration, and tests.
Behavior is unchanged. The discovery registry's env_vars (OS-level vars
for external servers) and the stdio env field are intentionally left as-is.

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
Per-user MCP variables are now shared by name across every server/instance the
user can access, keyed by user_id (one encrypted row per user) instead of per
(user, server). Adds a kind ("template"|"instance") discriminator and
template_id to the server table; templates are config blueprints and are not
loaded as live servers. The variable read API is write-only — it returns only
variable names + is_set, never the stored values.

- schema (3 copies) + migrations: global LiteLLM_MCPUserVariables, kind/template_id
- db helpers drop server_id; remove get_user_variables_bulk
- endpoints: global GET/POST/DELETE /user/variables (aggregated status);
  /user-variables/status skips templates
- manager excludes templates from DB load; global variable lookup
- tests updated + coverage for aggregation, template exclusion, write-only

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
Adds an optional credential-store backend for per-user MCP variables. When an
admin configures a store (general_settings.mcp_variable_store or the
LITELLM_MCP_VARIABLE_STORE env var; HashiCorp Vault or the proxy's global
key-management system), variables are stored there as a single encrypted JSON
blob per user keyed by litellm/mcp/user/{user_id} — opaque even to store admins.
Otherwise they fall back to the encrypted DB column (store is exclusive when
configured). Endpoints and the server manager now resolve variables through
this store layer.

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
…-backed lookup

Brings the Phase 2 patch coverage up: full coverage of the variable-store
backend (provider resolution incl. error fallback, HashiCorp + KMS manager
build, get/build caching), the manager's template-exclusion query in
reload_servers_from_database, and the store-backed _load_user_variables
success/error paths.

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
Adds branch coverage for parse_admin_variables (pydantic-model and
non-dict entries), interpolate_variables empty input, the manager's
_deserialize_json_list variants, the no-static-headers resolve path, MCP
server variable serialization, and _decode_user_variables error blobs —
lifting Phase 2 patch coverage well above the project baseline.

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
Finishes the env-vars -> variables rename in the dashboard and repoints the
per-user variable UI to the new global endpoints. Per-user variables are now
shared per user (GET/POST/DELETE /v1/mcp/user/variables) with a write-only
contract: the modal never prefills values and shows a "set" placeholder for
already-set fields, sending only non-empty fields. Adds kind/template_id to the
MCPServer type and the MCPUserVariablesGlobalStatus type. The admin server form
now sends `variables` (previously `env_vars`, which the renamed backend ignored).

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
Adds the Templates / Variables / Instances UX to the MCP dashboard:
- Splits servers by kind: the Instances tab (live servers) keeps the existing
  filters/detail view; a new Templates tab lists blueprints with a "Create
  instance" action (admins can add/edit/delete templates).
- New Variables tab + reusable UserVariablesForm for the global, write-only
  per-user variables (masked "set" placeholder, only non-empty values submitted).
- Create form gains a Type selector (Instance/Template); "Create instance"
  prefills from the template (user-scope values blanked) and submits
  kind=instance + template_id.
- Deep-links: ?tab=variables and ?fill_variables=<id> open the Variables tab.

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it?

I read the description against our contribution rubric. Here's how it lined up:

What you got right:

  • ✅ Clear problem description
  • ✅ Expected vs. actual behavior

What's still missing:

  • linked GitHub issue or explicit issue reference
  • end-to-end QA proof (video, screenshot, or real commands with output)

The PR has a clear problem description and expected-vs-actual behavior in the summary/demo flow, but it does not include any qualifying end-to-end proof. The body explicitly says manual verification is still pending, and unit tests alone do not satisfy the QA requirement.

If the description isn't updated in the next 24 hours, I'll auto-close this PR. That's not us saying we don't care about the change; we want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later," not a rejection. Take your time; everything below still works after the close.

During the grace period: just update the PR description with the missing pieces. No need to ping me; I'll re-check on the next sweep and skip the auto-close if it now passes. See what counts as QA proof for the full rubric (a linked issue alone isn't enough; it covers context, not proof).

If the PR does get auto-closed in 24 hours, you still have easy recovery paths:

  • Comment @agent-shin reconsider after updating the description. I'll re-evaluate and reopen the PR if it now passes.
  • Comment @greptileai to request a fresh Greptile review; that still works even after the PR is closed, and a stronger score is one of the signals that lifts the PR back into the queue. So a low Greptile score isn't a blocker either.

Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.

(I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a maintainer; they'll override me.)

@devin-ai-integration

Copy link
Copy Markdown
Contributor

🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it?

I read the description against our contribution rubric. Here's how it lined up:

What you got right:

  • ✅ Clear problem description
  • ✅ Expected vs. actual behavior

What's still missing:

  • End-to-end QA proof: the test plan's own last box, "Manual verification of the four-step demo flow in a running proxy + dashboard", is unchecked

Global versus per-user scopes and the ${NAME} interpolation are clearly specified, and the 15 unit tests cover the parsing well. What is missing is the flow itself: the admin form saved, the red missing-fields chip, and one MCP call before and after the user fills their values.

Closing this PR isn't a rejection of the change. We want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later"; your work is still here, the diff is still here, and getting it reopened is one comment away. Take your time.

To bring this PR back:

  • Update the description with the missing pieces, then comment @agent-shin reconsider on this PR. I'll re-evaluate and reopen if it now passes.
  • Or Open a new PR with the same fix and the updated description. GitHub doesn't always let external contributors reopen a bot-closed PR, so a fresh PR is the most reliable path back into the review queue.
  • If Greptile's most recent score on this PR was below 4/5, comment @greptileai to request a fresh review; that still works even after the PR is closed, and a stronger score is one of the signals that lifts the PR back into the queue. A low Greptile score isn't a blocker.

What "end-to-end QA proof" means, since it's the most common gap: at least one of a short before/after screen recording / video (the bug reproducing, then the fix working; for a brand-new feature, a recording of it working end-to-end), a screenshot (or before/after screenshots) of it working, or the exact commands you ran paired with their real output against the real system. Running pytest on the repo's unit tests doesn't count; those mock the LLM provider, DB, and network, so they aren't end-to-end. Output from a real, no-mocks integration run is what we look for. A linked issue alone isn't enough either: it covers context, not proof. See the full rubric.

Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.

(I'm an LLM, so I'm not infallible. If you think I got this wrong, comment @agent-shin reconsider or ping a maintainer; they'll override me.)

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.

3 participants