feat(mcp): support TLS client certificates (mTLS) for HTTP and SSE servers (#33721)

Adds first-class `client_cert` / `client_key` config keys so MCP servers
behind mTLS work without an external TLS-terminating proxy. Resolves
inbound community question (Jeremy W.).

Schema (per `mcp_servers.<name>`, HTTP/SSE only):

- `client_cert: "/path/to/combined.pem"` — single PEM with cert + key
- `client_cert: "/path/to/cert"` + `client_key: "/path/to/key"` — separate
- `client_cert: [cert, key]` or `[cert, key, password]` — list form,
  with optional passphrase for encrypted keys

Paths support `~` expansion. Missing files raise a server-scoped
`FileNotFoundError` at connect time rather than failing later with an
opaque TLS handshake error.

Wiring:

- New SDK HTTP path (mcp >= 1.24): `cert=` on the user-owned
  `httpx.AsyncClient` alongside the existing `verify=` handling.
- SSE path: routed through an `httpx_client_factory` that wraps the
  SDK's defaults (follow_redirects=True) and layers `verify` + `cert`
  on top. The factory is only injected when needed, so the SDK's
  built-in `create_mcp_http_client` keeps being used in the default
  case.
- Deprecated mcp<1.24 path left untouched — that SDK's
  `streamablehttp_client` signature doesn't expose `cert`, and adding
  it would be dead code.

Also documents the previously-undocumented `ssl_verify` key (bool or
CA bundle path) in the MCP config reference.

Tests:

- `tests/tools/test_mcp_client_cert.py` (new, 19 tests):
  - `_resolve_client_cert` helper: all three input forms, `~` expansion,
    missing-file and validation errors.
  - HTTP transport: `cert=` forwarded into `httpx.AsyncClient` for
    string and tuple forms; absent when unset; missing-file error
    propagates.
  - SSE transport: factory only injected when cert or non-default
    verify is set; factory applies cert, custom CA bundle, and
    preserves `follow_redirects=True` + forwarded headers/auth.
- Existing tests: 200/200 in `test_mcp_tool.py` + `test_mcp_sse_transport.py`
  still pass.
This commit is contained in:
Teknium
2026-05-28 00:55:55 -07:00
committed by GitHub
parent 8595281f3c
commit 87e5b2fae0
3 changed files with 671 additions and 0 deletions
+107
View File
@@ -559,6 +559,79 @@ def _validate_remote_mcp_url(server_name: str, url: Any) -> str:
return stripped
def _resolve_client_cert(server_name: str, config: dict):
"""Resolve the ``client_cert`` / ``client_key`` config for mTLS.
Returns whatever ``httpx``'s ``cert=`` parameter accepts, or ``None`` when
no client certificate is configured:
- ``None`` if neither ``client_cert`` nor ``client_key`` is set.
- A single absolute path string if ``client_cert`` is a string and
``client_key`` is unset (PEM file with cert + key combined).
- A ``(cert_path, key_path)`` tuple when both are set, or when
``client_cert`` is a 2-element list/tuple.
- A ``(cert_path, key_path, password)`` tuple when ``client_cert`` is
a 3-element list/tuple — the third element is the key passphrase.
User paths support ``~`` expansion. Missing files raise ``FileNotFoundError``
with a server-scoped message so the failure surfaces as a clear setup
error rather than an opaque TLS handshake error.
"""
raw_cert = config.get("client_cert")
raw_key = config.get("client_key")
if raw_cert is None and raw_key is None:
return None
def _expand(path: Any, label: str) -> str:
if not isinstance(path, str) or not path.strip():
raise ValueError(
f"MCP server '{server_name}': {label} must be a non-empty "
f"string path (got {type(path).__name__})"
)
expanded = os.path.expanduser(path.strip())
if not os.path.isfile(expanded):
raise FileNotFoundError(
f"MCP server '{server_name}': {label} not found at "
f"{expanded!r}"
)
return expanded
# Tuple/list form for client_cert — (cert, key) or (cert, key, password).
if isinstance(raw_cert, (list, tuple)):
if raw_key is not None:
raise ValueError(
f"MCP server '{server_name}': specify either client_cert as "
f"a list [cert, key] OR client_cert + client_key, not both"
)
if len(raw_cert) == 2:
cert_path = _expand(raw_cert[0], "client_cert[0]")
key_path = _expand(raw_cert[1], "client_cert[1]")
return (cert_path, key_path)
if len(raw_cert) == 3:
cert_path = _expand(raw_cert[0], "client_cert[0]")
key_path = _expand(raw_cert[1], "client_cert[1]")
password = raw_cert[2]
if not isinstance(password, str):
raise ValueError(
f"MCP server '{server_name}': client_cert[2] (key "
f"passphrase) must be a string"
)
return (cert_path, key_path, password)
raise ValueError(
f"MCP server '{server_name}': client_cert list form must have 2 "
f"or 3 elements (got {len(raw_cert)})"
)
# String form for client_cert.
cert_path = _expand(raw_cert, "client_cert")
if raw_key is not None:
key_path = _expand(raw_key, "client_key")
return (cert_path, key_path)
# Single combined PEM file (cert + key in one file).
return cert_path
def _format_connect_error(exc: BaseException) -> str:
"""Render nested MCP connection errors into an actionable short message."""
@@ -1362,6 +1435,7 @@ class MCPServerTask:
headers["mcp-protocol-version"] = LATEST_PROTOCOL_VERSION
connect_timeout = config.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT)
ssl_verify = config.get("ssl_verify", True)
client_cert = _resolve_client_cert(self.name, config)
# OAuth 2.1 PKCE: route through the central MCPOAuthManager so the
# same provider instance is reused across reconnects, pre-flow
@@ -1413,6 +1487,37 @@ class MCPServerTask:
# behind OAuth 2.1 PKCE work. Previously built but never
# forwarded — SSE OAuth would silently fail with 401s.
_sse_kwargs["auth"] = _oauth_auth
if client_cert is not None or ssl_verify is not True:
# SSE transport doesn't expose verify/cert as kwargs, so route
# them through an httpx_client_factory that wraps the SDK's
# defaults (follow_redirects=True) and adds our TLS settings.
# The SDK calls the factory with (headers, auth, timeout); we
# forward all of those and layer verify/cert on top.
import httpx as _httpx_mod
_cert_for_factory = client_cert
_verify_for_factory = ssl_verify
def _mcp_http_client_factory(
headers=None, timeout=None, auth=None,
):
kwargs: dict = {
"follow_redirects": True,
"verify": _verify_for_factory,
}
if timeout is not None:
kwargs["timeout"] = timeout
else:
kwargs["timeout"] = _httpx_mod.Timeout(30.0, read=300.0)
if headers is not None:
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
if _cert_for_factory is not None:
kwargs["cert"] = _cert_for_factory
return _httpx_mod.AsyncClient(**kwargs)
_sse_kwargs["httpx_client_factory"] = _mcp_http_client_factory
async with sse_client(**_sse_kwargs) as (read_stream, write_stream):
async with ClientSession(
read_stream, write_stream, **sampling_kwargs
@@ -1456,6 +1561,8 @@ class MCPServerTask:
client_kwargs["headers"] = headers
if _oauth_auth is not None:
client_kwargs["auth"] = _oauth_auth
if client_cert is not None:
client_kwargs["cert"] = client_cert
# Caller owns the client lifecycle — the SDK skips cleanup when
# http_client is provided, so we wrap in async-with.