fix(mcp): load personal transport TLS trust off the event loop (#7909)

This commit is contained in:
asts 2026-11-05 14:27:52 +08:00 committed by GitHub
parent bebbd2cb35
commit 59beff5799
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 86 additions and 1 deletions

View File

@ -1206,6 +1206,7 @@ downloading a partial transcript. Public demos export their bundled messages.
Capability Center groups plugins by office collaboration, documents and knowledge, search and research, business and data, and development and operations. The directory includes setup references alongside existing MCP configurations and Lark. Recommended integrations and built-in support do not imply an installed or verified connection; the Installed filter shows configured MCP entries and installed Lark only.
Personal MCP connections configured in the web interface are persisted per user.
Personal MCP HTTP/SSE connections admitted under the public-network policy load TLS trust off the Gateway event loop, while retaining public-address checks, certificate verification and per-origin connection isolation.
Unexpected failures when changing deployment or personal MCP configuration return fixed error messages; exception details stay in server logs.
Deployment tools remain shared. Administrators can add, edit, enable, disable and delete shared MCP servers under **Platform provided**; ordinary users see their status without controls. Personal plugin switches affect only the signed-in user's connections. See [connection ownership](docs/capability-center.md#personal-and-deployment-mcp-configuration).

View File

@ -85,3 +85,4 @@ The optional background-task `status` filter flows through `McpTaskSubmitter.lis
- Personal MCP configs live under the authenticated user root (`user_config.py`), use literal values, and never enter the deployment tool cache. `user_tools.py` guards owner and current revision before every call. Runtime server names bind owner/name/revision; durable task `driver_data.connection_scope` records personal or deployment ownership at submission, and background calls resolve a personal key only in the persisted task owner's file. Older rows without that field are deployment-owned. When a deployment name matches a personal runtime name, Agent assembly omits the personal tool rather than exposing two tools under one name; an already submitted personal task still uses its recorded personal scope. Keep catalog IDs stable across edits; never fall back by display name.
- Personal definitions without `personal_public_network: true` require current administrator authority at discovery and every tool/durable call. `personal_access.py` uses a host-installed async lookup, never a captured role or cached decision; absent/failed lookup denies privileged connections. The Gateway binds the lookup to fresh account records on its database-owning loop for its lifespan. Deployment connections are unaffected. This is admission control, not termination of operations already admitted before a role change.
- Public-network personal HTTP/SSE clients load TLS trust lazily in a worker thread, once per transport under an async lock. Keep `trust_env=False`, DNS screening, SNI and per-origin pool isolation intact; requests and pool cleanup remain asynchronous. Regression: `tests/blocking_io/test_personal_mcp_ssl.py`.

View File

@ -2,6 +2,7 @@
import asyncio
import ipaddress
import ssl
import httpx
@ -24,11 +25,18 @@ def _public_addresses(url: httpx.URL) -> list[str]:
class _PersonalTransport(httpx.AsyncBaseTransport):
def __init__(self):
self._ssl_context = httpx.create_ssl_context(trust_env=False)
self._ssl_context: ssl.SSLContext | None = None
self._ssl_context_lock = asyncio.Lock()
self._transports: dict[tuple[bytes, bytes, int | None], httpx.AsyncHTTPTransport] = {}
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
addresses = await asyncio.to_thread(_public_addresses, request.url)
# The synchronous client factory can run on the event loop. Load TLS
# trust lazily off-loop, sharing one context across concurrent requests.
if self._ssl_context is None:
async with self._ssl_context_lock:
if self._ssl_context is None:
self._ssl_context = await asyncio.to_thread(httpx.create_ssl_context, trust_env=False)
origin = (request.url.raw_scheme, request.url.raw_host, request.url.port)
# Pin TCP to the vetted IP, but isolate pools by original origin so two
# names sharing an IP cannot reuse a connection authenticated for one.

View File

@ -0,0 +1,75 @@
"""Personal MCP clients must load TLS trust off the Gateway event loop."""
import asyncio
import ssl
import threading
import httpx
import pytest
from deerflow.mcp.personal_network import personal_httpx_client_factory
@pytest.mark.asyncio
@pytest.mark.parametrize("concurrent", [False, True])
async def test_personal_mcp_tls_setup_runs_off_loop(monkeypatch, concurrent):
loop_thread = threading.get_ident()
original_context = ssl.create_default_context
contexts = []
requests = []
def create_context(*args, **kwargs):
assert threading.get_ident() != loop_thread, "TLS trust loading blocks the event loop"
context = original_context(*args, **kwargs)
contexts.append(context)
return context
async def send(self, request):
assert threading.get_ident() == loop_thread
requests.append((self, request))
return httpx.Response(200, request=request, content=b"ok")
monkeypatch.setattr(ssl, "create_default_context", create_context)
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", send)
monkeypatch.setenv("SSL_CERT_FILE", "/missing-personal-mcp-test.pem")
async with personal_httpx_client_factory(headers={"Authorization": "Bearer synthetic"}) as client:
urls = ["https://8.8.8.8/first", "https://1.1.1.1/second", "https://8.8.8.8/third"]
responses = await asyncio.gather(*(client.get(url) for url in urls)) if concurrent else [await client.get(url) for url in urls]
assert all(response.text == "ok" for response in responses)
assert client.is_closed
assert len(contexts) == 1
assert contexts[0].check_hostname
assert contexts[0].verify_mode == ssl.CERT_REQUIRED
by_path = {request.url.path: transport for transport, request in requests}
assert by_path["/first"] is by_path["/third"]
assert by_path["/first"] is not by_path["/second"]
assert all(request.headers["Authorization"] == "Bearer synthetic" for _, request in requests)
@pytest.mark.asyncio
async def test_personal_mcp_tls_setup_failure_can_be_retried(monkeypatch):
original_context = ssl.create_default_context
attempts = []
requests = []
def create_context(*args, **kwargs):
attempts.append(True)
if len(attempts) == 1:
raise OSError("TLS trust unavailable")
return original_context(*args, **kwargs)
async def send(self, request):
requests.append(request)
return httpx.Response(200, request=request, content=b"ok")
monkeypatch.setattr(ssl, "create_default_context", create_context)
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", send)
async with personal_httpx_client_factory() as client:
with pytest.raises(OSError, match="TLS trust unavailable"):
await client.get("https://8.8.8.8/mcp")
assert not requests
for _ in range(2):
assert (await client.get("https://8.8.8.8/mcp")).text == "ok"
assert len(attempts) == 2
assert len(requests) == 2
assert client.is_closed