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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user