|
@@ -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
|