Skip to content

Commit f3b4db2

Browse files
committed
feat: implement SandboxMCPBackend for isolated worker support
1 parent dff96c9 commit f3b4db2

14 files changed

Lines changed: 150 additions & 191 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""MCP backend using isolated workers for user-configured remote servers."""
2+
3+
from langchain_mcp_adapters.client import MultiServerMCPClient
4+
from mcp.types import CallToolResult
5+
6+
from common.mcp.config import InternalMCPConfig, validate_mcp_servers
7+
from common.mcp.sandbox import sandbox_connection
8+
9+
10+
class SandboxMCPBackend(MultiServerMCPClient):
11+
"""Provide MCP tools and sessions backed by sandboxed stdio connections.
12+
13+
Inherited get_tools() creates tools whose later invocations also open sandbox
14+
workers. This backend supplies the agent's tools; SandboxShellBackend remains
15+
the agent backend for skill files and shell commands.
16+
"""
17+
18+
def __init__(self, servers: dict):
19+
super().__init__(connections=self._build_connections(servers))
20+
21+
@staticmethod
22+
def _build_connections(servers: dict) -> dict:
23+
if not isinstance(servers, dict):
24+
raise ValueError("MCP servers must be an object")
25+
connections = {}
26+
for name, config in servers.items():
27+
if not isinstance(config, dict):
28+
raise ValueError("MCP server configuration must be an object")
29+
internal = isinstance(config, InternalMCPConfig)
30+
if internal and config.get("transport") == "stdio":
31+
connections[name] = dict(config)
32+
continue
33+
validate_mcp_servers({name: config})
34+
connections[name] = dict(config) if internal else sandbox_connection(config)
35+
return connections
36+
37+
async def call_tool(self, server_name: str, tool_name: str, arguments: dict | None = None) -> CallToolResult:
38+
"""Call one tool and close its session/worker, preserving the MCP result."""
39+
async with self.session(server_name) as session:
40+
return await session.call_tool(tool_name, arguments)

apps/application/flow/step_node/mcp_node/impl/base_mcp_node.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import List
55

66
from django.db.models import QuerySet
7-
from common.utils.mcp_client import create_mcp_client
7+
from application.flow.backend.sandbox_mcp import SandboxMCPBackend
88

99
from application.flow.i_step_node import NodeResult
1010
from application.flow.step_node.mcp_node.i_mcp_node import IMcpNode
@@ -37,12 +37,8 @@ def execute(self, mcp_servers, mcp_server, mcp_tool, mcp_tool_id, mcp_source, to
3737
params = json.loads(json.dumps(tool_params))
3838
params = self.handle_variables(params)
3939

40-
async def call_tool(t, a):
41-
client = create_mcp_client(servers)
42-
async with client.session(mcp_server) as s:
43-
return await s.call_tool(t, a)
44-
45-
res = asyncio.run(call_tool(mcp_tool, params))
40+
backend = SandboxMCPBackend(servers)
41+
res = asyncio.run(backend.call_tool(mcp_server, mcp_tool, params))
4642
return NodeResult(
4743
{"result": [content.text for content in res.content], "tool_params": params, "mcp_tool": mcp_tool}, {}
4844
)

apps/application/flow/tools.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@
4545
from langchain_core.messages import AIMessageChunk, BaseMessage, BaseMessageChunk, ToolMessage
4646
from langchain_core.tools import StructuredTool
4747
from langchain_core.utils._merge import merge_lists as _original_merge_lists
48-
from common.utils.mcp_client import create_mcp_client
4948
from langgraph.checkpoint.memory import MemorySaver
5049
from maxkb.const import CONFIG
5150
from pydantic import Field, create_model
5251
from tools.models import Tool, ToolRecord, ToolType, ToolWorkflowVersion
5352

53+
from application.flow.backend.sandbox_mcp import SandboxMCPBackend
5454
from application.flow.backend.sandbox_shell import SandboxShellBackend
5555
from application.flow.common import Workflow, WorkflowMode
5656
from application.flow.i_step_node import ToolWorkflowPostHandler, WorkFlowPostHandler
@@ -394,7 +394,7 @@ def _extract_tool_id(raw_id):
394394
return tool_id or raw_id
395395

396396

397-
async def _initialize_skills(mcp_servers, temp_dir):
397+
async def _initialize_skills(mcp_servers, temp_dir) -> SandboxMCPBackend:
398398
skills_dir = os.path.join(temp_dir, "skills")
399399
mcp_config = dict(mcp_servers) # Preserve server-generated InternalMCPConfig objects.
400400
if "skills" in mcp_config:
@@ -435,9 +435,7 @@ async def _initialize_skills(mcp_servers, temp_dir):
435435

436436
os.system("chmod -R g+rx " + temp_dir) # 确保技能目录可访问
437437

438-
client = create_mcp_client(mcp_config)
439-
440-
return client
438+
return SandboxMCPBackend(mcp_config)
441439

442440

443441
async def _yield_mcp_response(
@@ -455,8 +453,8 @@ async def _yield_mcp_response(
455453
):
456454
try:
457455
checkpointer = MemorySaver()
458-
client = await _initialize_skills(mcp_servers, temp_dir)
459-
tools = await client.get_tools()
456+
mcp_backend = await _initialize_skills(mcp_servers, temp_dir)
457+
tools = await mcp_backend.get_tools()
460458
for tool in tools:
461459
tool.handle_tool_error = True
462460
if extra_tools:

apps/application/serializers/application.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from knowledge.models import File, FileSourceType, Knowledge, KnowledgeScope
5252
from knowledge.serializers.common import BatchMoveSerializer, BatchSerializer
5353
from knowledge.serializers.knowledge import KnowledgeModelSerializer, KnowledgeSerializer
54-
from common.utils.mcp_client import create_mcp_client
54+
from application.flow.backend.sandbox_mcp import SandboxMCPBackend
5555
from maxkb.conf import PROJECT_DIR
5656
from maxkb.const import CONFIG
5757
from models_provider.models import Model
@@ -1095,8 +1095,8 @@ class PlayDemoTextRequest(serializers.Serializer):
10951095

10961096

10971097
async def get_mcp_tools(servers):
1098-
client = create_mcp_client(servers)
1099-
return await client.get_tools()
1098+
backend = SandboxMCPBackend(servers)
1099+
return await backend.get_tools()
11001100

11011101

11021102
class McpServersSerializer(serializers.Serializer):

apps/common/mcp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Shared MCP configuration and sandbox workers."""

