Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -1190,7 +1190,7 @@ def _get_mcp_auth_header_from_headers(headers: Headers) -> str | None:

DEPRECATED: This method is deprecated in favor of server-specific auth headers using the format x-mcp-{{server_alias}}-{{header_name}} instead.
"""
mcp_client_side_auth_header_name: Final[str] = MCPRequestHandler._get_mcp_client_side_auth_header_name()
mcp_client_side_auth_header_name: Final[str] = MCPRequestHandler.get_mcp_client_side_auth_header_name()
auth_header: Final = headers.get(mcp_client_side_auth_header_name)
if auth_header:
verbose_logger.warning(
Expand Down Expand Up @@ -1265,7 +1265,7 @@ def _get_oauth2_headers_from_headers(headers: Headers) -> dict[str, str]:
return oauth2_headers

@staticmethod
def _get_mcp_client_side_auth_header_name() -> str:
def get_mcp_client_side_auth_header_name() -> str:
"""
Get the header name used to pass the MCP auth header to the MCP server

Expand Down
2 changes: 2 additions & 0 deletions litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
is_short_mcp_tool_prefix_enabled,
iter_known_server_prefixes,
iter_known_tool_name_spellings,
logging_safe_mcp_headers,
match_known_server_prefix,
match_known_tool_name,
merge_mcp_headers,
Expand Down Expand Up @@ -4603,6 +4604,7 @@ async def pre_call_tool_check(
),
"user_api_key_hash": (getattr(user_api_key_auth, "api_key_hash", None) if user_api_key_auth else None),
"incoming_bearer_token": incoming_bearer_token,
"headers": logging_safe_mcp_headers(raw_headers),
}

# Create MCP request object for processing
Expand Down
99 changes: 7 additions & 92 deletions litellm/proxy/_experimental/mcp_server/sampling_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1042,100 +1042,15 @@ def _build_sampling_request(
raw_headers: dict[str, str] | None = None,
client_ip: str | None = None,
) -> "Request":
"""Build a synthetic FastAPI Request for sampling sub-calls.

Converts the original MCP connection's HTTP headers into ASGI
scope format so that ``add_litellm_data_to_request`` can apply
header-dependent guardrails, tag-based routing, trace correlation,
and ``forward_llm_provider_auth_headers``.

Key fields populated:
- **headers**: All original HTTP headers are forwarded (except
hop-by-hop: content-length, transfer-encoding). This ensures
``traceparent``, ``authorization``, ``user-agent``, and
``x-litellm-api-key`` are visible to pre-call utils.
- **client**: The ASGI ``(host, port)`` tuple so that
``request.client.host`` returns the real client IP for
IP-based routing and guardrails.
- **server**: Derived from the running proxy's ``server_host``
/ ``server_port`` when available, avoiding the misleading
``127.0.0.1:0`` placeholder.
- **x-forwarded-for**: Injected from ``client_ip`` if the
original headers don't already carry it, as a fallback for
IP attribution.
"""
from fastapi import Request
"""The synthetic FastAPI Request for sampling sub-calls, carrying the original
MCP connection's headers and client IP."""
from litellm.proxy._experimental.mcp_server.utils import build_synthetic_mcp_request

