diff --git a/README.md b/README.md index 4ea9fa4e5d..445c91f8b8 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/backend/packages/harness/deerflow/mcp/AGENTS.md b/backend/packages/harness/deerflow/mcp/AGENTS.md index cb231194dd..f7b0713a87 100644 --- a/backend/packages/harness/deerflow/mcp/AGENTS.md +++ b/backend/packages/harness/deerflow/mcp/AGENTS.md @@ -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`. diff --git a/backend/packages/harness/deerflow/mcp/personal_network.py b/backend/packages/harness/deerflow/mcp/personal_network.py index 5e11f46ffb..43b10959e7 100644 --- a/backend/packages/harness/deerflow/mcp/personal_network.py +++ b/backend/packages/harness/deerflow/mcp/personal_network.py @@ -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. diff --git a/backend/tests/blocking_io/test_personal_mcp_ssl.py b/backend/tests/blocking_io/test_personal_mcp_ssl.py new file mode 100644 index 0000000000..2b8c2ee05c --- /dev/null +++ b/backend/tests/blocking_io/test_personal_mcp_ssl.py @@ -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