apps/common/mcp/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Compatibility exports and client factory for the dedicated MCP backend."""
2+
3+
from application.flow.backend.sandbox_mcp import SandboxMCPBackend
4+
from common.mcp.config import InternalMCPConfig, validate_mcp_servers
5+
6+
7+
__all__ = ["InternalMCPConfig", "validate_mcp_servers", "create_mcp_client"]
8+
9+
10+
def create_mcp_client(servers):
11+
"""Keep existing callers compatible with the dedicated MCP backend."""
12+
return SandboxMCPBackend(servers)

apps/common/mcp/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Shared MCP configuration types and validation."""
2+
3+
4+
class InternalMCPConfig(dict):
5+
"""In-memory provenance for configurations generated by ToolExecutor.
6+
7+
Never deserialize user input into this type. JSON round trips deliberately
8+
lose this privilege; keep runtime configurations in memory instead.
9+
"""
10+
11+
12+
def validate_mcp_servers(servers):
13+
if not isinstance(servers, dict):
14+
raise ValueError("MCP servers must be an object")
15+
for config in servers.values():
16+
if not isinstance(config, dict) or config.get("transport") not in ("sse", "streamable_http"):
17+
raise ValueError("Only support transport=sse or transport=streamable_http")
18+
if not isinstance(config.get("url"), str) or not config["url"].strip():
19+
raise ValueError("MCP server URL must be a non-empty string")
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@
44
import pwd
55
import sys
66
from datetime import timedelta
7+
from importlib.machinery import PathFinder
78
from pathlib import Path
89

910
from mcp.types import Implementation
1011

12+
from maxkb.const import CONFIG
13+
1114

1215
BOOTSTRAP_KEY = "maxkbSandbox"
1316
REMOTE_FIELDS = {"transport", "url", "headers", "timeout", "sse_read_timeout", "terminate_on_close"}
1417

1518