# --- Build ASGI headers ---
_scope_headers: Final[list[tuple[bytes, bytes]]] = [(b"content-type", b"application/json")]
# Hop-by-hop headers that must NOT be forwarded into the
# synthetic request (they describe the original HTTP framing,
# not the logical request).
_HOP_BY_HOP: Final = frozenset(
{
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"upgrade",
"te",
"trailer",
}
return build_synthetic_mcp_request(
path="/mcp/sampling/createMessage",
raw_headers=raw_headers,
client_ip=client_ip,
)
if raw_headers:
for hdr_name, hdr_value in raw_headers.items():
_key = hdr_name.lower()
# Skip content-type (already set), x-forwarded-for (use resolved
# client_ip instead to prevent spoofing), and hop-by-hop headers
if _key in {"content-type", "x-forwarded-for"} or _key in _HOP_BY_HOP:
continue
_scope_headers.append(
(
_key.encode("latin-1", errors="replace"),
hdr_value.encode("utf-8"),
)
)

# Inject x-forwarded-for from captured client_ip if the
# original headers don't already carry it
if client_ip and not any(h[0] == b"x-forwarded-for" for h in _scope_headers):
_scope_headers.append((b"x-forwarded-for", client_ip.encode("utf-8")))

# --- Derive server (host, port) from the running proxy ---
_server_host = "127.0.0.1"
_server_port = 4000 # LiteLLM default
try:
from litellm.proxy import proxy_server

_proxy_host: Final[str | None] = getattr(proxy_server, "server_host", None)
_proxy_port: Final[str | int | None] = getattr(proxy_server, "server_port", None)

if _proxy_host:
_server_host = str(_proxy_host)
if _proxy_port:
_server_port = int(_proxy_port)
except (ImportError, AttributeError, TypeError, ValueError):
pass

# --- Build ASGI client tuple for request.client.host ---
_client_tuple = None
if client_ip:
_client_tuple = (client_ip, 0)

scope: Final[dict[str, object]] = {
"type": "http",
"method": "POST",
"path": "/mcp/sampling/createMessage",
"scheme": "http",
"server": (_server_host, _server_port),
"query_string": b"",
"root_path": "",
"headers": _scope_headers,
}
if _client_tuple is not None:
scope["client"] = _client_tuple

return Request(scope=scope)


async def _build_completion_kwargs(
Expand Down
36 changes: 18 additions & 18 deletions litellm/proxy/_experimental/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@
LITELLM_MCP_SERVER_VERSION,
MCPMissingUserEnvVarsError,
add_server_prefix_to_name,
build_synthetic_mcp_request,
extract_mcp_tool_result_error_message,
get_server_prefix,
iter_known_server_prefixes,
logging_safe_mcp_headers,
match_known_tool_name,
)
from litellm.proxy._types import (
Expand Down Expand Up @@ -860,11 +862,11 @@ async def _build_virtual_call_logging_obj(
name: str,
arguments: dict[str, object],
user_api_key_auth: UserAPIKeyAuth,
raw_headers: Mapping[str, str] | None = None,
client_ip: str | None = None,
) -> LiteLLMLoggingObj | None:
"""Run the pre-call pipeline (guardrails + logging setup) for a virtual
mcp_tool_call so the SSE path spend-logs like the REST path."""
from fastapi import Request

from litellm.proxy.common_request_processing import (
ProxyBaseLLMRequestProcessing,
)
Expand All @@ -874,13 +876,10 @@ async def _build_virtual_call_logging_obj(
proxy_logging_obj,
)

request: Final = Request(
scope={
"type": "http",
"method": "POST",
"path": "/mcp/tools/call",
"headers": [(b"content-type", b"application/json")],
}
request: Final = build_synthetic_mcp_request(
path="/mcp/tools/call",
raw_headers=raw_headers,
client_ip=client_ip,
)
_, virtual_logging_obj = await ProxyBaseLLMRequestProcessing(
data={"name": name, "arguments": arguments}
Expand Down Expand Up @@ -952,7 +951,11 @@ async def _dispatch_virtual_mcp_tool(

assert user_api_key_auth is not None # guaranteed by the flag check above
virtual_logging_obj: Final = await _build_virtual_call_logging_obj(
name=name, arguments=args, user_api_key_auth=user_api_key_auth
name=name,
arguments=args,
user_api_key_auth=user_api_key_auth,
raw_headers=raw_headers,
client_ip=client_ip,
)
return await handle_mcp_tool_call(
tool_name=args.get("tool_name", ""),
Expand All @@ -979,7 +982,6 @@ async def mcp_server_tool_call(name: str, arguments: dict[str, object] | None) -
Raises:
HTTPException: If tool not found or arguments missing
"""
from fastapi import Request
from mcp.server.lowlevel.server import request_ctx
from mcp.types import CallToolResult

Expand Down Expand Up @@ -1041,13 +1043,10 @@ async def mcp_server_tool_call(name: str, arguments: dict[str, object] | None) -
body_data["litellm_trace_id"] = chain_id
body_data["litellm_session_id"] = chain_id

request: Final = Request(
scope={
"type": "http",
"method": "POST",
"path": "/mcp/tools/call",
"headers": [(b"content-type", b"application/json")],
}
request: Final = build_synthetic_mcp_request(
path="/mcp/tools/call",
raw_headers=raw_headers,
client_ip=_client_ip,
)
if user_api_key_auth is not None:
data = await add_litellm_data_to_request(
Expand Down Expand Up @@ -1905,6 +1904,7 @@ async def _get_tools_from_mcp_servers(
"litellm_trace_id": effective_litellm_trace_id,
"metadata": {
"spend_logs_metadata": spend_logs_metadata,
"headers": logging_safe_mcp_headers(raw_headers),
**({"tags": request_tags} if request_tags else {}),
},
# Provide a small input payload for standard logging
Expand Down
147 changes: 147 additions & 0 deletions litellm/proxy/_experimental/mcp_server/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
import json
import os
import re
import typing
from collections.abc import Iterable, Iterator, Mapping, MutableMapping, MutableSequence
from collections.abc import Set as AbstractSet
from typing import Any, Final, Protocol
from urllib.parse import quote

from litellm.types.mcp_server.mcp_server_manager import MCPServer

if typing.TYPE_CHECKING:
from fastapi import Request


class _McpServerLike(Protocol):
@property
Expand Down Expand Up @@ -862,3 +866,146 @@ def set_mcp_tool_result_structured_content(result: object, value: object) -> boo
return True
except (AttributeError, TypeError, ValueError):
return False


_HOP_BY_HOP_HEADERS: Final = frozenset(
{
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"upgrade",
"te",
"trailer",
}
)

_SYNTHETIC_REQUEST_EXCLUDED_HEADERS: Final = _HOP_BY_HOP_HEADERS | frozenset({"content-type", "x-forwarded-for"})

_SYNTHETIC_REQUEST_SERVER: Final = ("127.0.0.1", 4000)

_MCP_SERVER_AUTH_HEADER_PREFIX: Final = "x-mcp-"


def _custom_litellm_key_header_name() -> str | None:
"""``general_settings.litellm_key_header_name``, the deployment's custom header name for
the proxy virtual key, so it is stripped from observability copies like the standard ones."""
try:
from litellm.proxy.proxy_server import general_settings
except ImportError:
return None
return general_settings.get("litellm_key_header_name") if general_settings else None


def _mcp_client_side_auth_header_name() -> str:
"""The header name the client passes the upstream MCP credential in, falling back to the
default when ``general_settings`` is unavailable (the SDK, outside a running proxy)."""
from .auth.user_api_key_auth_mcp import MCPRequestHandler

try:
return MCPRequestHandler.get_mcp_client_side_auth_header_name()
except ImportError:
return MCPRequestHandler.LITELLM_MCP_AUTH_HEADER_NAME


def _upstream_credential_headers(header_names: Iterable[str]) -> frozenset[str]:
"""Lowercased names of the headers in ``header_names`` that carry an upstream MCP
credential rather than request context: the configured client side auth header and
the per-server ``x-mcp-{alias}-{header}`` family. ``clean_headers`` only knows the
credential headers of the chat completions path, so these are dropped on top of it.
"""
from .auth.user_api_key_auth_mcp import MCPRequestHandler

non_credential: Final = frozenset(
{
MCPRequestHandler.LITELLM_MCP_SERVERS_HEADER_NAME.lower(),
MCPRequestHandler.LITELLM_MCP_ACCESS_GROUPS_HEADER_NAME.lower(),
}
)
client_side_auth: Final = _mcp_client_side_auth_header_name().lower()
return frozenset(
name
for name in (raw_name.lower() for raw_name in header_names)
if name == client_side_auth or (name.startswith(_MCP_SERVER_AUTH_HEADER_PREFIX) and name not in non_credential)
)


def build_synthetic_mcp_request(
*,
path: str,
raw_headers: Mapping[str, str] | None = None,
client_ip: str | None = None,
) -> "Request":
"""A synthetic FastAPI ``Request`` carrying the MCP connection's HTTP headers.

The MCP protocol transports do not hand a per-call ``Request`` to the tool
handlers, so one is reconstructed from the connection's ``raw_headers``. That
lets ``add_litellm_data_to_request`` derive ``metadata.headers``,
``proxy_server_request``, header-based tags, guardrails and trace correlation
exactly as on the chat completions path. Hop-by-hop headers describe the
original HTTP framing rather than the logical request, so they are dropped, and
``x-forwarded-for`` comes from the resolved ``client_ip`` to avoid spoofing. Upstream
MCP credentials and the deployment's proxy key header, including a custom
``litellm_key_header_name``, are dropped so they cannot reach a callback or a guardrail
through the derived metadata even when a caller omits ``general_settings``.
"""
from fastapi import Request

custom_key_header: Final = _custom_litellm_key_header_name()
excluded: Final = (
_SYNTHETIC_REQUEST_EXCLUDED_HEADERS
| _upstream_credential_headers(raw_headers.keys() if raw_headers else ())
| (frozenset({custom_key_header.lower()}) if custom_key_header else frozenset())
)
forwarded: Final = tuple(
(
name.lower().encode("latin-1", errors="replace"),
value.encode("utf-8", errors="replace"),
)
for name, value in (raw_headers.items() if raw_headers else ())
if name.lower() not in excluded
)
xff: Final = ((b"x-forwarded-for", client_ip.encode("utf-8")),) if client_ip else ()
return Request(
scope={
"type": "http",
"method": "POST",
"path": path,
"scheme": "http",
"server": _SYNTHETIC_REQUEST_SERVER,
"query_string": b"",
"root_path": "",
"headers": ((b"content-type", b"application/json"), *forwarded, *xff),
**({"client": (client_ip, 0)} if client_ip else {}),
}
)


def logging_safe_mcp_headers(raw_headers: Mapping[str, str] | None) -> Mapping[str, str]:
"""The MCP request's client headers, sanitized the way the chat completions path
sanitizes them before they reach a logging callback or a guardrail: proxy key
headers stripped, including the custom key header name the deployment configured,
upstream MCP credentials dropped, and credential-bearing values masked.

Client-controlled behaviour flags (``litellm-disable-message-redaction``) are dropped
too: these headers are read back out of the metadata to change proxy behaviour, so
leaving one in place would let any MCP client turn off the redaction an admin
configured. This path carries no key or team object to authorize an opt-out with, so
it always strips them."""
from starlette.datastructures import Headers

from litellm.proxy.litellm_pre_call_utils import (
UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS,
clean_headers,
redact_credential_headers,
)

excluded: Final = (
_upstream_credential_headers(raw_headers.keys() if raw_headers else ())
| UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS
)
cleaned: Final = clean_headers(
Headers(raw_headers),
litellm_key_header_name=_custom_litellm_key_header_name(),
)
return redact_credential_headers({name: value for name, value in cleaned.items() if name.lower() not in excluded})
Loading
Loading