Parcourir la source

feat(mcp): MCP server 注册中心 P1(docs/MCP_SKILL_DESIGN.md)

让老师接入外部 MCP 工具(如 arXiv 检索)。按 codex 评审落地:

后端:
- engine/mcp_registry.py: json 权威源(~/.agentpaas/mcp_servers.json)
  带 fcntl 文件锁读改写(评审#12);CRUD+校验+脱敏(auth 只出现 env_key
  名不出现值,评审#13);stdio command_argv 数组+可执行 allowlist
  (npx/node/python… 评审#16);工具风险预分级(默认 medium,破坏性
  动词升 high,override 优先,评审#15);probe(http/sse JSON-RPC
  tools/list,stdio 标 registered);inject_into_config 运行时桥
- api/v1/mcp.py: GET/POST/PUT/DELETE/probe,add/改 loopback-only
- tool_gateway.py: MCP 工具默认 MEDIUM(原一律 LOW→删除/发送类不触发
  确认是漏洞),破坏性动词升 HIGH
- agents.py _execute_agent: 注入启用的 server 到 config。每条消息独立
  编译,禁用即不注入→下次 run 立即失效(评审#14 在 agentpaas 侧天然
  成立,无需穿透 lambdagent 编译缓存)

前端:
- pages/Connections.tsx「工具与连接」:server 卡片+状态点、添加表单
  (transport 三选一/凭证填变量名)、测试连接(拉工具清单+风险徽章)、启停
- 侧栏新增「工具与连接」入口 + 路由 + api/mcp.ts

测试 +12(CRUD/校验/allowlist/风险分级/注入只启用/凭证/gateway 修复/
API loopback+probe),全量 317+566 passed;tsc+build 0 错误。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kenny67nju il y a 2 mois
Parent
commit
87be1baf2d

+ 2 - 1
agentpaas/src/agentpaas/api/app.py

@@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
 from agentpaas.config import settings
 from agentpaas.config import settings
-from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router, agentpacks, assistant
+from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router, agentpacks, assistant, mcp as mcp_router
 from agentpaas.observability.logging import logger
 from agentpaas.observability.logging import logger
 
 
 # 改进⑦ (AUDIT_2026-06-11): on_event("startup") 在 FastAPI 中已弃用,
 # 改进⑦ (AUDIT_2026-06-11): on_event("startup") 在 FastAPI 中已弃用,
@@ -220,6 +220,7 @@ app.include_router(templates_api.router, prefix="/api/v1")
 app.include_router(knowledge_router.router, prefix="/api/v1")
 app.include_router(knowledge_router.router, prefix="/api/v1")
 app.include_router(agentpacks.router, prefix="/api/v1")
 app.include_router(agentpacks.router, prefix="/api/v1")
 app.include_router(assistant.router, prefix="/api/v1")
 app.include_router(assistant.router, prefix="/api/v1")
+app.include_router(mcp_router.router, prefix="/api/v1")
 
 
 
 
 async def _on_startup():
 async def _on_startup():

+ 8 - 0
agentpaas/src/agentpaas/api/v1/agents.py

@@ -1868,6 +1868,14 @@ def _execute_agent(config: dict, input_text: str, on_step=None,
     import yaml, os
     import yaml, os
     from agentpaas.engine.sandbox import create_run_workspace, save_run_artifacts
     from agentpaas.engine.sandbox import create_run_workspace, save_run_artifacts
 
 
+    # MCP 注册中心:把启用的 server 连接注入 config,供 _compile_mcp_caller
+    # 解析。每条消息独立编译,禁用的 server 不注入 → 立即生效(评审#14)。
+    try:
+        from agentpaas.engine import mcp_registry
+        config = mcp_registry.inject_into_config(config)
+    except Exception as _mcp_e:
+        logger.warning("MCP inject skipped: %s", _mcp_e)
+
     # ── source_dir injection ──────────────────────────────────────────────────
     # ── source_dir injection ──────────────────────────────────────────────────
     # Prepend a brief preamble to the system prompt so the agent knows where
     # Prepend a brief preamble to the system prompt so the agent knows where
     # its input data lives. This is purely informational — the agent still
     # its input data lives. This is purely informational — the agent still

+ 113 - 0
agentpaas/src/agentpaas/api/v1/mcp.py

@@ -0,0 +1,113 @@
+"""
+api.v1.mcp — MCP server 注册中心 API(docs/MCP_SKILL_DESIGN.md P1)。
+
+GET    /api/v1/mcp                列表(脱敏:auth 只出现 env_key 名)
+POST   /api/v1/mcp                添加(loopback-only + stdio argv allowlist)
+PUT    /api/v1/mcp/{id}           修改 / 启停
+DELETE /api/v1/mcp/{id}           移除
+POST   /api/v1/mcp/{id}/probe    连接测试 + 刷新工具清单
+
+添加/修改 server 是本机配置操作(指向本机命令或外部端点),与 /setup、
+agentpack install 同样 loopback-only(远程调用者不该改本机连接配置)。
+"""
+from __future__ import annotations
+
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel, Field
+
+from agentpaas.api.deps import get_tenant
+from agentpaas.api.middleware.auth import TenantContext
+from agentpaas.engine import mcp_registry as reg
+
+router = APIRouter(prefix="/mcp", tags=["mcp"])
+
+
+def _loopback_only(request: Request) -> None:
+    host = (request.client.host if request.client else "") or ""
+    if host not in ("127.0.0.1", "::1", "localhost", "testclient"):
+        raise HTTPException(status_code=404, detail="Not Found")
+
+
+class AuthSpec(BaseModel):
+    kind: str = "none"            # none | env_bearer | env_header
+    env_key: str = ""
+    header_name: str = "Authorization"
+
+
+class ServerSpec(BaseModel):
+    id: str = Field(..., min_length=1)
+    name: str = ""
+    transport: str = "stdio"      # stdio | sse | http
+    command_argv: List[str] = Field(default_factory=list)
+    url: str = ""
+    auth: AuthSpec = Field(default_factory=AuthSpec)
+    enabled: bool = True
+    tool_risk_overrides: dict = Field(default_factory=dict)
+    # 信任时显式给 stdio 可执行加白(评审#16)
+    allow_argv: List[str] = Field(default_factory=list)
+
+
+class UpdateSpec(BaseModel):
+    name: Optional[str] = None
+    transport: Optional[str] = None
+    command_argv: Optional[List[str]] = None
+    url: Optional[str] = None
+    auth: Optional[AuthSpec] = None
+    enabled: Optional[bool] = None
+    tool_risk_overrides: Optional[dict] = None
+    allow_argv: List[str] = Field(default_factory=list)
+
+
+@router.get("")
+async def list_mcp(_tenant: TenantContext = Depends(get_tenant)):
+    return {"servers": reg.list_servers()}
+
+
+@router.post("", status_code=201)
+async def add_mcp(spec: ServerSpec, request: Request,
+                  _tenant: TenantContext = Depends(get_tenant)):
+    _loopback_only(request)
+    allowlist = reg.DEFAULT_ARGV_ALLOWLIST | set(spec.allow_argv or [])
+    payload = spec.model_dump(exclude={"allow_argv"})
+    try:
+        view = reg.upsert_server(payload, argv_allowlist=allowlist)
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    return {"ok": True, "server": view}
+
+
+@router.put("/{server_id}")
+async def update_mcp(server_id: str, body: UpdateSpec, request: Request,
+                     _tenant: TenantContext = Depends(get_tenant)):
+    _loopback_only(request)
+    cur = reg.get_server(server_id)
+    if not cur:
+        raise HTTPException(status_code=404, detail="MCP server not found")
+    merged = {**cur, "id": server_id}
+    for k, v in body.model_dump(exclude={"allow_argv"}).items():
+        if v is not None:
+            merged[k] = v
+    allowlist = reg.DEFAULT_ARGV_ALLOWLIST | set(body.allow_argv or [])
+    try:
+        view = reg.upsert_server(merged, argv_allowlist=allowlist)
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    return {"ok": True, "server": view}
+
+
+@router.delete("/{server_id}")
+async def delete_mcp(server_id: str, request: Request,
+                     _tenant: TenantContext = Depends(get_tenant)):
+    _loopback_only(request)
+    if not reg.remove_server(server_id):
+        raise HTTPException(status_code=404, detail="MCP server not found")
+    return {"ok": True}
+
+
+@router.post("/{server_id}/probe")
+async def probe_mcp(server_id: str, _tenant: TenantContext = Depends(get_tenant)):
+    if not reg.get_server(server_id):
+        raise HTTPException(status_code=404, detail="MCP server not found")
+    return reg.probe_server(server_id)

+ 283 - 0
agentpaas/src/agentpaas/engine/mcp_registry.py

@@ -0,0 +1,283 @@
+"""
+agentpaas.engine.mcp_registry — MCP server 注册中心(docs/MCP_SKILL_DESIGN.md P1)。
+
+单一权威源:`~/.agentpaas/mcp_servers.json`(人可编辑、可迁移)。所有写操作
+走带文件锁的读-改-写(评审#12),不并发覆盖。DB 只缓存 probe 结果,不存配置。
+
+connection 注入运行时(评审#14 在 agentpaas 侧天然成立):每条聊天消息是
+独立 run、按 config_hash 重新编译 term,禁用 server 不注入 config → 下次 run
+立即生效,无需穿透 lambdagent 编译缓存。
+
+凭证(评审#13):auth 只存 env_key 变量名,值走 ~/.agentpaas/.env;list/probe
+返回与日志只出现变量名,不出现值。
+
+stdio 命令加固(评审#16):command_argv 是数组(无 shell),可执行名走 allowlist。
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import time
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+MCP_FILE = os.path.join(os.path.expanduser("~"), ".agentpaas", "mcp_servers.json")
+
+# stdio 可执行 allowlist(评审#16):已知 MCP 运行器;其他需显式加白
+DEFAULT_ARGV_ALLOWLIST = {"npx", "node", "python", "python3", "uvx", "uv",
+                          "docker", "deno", "bunx"}
+
+# 破坏性动词 → 工具风险升 HIGH(与 tool_gateway 一致)
+_HIGH_VERBS = ("delete", "remove", "drop", "send", "email", "exec", "execute",
+               "write", "payment", "pay", "transfer", "purchase", "deploy",
+               "publish", "post", "create_issue")
+_ID_OK = lambda s: bool(s) and all(c.isalnum() or c in "-_." for c in s)
+
+
+# ── 文件锁读写(评审#12)──────────────────────────────────────────────────────
+
+def _load() -> Dict[str, dict]:
+    try:
+        with open(MCP_FILE, encoding="utf-8") as f:
+            return json.load(f) or {}
+    except FileNotFoundError:
+        return {}
+    except Exception as e:
+        logger.warning("mcp_servers.json unreadable: %s", e)
+        return {}
+
+
+def _atomic_update(mutate) -> Dict[str, dict]:
+    """带文件锁的读-改-写。mutate(data) 原地改 data;返回最终 data。"""
+    import fcntl
+    os.makedirs(os.path.dirname(MCP_FILE), exist_ok=True)
+    # 用单独的锁文件,避免 truncate 与读冲突
+    lock_path = MCP_FILE + ".lock"
+    with open(lock_path, "w") as lock:
+        fcntl.flock(lock, fcntl.LOCK_EX)
+        try:
+            data = _load()
+            mutate(data)
+            tmp = MCP_FILE + ".tmp"
+            with open(tmp, "w", encoding="utf-8") as f:
+                json.dump(data, f, ensure_ascii=False, indent=2)
+            os.replace(tmp, MCP_FILE)
+            return data
+        finally:
+            fcntl.flock(lock, fcntl.LOCK_UN)
+
+
+# ── 工具风险预分级(评审#15)─────────────────────────────────────────────────
+
+def classify_mcp_tool(tool_name: str, overrides: Dict[str, str]) -> str:
+    """返回 low|medium|high。override 最高优先;破坏性动词升 high;否则 medium。"""
+    if tool_name in (overrides or {}):
+        return overrides[tool_name]
+    low = tool_name.lower()
+    if any(v in low for v in _HIGH_VERBS):
+        return "high"
+    return "medium"
+
+
+# ── CRUD ─────────────────────────────────────────────────────────────────────
+
+def list_servers(include_secrets: bool = False) -> List[dict]:
+    """列出所有 server(默认脱敏:auth 只出现 env_key 名,不出现值)。"""
+    out = []
+    for sid, s in _load().items():
+        out.append(_view(sid, s, include_secrets))
+    return out
+
+
+def get_server(server_id: str, include_secrets: bool = False) -> Optional[dict]:
+    s = _load().get(server_id)
+    return _view(server_id, s, include_secrets) if s else None
+
+
+def _view(sid: str, s: dict, include_secrets: bool) -> dict:
+    auth = dict(s.get("auth") or {})
+    # 评审#13:永不返回凭证值(env_key 是变量名,安全;这里确保没有 value 字段)
+    auth.pop("value", None)
+    return {
+        "id": sid,
+        "name": s.get("name", sid),
+        "transport": s.get("transport", "stdio"),
+        "command_argv": s.get("command_argv", []),
+        "url": s.get("url", ""),
+        "auth": auth,
+        "enabled": bool(s.get("enabled", True)),
+        "tool_risk_overrides": s.get("tool_risk_overrides", {}),
+    }
+
+
+def validate_server(spec: dict, argv_allowlist: Optional[set] = None) -> List[str]:
+    """校验 server spec,返回错误列表(空=通过)。"""
+    errs: List[str] = []
+    sid = spec.get("id", "")
+    if not _ID_OK(sid):
+        errs.append(f"id {sid!r} 非法(只允许字母数字 -_.)")
+    transport = spec.get("transport", "")
+    if transport not in ("stdio", "sse", "http"):
+        errs.append(f"transport 必须是 stdio|sse|http,得到 {transport!r}")
+    if transport == "stdio":
+        argv = spec.get("command_argv") or []
+        if not isinstance(argv, list) or not argv:
+            errs.append("stdio 需要非空 command_argv 数组")
+        else:
+            exe = os.path.basename(str(argv[0]))
+            allow = argv_allowlist if argv_allowlist is not None else DEFAULT_ARGV_ALLOWLIST
+            if exe not in allow:
+                errs.append(f"可执行 {exe!r} 不在 allowlist;如信任请显式加白")
+    elif transport in ("sse", "http"):
+        if not spec.get("url"):
+            errs.append(f"{transport} 需要 url")
+    return errs
+
+
+def upsert_server(spec: dict, argv_allowlist: Optional[set] = None) -> dict:
+    """新增/更新一个 server(已校验)。返回脱敏视图。"""
+    errs = validate_server(spec, argv_allowlist)
+    if errs:
+        raise ValueError("; ".join(errs))
+    sid = spec["id"]
+
+    def _mut(data):
+        existing = data.get(sid, {})
+        data[sid] = {
+            "name": spec.get("name", existing.get("name", sid)),
+            "transport": spec["transport"],
+            "command_argv": spec.get("command_argv", existing.get("command_argv", [])),
+            "url": spec.get("url", existing.get("url", "")),
+            "auth": spec.get("auth", existing.get("auth", {"kind": "none"})),
+            "enabled": bool(spec.get("enabled", existing.get("enabled", True))),
+            "tool_risk_overrides": spec.get("tool_risk_overrides",
+                                            existing.get("tool_risk_overrides", {})),
+        }
+    _atomic_update(_mut)
+    return get_server(sid)
+
+
+def set_enabled(server_id: str, enabled: bool) -> bool:
+    found = {"v": False}
+
+    def _mut(data):
+        if server_id in data:
+            data[server_id]["enabled"] = bool(enabled)
+            found["v"] = True
+    _atomic_update(_mut)
+    return found["v"]
+
+
+def remove_server(server_id: str) -> bool:
+    found = {"v": False}
+
+    def _mut(data):
+        if data.pop(server_id, None) is not None:
+            found["v"] = True
+    _atomic_update(_mut)
+    return found["v"]
+
+
+# ── probe:连接测试 + 工具发现 ───────────────────────────────────────────────
+
+def probe_server(server_id: str, timeout: float = 8.0) -> dict:
+    """连接 server、拉工具清单、预分级风险。返回 {status, tools, error}。
+
+    status: ok | unreachable | auth_failed | unsupported。
+    P1 实现 http/sse 的 JSON-RPC tools/list;stdio 标记 registered(启动验证)。
+    """
+    s = _load().get(server_id)
+    if not s:
+        return {"status": "unreachable", "tools": [], "error": "server 未注册"}
+    transport = s.get("transport", "stdio")
+    overrides = s.get("tool_risk_overrides", {})
+
+    if transport in ("http", "sse"):
+        return _probe_http(s, overrides, timeout)
+    # stdio:P1 不在 probe 里 spawn 子进程(重且平台相关),返回 registered
+    return {"status": "registered",
+            "tools": [], "error": "stdio server 将在 agent 运行时启动验证"}
+
+
+def _probe_http(s: dict, overrides: dict, timeout: float) -> dict:
+    import urllib.request
+    import urllib.error
+    url = s.get("url", "").rstrip("/")
+    headers = {"Content-Type": "application/json"}
+    # 凭证:从 env 取值注入 header(评审#13:值不落库不回显)
+    auth = s.get("auth") or {}
+    if auth.get("kind") in ("env_bearer", "env_header"):
+        val = os.environ.get(auth.get("env_key", ""), "")
+        if val:
+            hname = auth.get("header_name", "Authorization")
+            prefix = "Bearer " if auth["kind"] == "env_bearer" else ""
+            headers[hname] = f"{prefix}{val}"
+    body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list",
+                       "params": {}}).encode("utf-8")
+    try:
+        req = urllib.request.Request(url, data=body, headers=headers, method="POST")
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            data = json.loads(resp.read().decode("utf-8"))
+        raw_tools = (data.get("result") or {}).get("tools", [])
+        tools = []
+        for t in raw_tools:
+            tname = t.get("name", "")
+            tools.append({
+                "name": tname,
+                "description": (t.get("description") or "")[:160],
+                "risk": classify_mcp_tool(tname, overrides),
+            })
+        return {"status": "ok", "tools": tools, "error": ""}
+    except urllib.error.HTTPError as e:
+        st = "auth_failed" if e.code in (401, 403) else "unreachable"
+        return {"status": st, "tools": [], "error": f"HTTP {e.code}"}
+    except Exception as e:
+        return {"status": "unreachable", "tools": [], "error": str(e)[:120]}
+
+
+# ── 运行时桥:把启用的 server 注入 agent config ──────────────────────────────
+
+def inject_into_config(config: dict) -> dict:
+    """执行 agent 前调用:把启用的 MCP server 连接信息注入 config,
+    供 lambdagent `_compile_mcp_caller` 解析。只注入 enabled=true 的 server;
+    禁用的不注入 → 下次 run 重编译时该工具不可用(评审#14 天然穿透)。
+
+    现有 _compile_mcp_caller 从 config.app.mcp.custom.nodes 取连接;这里
+    按其 schema 填。凭证从 env 取值注入 headers(值不落 config 持久化,
+    config 仅在内存中流转给 from_config)。
+    """
+    servers = _load()
+    if not servers:
+        return config
+    nodes: Dict[str, dict] = {}
+    for sid, s in servers.items():
+        if not s.get("enabled", True):
+            continue
+        if s.get("transport") not in ("http", "sse"):
+            continue  # P1 运行时桥只接 http/sse;stdio 留后续
+        node = {"url": s.get("url", ""), "endpoint": "", "headers": {}, "timeout": 30}
+        auth = s.get("auth") or {}
+        if auth.get("kind") in ("env_bearer", "env_header"):
+            val = os.environ.get(auth.get("env_key", ""), "")
+            if val:
+                hname = auth.get("header_name", "Authorization")
+                prefix = "Bearer " if auth["kind"] == "env_bearer" else ""
+                node["headers"][hname] = f"{prefix}{val}"
+        nodes[sid] = node
+    if not nodes:
+        return config
+    # 深合并进 config.app.mcp.custom.nodes(不覆盖已有同名)
+    cfg = dict(config)
+    app = dict(cfg.get("app") or {})
+    mcp = dict(app.get("mcp") or {})
+    custom = dict(mcp.get("custom") or {})
+    existing_nodes = dict(custom.get("nodes") or {})
+    for k, v in nodes.items():
+        existing_nodes.setdefault(k, v)
+    custom["nodes"] = existing_nodes
+    mcp["custom"] = custom
+    app["mcp"] = mcp
+    cfg["app"] = app
+    return cfg

+ 10 - 2
lambdagent/src/lambdagent/tool_gateway.py

@@ -225,9 +225,17 @@ def classify_tool_call(tool_name: str, tool_input: Any) -> tuple[RiskLevel, str]
     if tool_name in ("terminate", "done"):
     if tool_name in ("terminate", "done"):
         return RiskLevel.SAFE, "agent termination"
         return RiskLevel.SAFE, "agent termination"
 
 
-    # MCP 远程工具
+    # MCP 远程工具(评审#15 MCP_SKILL_DESIGN §1.3):远程副作用不可预知,
+    # 默认 MEDIUM 而非 LOW;工具名含破坏性动词 → 升 HIGH(走人工确认)。
+    # 原来一律 LOW 意味着 MCP 的删除/发邮件/发请求工具不触发确认,是漏洞。
     if tool_name.startswith("mcp_") or "." in tool_name:
     if tool_name.startswith("mcp_") or "." in tool_name:
-        return RiskLevel.LOW, f"MCP remote tool: {tool_name}"
+        low = tool_name.lower()
+        _HIGH_VERBS = ("delete", "remove", "drop", "send", "email", "exec",
+                       "execute", "write", "payment", "pay", "transfer",
+                       "purchase", "deploy", "publish", "post", "create_issue")
+        if any(v in low for v in _HIGH_VERBS):
+            return RiskLevel.HIGH, f"MCP remote tool (破坏性动词): {tool_name}"
+        return RiskLevel.MEDIUM, f"MCP remote tool: {tool_name}"
 
 
     return RiskLevel.LOW, f"unknown tool: {tool_name}"
     return RiskLevel.LOW, f"unknown tool: {tool_name}"
 
 

+ 183 - 0
tests/test_mcp_registry.py

@@ -0,0 +1,183 @@
+"""
+tests/test_mcp_registry.py — MCP server 注册中心 P1(docs/MCP_SKILL_DESIGN.md)。
+
+覆盖:
+- 注册中心 CRUD(文件锁 json 权威源)、校验、脱敏。
+- stdio argv allowlist(评审#16)。
+- 风险分级 medium 默认 + 破坏性动词升 high(评审#15)。
+- inject_into_config 只注入启用的 http server(评审#14 禁用即不可用)。
+- ToolGateway MCP 风险修复。
+- API 端点 loopback 门 + CRUD + probe。
+"""
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+
+os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
+os.environ.setdefault("AGENTPAAS_TESTING", "1")
+
+
+@pytest.fixture()
+def reg(tmp_path, monkeypatch):
+    """隔离 MCP_FILE 到 tmp,避免动到真实 ~/.agentpaas/mcp_servers.json。"""
+    from agentpaas.engine import mcp_registry as r
+    monkeypatch.setattr(r, "MCP_FILE", str(tmp_path / "mcp_servers.json"))
+    return r
+
+
+# ── CRUD + 校验 ──
+
+def test_upsert_list_get_remove(reg):
+    reg.upsert_server({
+        "id": "arxiv", "name": "arXiv", "transport": "http",
+        "url": "http://localhost:9000/mcp",
+        "auth": {"kind": "env_bearer", "env_key": "ARXIV_KEY"},
+    })
+    lst = reg.list_servers()
+    assert len(lst) == 1 and lst[0]["id"] == "arxiv"
+    got = reg.get_server("arxiv")
+    assert got["url"] == "http://localhost:9000/mcp"
+    assert got["auth"]["env_key"] == "ARXIV_KEY"   # 变量名保留
+    assert "value" not in got["auth"]              # 评审#13: 永不出现凭证值
+    assert reg.set_enabled("arxiv", False)
+    assert reg.get_server("arxiv")["enabled"] is False
+    assert reg.remove_server("arxiv")
+    assert reg.get_server("arxiv") is None
+
+
+def test_validate_rejects_bad(reg):
+    assert reg.validate_server({"id": "x", "transport": "http"})  # 缺 url
+    assert reg.validate_server({"id": "x y", "transport": "http", "url": "u"})  # 非法 id
+    assert reg.validate_server({"id": "x", "transport": "ftp"})  # 非法 transport
+
+
+def test_stdio_argv_allowlist(reg):
+    # rm 不在 allowlist → 拒绝
+    errs = reg.validate_server({"id": "evil", "transport": "stdio",
+                                "command_argv": ["rm", "-rf", "/"]})
+    assert any("allowlist" in e for e in errs)
+    # npx 在 allowlist → 通过
+    assert reg.validate_server({"id": "ok", "transport": "stdio",
+                                "command_argv": ["npx", "some-mcp"]}) == []
+    # 显式加白
+    assert reg.validate_server({"id": "ok2", "transport": "stdio",
+                                "command_argv": ["mybin"]},
+                               argv_allowlist=reg.DEFAULT_ARGV_ALLOWLIST | {"mybin"}) == []
+
+
+def test_risk_classification(reg):
+    ov = {"special_tool": "low"}
+    assert reg.classify_mcp_tool("search_papers", {}) == "medium"   # 默认 medium
+    assert reg.classify_mcp_tool("delete_paper", {}) == "high"      # 动词升 high
+    assert reg.classify_mcp_tool("send_email", {}) == "high"
+    assert reg.classify_mcp_tool("special_tool", ov) == "low"       # override 优先
+
+
+# ── 运行时注入桥(评审#14)──
+
+def test_inject_only_enabled_http(reg):
+    reg.upsert_server({"id": "on", "transport": "http", "url": "http://h/mcp",
+                       "auth": {"kind": "none"}})
+    reg.upsert_server({"id": "off", "transport": "http", "url": "http://h2/mcp",
+                       "auth": {"kind": "none"}, "enabled": False})
+    reg.upsert_server({"id": "stdio1", "transport": "stdio",
+                       "command_argv": ["npx", "x"]})
+    cfg = reg.inject_into_config({"name": "a", "type": "simple"})
+    nodes = cfg["app"]["mcp"]["custom"]["nodes"]
+    assert "on" in nodes                 # 启用的 http 注入
+    assert "off" not in nodes            # 禁用的不注入(评审#14)
+    assert "stdio1" not in nodes         # P1 运行时桥不接 stdio
+
+
+def test_inject_credential_from_env(reg, monkeypatch):
+    monkeypatch.setenv("MY_MCP_KEY", "secret123")
+    reg.upsert_server({"id": "s", "transport": "http", "url": "http://h/mcp",
+                       "auth": {"kind": "env_bearer", "env_key": "MY_MCP_KEY"}})
+    cfg = reg.inject_into_config({"name": "a"})
+    hdr = cfg["app"]["mcp"]["custom"]["nodes"]["s"]["headers"]
+    assert hdr["Authorization"] == "Bearer secret123"
+
+
+def test_inject_no_servers_passthrough(reg):
+    cfg = {"name": "a", "type": "simple"}
+    assert reg.inject_into_config(cfg) == cfg
+
+
+# ── ToolGateway MCP 风险修复(评审#15)──
+
+def test_gateway_mcp_risk():
+    from lambdagent.tool_gateway import classify_tool_call, RiskLevel
+    assert classify_tool_call("mcp_search", "")[0] == RiskLevel.MEDIUM
+    assert classify_tool_call("arxiv.search", "")[0] == RiskLevel.MEDIUM
+    assert classify_tool_call("mcp_delete_file", "")[0] == RiskLevel.HIGH
+    assert classify_tool_call("github.create_issue", "")[0] == RiskLevel.HIGH
+
+
+# ── API 端点 ──
+
+@pytest.fixture()
+def api(tmp_path, monkeypatch):
+    import secrets
+    from fastapi.testclient import TestClient
+    from agentpaas.api.app import app
+    from agentpaas.api.middleware.auth import hash_key
+    from agentpaas.db.models import Database, gen_id, now_utc
+    from agentpaas.engine import mcp_registry as r
+    import agentpaas.db.session as _session_mod
+    monkeypatch.setattr(r, "MCP_FILE", str(tmp_path / "mcp.json"))
+    prev = _session_mod._db
+    _session_mod._db = Database("sqlite:///:memory:")
+    db = _session_mod._db
+    tid, uid = gen_id("tn_"), gen_id("usr_")
+    raw = f"ap_{secrets.token_hex(16)}"
+    now = now_utc()
+    db.execute("INSERT INTO tenants (id,name,plan,status,created_at) VALUES (?,'t','free','active',?)", (tid, now))
+    db.execute("INSERT INTO users (id,tenant_id,email,role,created_at) VALUES (?,?,'','admin',?)", (uid, tid, now))
+    db.execute("INSERT INTO api_keys (id,tenant_id,user_id,key_hash,key_prefix,name,scopes,rate_limit,status,created_at) "
+               "VALUES (?,?,?,?,?,'t',?,600,'active',?)",
+               (gen_id("key_"), tid, uid, hash_key(raw), raw[:8], json.dumps(["agents:*"]), now))
+    db.commit()
+    with TestClient(app) as c:
+        yield c, raw
+    _session_mod._db = prev
+
+
+def _auth(k):
+    return {"Authorization": f"Bearer {k}"}
+
+
+def test_api_crud_and_list(api):
+    c, key = api
+    r = c.post("/api/v1/mcp", headers=_auth(key),
+               json={"id": "arxiv", "name": "arXiv", "transport": "http",
+                     "url": "http://localhost:9000/mcp"})
+    assert r.status_code == 201, r.text[:200]
+    lst = c.get("/api/v1/mcp", headers=_auth(key)).json()
+    assert any(s["id"] == "arxiv" for s in lst["servers"])
+    # 启停
+    assert c.put("/api/v1/mcp/arxiv", headers=_auth(key), json={"enabled": False}).status_code == 200
+    assert c.get("/api/v1/mcp", headers=_auth(key)).json()["servers"][0]["enabled"] is False
+    assert c.delete("/api/v1/mcp/arxiv", headers=_auth(key)).status_code == 200
+
+
+def test_api_add_rejects_bad_argv(api):
+    c, key = api
+    r = c.post("/api/v1/mcp", headers=_auth(key),
+               json={"id": "evil", "transport": "stdio", "command_argv": ["rm", "-rf", "/"]})
+    assert r.status_code == 400 and "allowlist" in r.json()["detail"]
+
+
+def test_api_probe_unreachable(api):
+    c, key = api
+    c.post("/api/v1/mcp", headers=_auth(key),
+           json={"id": "dead", "transport": "http", "url": "http://127.0.0.1:1/mcp"})
+    r = c.post("/api/v1/mcp/dead/probe", headers=_auth(key))
+    assert r.status_code == 200 and r.json()["status"] == "unreachable"
+
+
+def test_api_probe_unknown_404(api):
+    c, key = api
+    assert c.post("/api/v1/mcp/nope/probe", headers=_auth(key)).status_code == 404

+ 2 - 0
webui/src/App.tsx

@@ -13,6 +13,7 @@ import Knowledge from './pages/Knowledge'
 import KnowledgeDetail from './pages/KnowledgeDetail'
 import KnowledgeDetail from './pages/KnowledgeDetail'
 import AgentPacks from './pages/AgentPacks'
 import AgentPacks from './pages/AgentPacks'
 import AgentWorkspace from './pages/AgentWorkspace'
 import AgentWorkspace from './pages/AgentWorkspace'
+import Connections from './pages/Connections'
 
 
 function RequireAuth({ children }: { children: React.ReactNode }) {
 function RequireAuth({ children }: { children: React.ReactNode }) {
   const apiKey = useAppStore(s => s.apiKey)
   const apiKey = useAppStore(s => s.apiKey)
@@ -42,6 +43,7 @@ export default function App() {
           <Route path="agents/:agentId/workspace" element={<AgentWorkspace />} />
           <Route path="agents/:agentId/workspace" element={<AgentWorkspace />} />
           <Route path="chat/:agentId" element={<Chat />} />
           <Route path="chat/:agentId" element={<Chat />} />
           <Route path="settings/providers" element={<Providers />} />
           <Route path="settings/providers" element={<Providers />} />
+          <Route path="settings/connections" element={<Connections />} />
           <Route path="knowledge" element={<Knowledge />} />
           <Route path="knowledge" element={<Knowledge />} />
           <Route path="knowledge/:kbId" element={<KnowledgeDetail />} />
           <Route path="knowledge/:kbId" element={<KnowledgeDetail />} />
           <Route path="agentpacks" element={<AgentPacks />} />
           <Route path="agentpacks" element={<AgentPacks />} />

+ 40 - 0
webui/src/api/mcp.ts

@@ -0,0 +1,40 @@
+import { api } from './client'
+
+export interface McpAuth {
+  kind: string            // none | env_bearer | env_header
+  env_key?: string
+  header_name?: string
+}
+
+export interface McpServer {
+  id: string
+  name: string
+  transport: string       // stdio | sse | http
+  command_argv: string[]
+  url: string
+  auth: McpAuth
+  enabled: boolean
+  tool_risk_overrides: Record<string, string>
+}
+
+export interface McpTool {
+  name: string
+  description: string
+  risk: string            // low | medium | high
+}
+
+export interface ProbeResult {
+  status: string          // ok | unreachable | auth_failed | registered | unsupported
+  tools: McpTool[]
+  error: string
+}
+
+export const mcpApi = {
+  list: () => api.get<{ servers: McpServer[] }>('/mcp'),
+  add: (body: Partial<McpServer> & { id: string; allow_argv?: string[] }) =>
+    api.post<{ ok: boolean; server: McpServer }>('/mcp', body),
+  update: (id: string, body: Partial<McpServer> & { allow_argv?: string[] }) =>
+    api.put<{ ok: boolean; server: McpServer }>(`/mcp/${id}`, body),
+  remove: (id: string) => api.delete<{ ok: boolean }>(`/mcp/${id}`),
+  probe: (id: string) => api.post<ProbeResult>(`/mcp/${id}/probe`),
+}

+ 2 - 1
webui/src/components/Sidebar.tsx

@@ -1,5 +1,5 @@
 import { NavLink, useNavigate } from 'react-router-dom'
 import { NavLink, useNavigate } from 'react-router-dom'
-import { LayoutDashboard, Bot, Cpu, LogOut, Zap, LibraryBig, Package } from 'lucide-react'
+import { LayoutDashboard, Bot, Cpu, LogOut, Zap, LibraryBig, Package, Plug } from 'lucide-react'
 import { useAppStore } from '../store/app'
 import { useAppStore } from '../store/app'
 import { clsx } from '../lib/clsx'
 import { clsx } from '../lib/clsx'
 import { useMode, MODE_META, type DeploymentMode } from '../api/mode'
 import { useMode, MODE_META, type DeploymentMode } from '../api/mode'
@@ -16,6 +16,7 @@ function navItems(mode: DeploymentMode) {
     { to: '/agentpacks', label: '智能体包', icon: Package },
     { to: '/agentpacks', label: '智能体包', icon: Package },
     { to: '/knowledge', label: desktop ? '资料库' : '知识体', icon: LibraryBig },
     { to: '/knowledge', label: desktop ? '资料库' : '知识体', icon: LibraryBig },
     { to: '/settings/providers', label: desktop ? '模型与隐私' : '模型提供商', icon: Cpu },
     { to: '/settings/providers', label: desktop ? '模型与隐私' : '模型提供商', icon: Cpu },
+    { to: '/settings/connections', label: '工具与连接', icon: Plug },
   ]
   ]
 }
 }
 
 

+ 236 - 0
webui/src/pages/Connections.tsx

@@ -0,0 +1,236 @@
+/**
+ * Connections — 「工具与连接」页(MCP server 注册中心,P1)。
+ *
+ * 老师在这里接入外部 MCP 工具(如 arXiv 检索)。docs/MCP_SKILL_DESIGN.md:
+ * server 卡片(状态点)、添加表单(transport 三选一)、测试连接(拉工具
+ * 清单+风险预分级)、启停。stdio 可执行走 allowlist;凭证只引用环境变量名。
+ */
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import {
+  Plug, Plus, Trash2, RefreshCw, X, CheckCircle2, XCircle,
+  Loader2, AlertTriangle,
+} from 'lucide-react'
+import toast from 'react-hot-toast'
+import { mcpApi, type McpServer, type ProbeResult } from '../api/mcp'
+import { PageHeader, Card, Button, Spinner, Badge } from '../components/ui'
+import { clsx } from '../lib/clsx'
+
+function riskBadge(risk: string) {
+  if (risk === 'high') return <Badge variant="red">高危·需确认</Badge>
+  if (risk === 'medium') return <Badge variant="yellow">中</Badge>
+  return <Badge variant="default">低</Badge>
+}
+
+function statusDot(status?: string) {
+  const map: Record<string, string> = {
+    ok: 'bg-green-500', registered: 'bg-blue-400',
+    unreachable: 'bg-red-500', auth_failed: 'bg-amber-500',
+  }
+  return <span className={clsx('w-2 h-2 rounded-full inline-block', map[status ?? ''] ?? 'bg-gray-300')} />
+}
+
+export default function Connections() {
+  const qc = useQueryClient()
+  const [showAdd, setShowAdd] = useState(false)
+  const [probes, setProbes] = useState<Record<string, ProbeResult>>({})
+  const [probing, setProbing] = useState<string | null>(null)
+
+  const { data, isLoading, isError } = useQuery({
+    queryKey: ['mcp'], queryFn: mcpApi.list, retry: false,
+  })
+
+  const toggleMut = useMutation({
+    mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
+      mcpApi.update(id, { enabled }),
+    onSuccess: () => qc.invalidateQueries({ queryKey: ['mcp'] }),
+    onError: (e: any) => toast.error(e.message ?? '操作失败'),
+  })
+  const removeMut = useMutation({
+    mutationFn: (id: string) => mcpApi.remove(id),
+    onSuccess: () => { toast.success('已移除'); qc.invalidateQueries({ queryKey: ['mcp'] }) },
+    onError: (e: any) => toast.error(e.message ?? '移除失败'),
+  })
+
+  async function probe(id: string) {
+    setProbing(id)
+    try {
+      const r = await mcpApi.probe(id)
+      setProbes(p => ({ ...p, [id]: r }))
+      if (r.status === 'ok') toast.success(`连接成功,发现 ${r.tools.length} 个工具`)
+      else if (r.status === 'registered') toast('已登记,stdio 将在运行时验证')
+      else toast.error(`连接失败:${r.error || r.status}`)
+    } catch (e: any) {
+      toast.error(e.message ?? '测试失败')
+    } finally { setProbing(null) }
+  }
+
+  const servers = data?.servers ?? []
+
+  return (
+    <div className="p-8 max-w-4xl mx-auto">
+      <PageHeader
+        title="工具与连接"
+        description="接入外部 MCP 工具服务,供智能体在对话中调用"
+        action={<Button size="sm" icon={<Plus size={14} />} onClick={() => setShowAdd(true)}>添加 MCP</Button>}
+      />
+
+      <div className="mb-5 flex items-start gap-3 bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-xs text-amber-800">
+        <AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-600" />
+        <div>
+          只添加来自可信来源的 MCP server。stdio 类需要本机命令(限 npx/node/python 等已知运行器);
+          凭证请填环境变量名(写在 <code>~/.agentpaas/.env</code>),不直接存密钥。
+          MCP 的删除/发送类工具默认需人工确认。
+        </div>
+      </div>
+
+      {isLoading ? <div className="flex justify-center py-16"><Spinner size={32} /></div>
+        : isError ? (
+          <div className="rounded-xl border border-yellow-200 bg-yellow-50 p-6 text-sm text-yellow-800">
+            无法连接后端服务,请确认 AgentPaaS 正在运行。
+          </div>
+        ) : servers.length === 0 ? (
+          <Card className="text-center py-12">
+            <Plug size={36} className="mx-auto text-gray-300 mb-3" />
+            <p className="text-sm text-gray-500 mb-4">还没有接入任何 MCP 工具</p>
+            <Button size="sm" icon={<Plus size={14} />} onClick={() => setShowAdd(true)}>添加第一个</Button>
+          </Card>
+        ) : (
+          <div className="space-y-3">
+            {servers.map(s => {
+              const pr = probes[s.id]
+              return (
+                <Card key={s.id} className="p-0 overflow-hidden">
+                  <div className="flex items-center gap-3 px-4 py-3">
+                    {statusDot(pr?.status)}
+                    <div className="min-w-0 flex-1">
+                      <p className="text-sm font-medium text-gray-900 truncate">
+                        {s.name || s.id} <span className="text-[10px] text-gray-400 font-mono">{s.transport}</span>
+                      </p>
+                      <p className="text-xs text-gray-400 truncate font-mono">
+                        {s.transport === 'stdio' ? s.command_argv.join(' ') : s.url}
+                        {s.auth?.kind !== 'none' && s.auth?.env_key && `  · 凭证 $${s.auth.env_key}`}
+                      </p>
+                    </div>
+                    {!s.enabled && <Badge variant="default">已禁用</Badge>}
+                    <Button size="sm" variant="secondary" disabled={probing === s.id}
+                      icon={probing === s.id ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
+                      onClick={() => probe(s.id)}>测试</Button>
+                    <button
+                      onClick={() => toggleMut.mutate({ id: s.id, enabled: !s.enabled })}
+                      className={clsx('text-xs px-2.5 py-1.5 rounded-lg transition-colors',
+                        s.enabled ? 'bg-green-50 text-green-700 hover:bg-green-100'
+                          : 'bg-gray-100 text-gray-500 hover:bg-gray-200')}
+                    >{s.enabled ? '启用中' : '已停用'}</button>
+                    <button onClick={() => { if (confirm(`移除 ${s.name || s.id}?`)) removeMut.mutate(s.id) }}
+                      className="text-gray-300 hover:text-red-500 p-1"><Trash2 size={14} /></button>
+                  </div>
+                  {pr && pr.tools.length > 0 && (
+                    <div className="border-t border-gray-100 px-4 py-2.5 bg-gray-50/50">
+                      <p className="text-[11px] text-gray-400 mb-1.5">{pr.tools.length} 个工具:</p>
+                      <div className="space-y-1">
+                        {pr.tools.map(t => (
+                          <div key={t.name} className="flex items-center gap-2 text-xs">
+                            <span className="font-mono text-gray-700">{t.name}</span>
+                            {riskBadge(t.risk)}
+                            <span className="text-gray-400 truncate">{t.description}</span>
+                          </div>
+                        ))}
+                      </div>
+                    </div>
+                  )}
+                  {pr && pr.status !== 'ok' && (
+                    <div className="border-t border-gray-100 px-4 py-2 text-xs flex items-center gap-1.5">
+                      {pr.status === 'registered'
+                        ? <><CheckCircle2 size={12} className="text-blue-400" /><span className="text-gray-500">已登记,stdio 在运行时验证</span></>
+                        : <><XCircle size={12} className="text-red-400" /><span className="text-red-500">{pr.error || pr.status}</span></>}
+                    </div>
+                  )}
+                </Card>
+              )
+            })}
+          </div>
+        )}
+
+      {showAdd && <AddModal onClose={() => setShowAdd(false)}
+        onAdded={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['mcp'] }) }} />}
+    </div>
+  )
+}
+
+function AddModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
+  const [id, setId] = useState('')
+  const [name, setName] = useState('')
+  const [transport, setTransport] = useState('http')
+  const [url, setUrl] = useState('')
+  const [argv, setArgv] = useState('')
+  const [envKey, setEnvKey] = useState('')
+
+  const addMut = useMutation({
+    mutationFn: () => mcpApi.add({
+      id, name, transport,
+      url: transport !== 'stdio' ? url : '',
+      command_argv: transport === 'stdio' ? argv.trim().split(/\s+/).filter(Boolean) : [],
+      auth: envKey ? { kind: 'env_bearer', env_key: envKey } : { kind: 'none' },
+    }),
+    onSuccess: () => { toast.success('已添加'); onAdded() },
+    onError: (e: any) => toast.error(e.message ?? '添加失败'),
+  })
+
+  return (
+    <div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
+      <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-5" onClick={e => e.stopPropagation()}>
+        <div className="flex items-center justify-between mb-4">
+          <h3 className="text-base font-semibold text-gray-900">添加 MCP server</h3>
+          <button onClick={onClose} className="text-gray-300 hover:text-gray-500"><X size={18} /></button>
+        </div>
+        <div className="space-y-3">
+          <div className="grid grid-cols-2 gap-3">
+            <label className="block">
+              <span className="text-xs text-gray-500">ID(英文短名)</span>
+              <input value={id} onChange={e => setId(e.target.value)} placeholder="arxiv-search"
+                className="mt-1 w-full text-sm border border-gray-200 rounded-lg px-2.5 py-1.5" />
+            </label>
+            <label className="block">
+              <span className="text-xs text-gray-500">显示名</span>
+              <input value={name} onChange={e => setName(e.target.value)} placeholder="arXiv 检索"
+                className="mt-1 w-full text-sm border border-gray-200 rounded-lg px-2.5 py-1.5" />
+            </label>
+          </div>
+          <label className="block">
+            <span className="text-xs text-gray-500">连接方式</span>
+            <select value={transport} onChange={e => setTransport(e.target.value)}
+              className="mt-1 w-full text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white">
+              <option value="http">http(JSON-RPC 端点)</option>
+              <option value="sse">sse(流式端点)</option>
+              <option value="stdio">stdio(本机命令)</option>
+            </select>
+          </label>
+          {transport === 'stdio' ? (
+            <label className="block">
+              <span className="text-xs text-gray-500">启动命令(限 npx/node/python 等)</span>
+              <input value={argv} onChange={e => setArgv(e.target.value)} placeholder="npx arxiv-mcp"
+                className="mt-1 w-full text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 font-mono" />
+            </label>
+          ) : (
+            <label className="block">
+              <span className="text-xs text-gray-500">端点 URL</span>
+              <input value={url} onChange={e => setUrl(e.target.value)} placeholder="http://localhost:9000/mcp"
+                className="mt-1 w-full text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 font-mono" />
+            </label>
+          )}
+          <label className="block">
+            <span className="text-xs text-gray-500">凭证环境变量名(可选,值写在 ~/.agentpaas/.env)</span>
+            <input value={envKey} onChange={e => setEnvKey(e.target.value)} placeholder="ARXIV_API_KEY"
+              className="mt-1 w-full text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 font-mono" />
+          </label>
+        </div>
+        <div className="flex justify-end gap-2 mt-5">
+          <Button size="sm" variant="secondary" onClick={onClose}>取消</Button>
+          <Button size="sm" disabled={!id || addMut.isPending}
+            onClick={() => addMut.mutate()}>{addMut.isPending ? '添加中…' : '添加'}</Button>
+        </div>
+      </div>
+    </div>
+  )
+}