1619
def sandbox_settings():
17-
from maxkb.const import CONFIG
18-
1920
if not sys.platform.startswith("linux") or not bool(int(CONFIG.get("SANDBOX", 1))):
2021
raise ValueError("External MCP requires an enabled Linux sandbox")
2122
account = pwd.getpwnam("sandbox")
@@ -37,6 +38,11 @@ def sandbox_settings():
3738

3839
def sandbox_connection(config):
3940
settings = sandbox_settings()
41+
# Release builds replace source files with adjacent, sourceless .pyc files.
42+
# Search only our installed directory, never a user-controlled module path.
43+
worker = PathFinder.find_spec("sandbox_worker", [str(Path(__file__).parent)])
44+
if worker is None or worker.origin is None or Path(worker.origin).suffix not in (".py", ".pyc"):
45+
raise RuntimeError("MCP sandbox worker is missing or has an unsupported format")
4046
# Only transport data goes to the remote client. In particular, ignore user
4147
# command/env/factory/session_kwargs fields and never deserialize Python code.
4248
remote = {key: value for key, value in config.items() if key in REMOTE_FIELDS}
@@ -46,7 +52,7 @@ def sandbox_connection(config):
4652
return {
4753
"transport": "stdio",
4854
"command": sys.executable,
49-
"args": ["-I", str(Path(__file__).with_name("mcp_sandbox_worker.py"))],
55+
"args": ["-I", worker.origin],
5056
"cwd": settings["cwd"],
5157
"env": {
5258
"LD_PRELOAD": settings["library"],
Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,51 @@
11
"""Forward MCP messages without converting tools, results or notifications."""
22

33
from contextlib import asynccontextmanager
4-
from functools import partial
54
import logging
65
import os
6+
import socket
7+
import ssl
78
import sys
89

910
import anyio
1011
import httpx
1112
from mcp.client.sse import sse_client
1213
from mcp.client.streamable_http import streamable_http_client
1314
from mcp.server.stdio import stdio_server
15+
from mcp.shared._httpx_utils import create_mcp_http_client
1416
from mcp.types import JSONRPCRequest
1517

1618

19+
def sandbox_failure_message(error):
20+
# SDK exception groups and chained HTTP errors can embed credentials. Only
21+
# report numeric HTTP statuses or fixed descriptions, never exception text.
22+
errors, pending, seen = [], [error], set()
23+
while pending:
24+
current = pending.pop()
25+
if id(current) in seen:
26+
continue
27+
seen.add(id(current))
28+
errors.append(current)
29+
if isinstance(current, BaseExceptionGroup):
30+
pending.extend(current.exceptions)
31+
if current.__cause__ is not None:
32+
pending.append(current.__cause__)
33+
for current in errors:
34+
if isinstance(current, httpx.HTTPStatusError):
35+
return f"MCP endpoint returned HTTP {current.response.status_code}; check endpoint and credentials"
36+
for exception_type, message in (
37+
(ssl.SSLCertVerificationError, "MCP TLS certificate verification failed"),
38+
(socket.gaierror, "MCP hostname resolution failed; check container DNS"),
39+
(PermissionError, "MCP access denied; check sandbox file and network policy"),
40+
((httpx.TimeoutException, TimeoutError), "MCP connection timed out"),
41+
(httpx.TooManyRedirects, "MCP endpoint returned too many redirects"),
42+
(httpx.ConnectError, "MCP connection failed; check container connectivity and sandbox network policy"),
43+
):
44+
if any(isinstance(current, exception_type) for current in errors):
45+
return message
46+
return "MCP session failed; check endpoint, sandbox setup and network policy"
47+
48+
1749
class PipeInput:
1850
"""Cancellable pipe reads; a blocked readline thread would delay shutdown."""
1951

@@ -75,11 +107,10 @@ def extract_bootstrap(message):
75107

76108

77109
@asynccontextmanager
78-
async def remote_transport(bootstrap, http_factory):
110+
async def remote_transport(bootstrap):
79111
config = bootstrap["connection"]
80112
if config.get("transport") not in ("sse", "streamable_http"):
81113
raise ValueError("Unsupported external MCP transport")
82-
factory = partial(http_factory, url=config["url"])
83114
timeout = config.get("timeout", 5 if config["transport"] == "sse" else 30)
84115
read_timeout = config.get("sse_read_timeout", 300)
85116
if config["transport"] == "sse":
@@ -88,11 +119,13 @@ async def remote_transport(bootstrap, http_factory):
88119
headers=config.get("headers"),
89120
timeout=timeout,
90121
sse_read_timeout=read_timeout,
91-
httpx_client_factory=factory,
92122
) as streams:
93123
yield streams
94124
else:
95-
async with factory(headers=config.get("headers"), timeout=httpx.Timeout(timeout, read=read_timeout)) as client:
125+
async with create_mcp_http_client(
126+
headers=config.get("headers"),
127+
timeout=httpx.Timeout(timeout, read=read_timeout),
128+
) as client:
96129
async with streamable_http_client(
97130
config["url"],
98131
http_client=client,
@@ -111,19 +144,19 @@ async def forward(source, destination, cancel_scope):
111144
cancel_scope.cancel()
112145

113146

114-
async def proxy(http_factory):
147+
async def proxy():
115148
async with stdio_server(stdin=PipeInput(), stdout=PipeOutput()) as (local_read, local_write):
116149
with anyio.fail_after(30):
117150
first = await local_read.receive()
118151
bootstrap = extract_bootstrap(first)
119-
async with remote_transport(bootstrap, http_factory) as (remote_read, remote_write):
152+
async with remote_transport(bootstrap) as (remote_read, remote_write):
120153
async with anyio.create_task_group() as tasks:
121154
tasks.start_soon(forward, remote_read, local_write, tasks.cancel_scope)
122155
await remote_write.send(first)
123156
tasks.start_soon(forward, local_read, remote_write, tasks.cancel_scope)
124157

125158

126-
def run(http_factory):
159+
def run():
127160
# Remote SDK exceptions may contain authorization headers or URL parameters.
128161
logging.disable(logging.CRITICAL)
129-
anyio.run(proxy, http_factory)
162+
anyio.run(proxy)
Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import ctypes
44
from contextlib import contextmanager
55
import errno
6+
import importlib.machinery
67
import importlib.util
78
import ipaddress
89
import json
@@ -17,7 +18,7 @@
1718

1819

1920
class MCPWorkerFailure(Exception):
20-
"""A failure whose message was sanitized by the fixed network module."""
21+
"""A failure whose message was sanitized by the protocol proxy."""
2122

2223

2324
class DlInfo(ctypes.Structure):
@@ -168,24 +169,23 @@ def enter_sandbox(settings):
168169

169170
def main():
170171
settings = json.loads(os.environ.pop("MAXKB_MCP_WORKER_SETTINGS"))
171-
# Load only fixed, installed modules before dropping access to the app tree.
172-
# Neither module imports Django nor reads the application configuration.
173-
modules = {}
174-
for name in ("mcp_network", "mcp_sandbox_proxy"):
175-
path = Path(__file__).with_name(name + ".py")
176-
spec = importlib.util.spec_from_file_location(name, path)
177-
module = importlib.util.module_from_spec(spec)
178-
spec.loader.exec_module(module)
179-
modules[name] = module
172+
# Load the fixed proxy before dropping access to the app tree. It does not
173+
# import Django or read application configuration. The loader supports both
174+
# source and sourceless release layouts, only from our installed directory.
175+
spec = importlib.machinery.PathFinder.find_spec("sandbox_proxy", [str(Path(__file__).parent)])
176+
if spec is None or spec.loader is None:
177+
raise RuntimeError("MCP sandbox dependency is missing")
178+
proxy = importlib.util.module_from_spec(spec)
179+
spec.loader.exec_module(proxy)
180180
# Remove the application directory while retaining approved package paths.
181181
app_path = str(Path(__file__).resolve().parents[2])
182182
sys.path = [p for p in sys.path if p != app_path]
183183
sys.path.extend(p for p in settings["python_paths"] if p and p not in sys.path)
184184
enter_sandbox(settings)
185185
try:
186-
modules["mcp_sandbox_proxy"].run(modules["mcp_network"].http_client_factory)
186+
proxy.run()
187187
except Exception as exc:
188-
raise MCPWorkerFailure(modules["mcp_network"].sandbox_failure_message(exc)) from None
188+
raise MCPWorkerFailure(proxy.sandbox_failure_message(exc)) from None
189189

190190

191191
if __name__ == "__main__":

0 commit comments

Comments
 (0)