test_mcp_registry.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """
  2. tests/test_mcp_registry.py — MCP server 注册中心 P1(docs/MCP_SKILL_DESIGN.md)。
  3. 覆盖:
  4. - 注册中心 CRUD(文件锁 json 权威源)、校验、脱敏。
  5. - stdio argv allowlist(评审#16)。
  6. - 风险分级 medium 默认 + 破坏性动词升 high(评审#15)。
  7. - inject_into_config 只注入启用的 http server(评审#14 禁用即不可用)。
  8. - ToolGateway MCP 风险修复。
  9. - API 端点 loopback 门 + CRUD + probe。
  10. """
  11. from __future__ import annotations
  12. import json
  13. import os
  14. import pytest
  15. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  16. os.environ.setdefault("AGENTPAAS_TESTING", "1")
  17. @pytest.fixture()
  18. def reg(tmp_path, monkeypatch):
  19. """隔离 MCP_FILE 到 tmp,避免动到真实 ~/.agentpaas/mcp_servers.json。"""
  20. from agentpaas.engine import mcp_registry as r
  21. monkeypatch.setattr(r, "MCP_FILE", str(tmp_path / "mcp_servers.json"))
  22. return r
  23. # ── CRUD + 校验 ──
  24. def test_upsert_list_get_remove(reg):
  25. reg.upsert_server({
  26. "id": "arxiv", "name": "arXiv", "transport": "http",
  27. "url": "http://localhost:9000/mcp",
  28. "auth": {"kind": "env_bearer", "env_key": "ARXIV_KEY"},
  29. })
  30. lst = reg.list_servers()
  31. assert len(lst) == 1 and lst[0]["id"] == "arxiv"
  32. got = reg.get_server("arxiv")
  33. assert got["url"] == "http://localhost:9000/mcp"
  34. assert got["auth"]["env_key"] == "ARXIV_KEY" # 变量名保留
  35. assert "value" not in got["auth"] # 评审#13: 永不出现凭证值
  36. assert reg.set_enabled("arxiv", False)
  37. assert reg.get_server("arxiv")["enabled"] is False
  38. assert reg.remove_server("arxiv")
  39. assert reg.get_server("arxiv") is None
  40. def test_validate_rejects_bad(reg):
  41. assert reg.validate_server({"id": "x", "transport": "http"}) # 缺 url
  42. assert reg.validate_server({"id": "x y", "transport": "http", "url": "u"}) # 非法 id
  43. assert reg.validate_server({"id": "x", "transport": "ftp"}) # 非法 transport
  44. def test_stdio_argv_allowlist(reg):
  45. # rm 不在 allowlist → 拒绝
  46. errs = reg.validate_server({"id": "evil", "transport": "stdio",
  47. "command_argv": ["rm", "-rf", "/"]})
  48. assert any("allowlist" in e for e in errs)
  49. # npx 在 allowlist → 通过
  50. assert reg.validate_server({"id": "ok", "transport": "stdio",
  51. "command_argv": ["npx", "some-mcp"]}) == []
  52. # 显式加白
  53. assert reg.validate_server({"id": "ok2", "transport": "stdio",
  54. "command_argv": ["mybin"]},
  55. argv_allowlist=reg.DEFAULT_ARGV_ALLOWLIST | {"mybin"}) == []
  56. def test_risk_classification(reg):
  57. ov = {"special_tool": "low"}
  58. assert reg.classify_mcp_tool("search_papers", {}) == "medium" # 默认 medium
  59. assert reg.classify_mcp_tool("delete_paper", {}) == "high" # 动词升 high
  60. assert reg.classify_mcp_tool("send_email", {}) == "high"
  61. assert reg.classify_mcp_tool("special_tool", ov) == "low" # override 优先
  62. # ── 运行时注入桥(评审#14)──
  63. def test_inject_only_enabled_http(reg):
  64. reg.upsert_server({"id": "on", "transport": "http", "url": "http://h/mcp",
  65. "auth": {"kind": "none"}})
  66. reg.upsert_server({"id": "off", "transport": "http", "url": "http://h2/mcp",
  67. "auth": {"kind": "none"}, "enabled": False})
  68. reg.upsert_server({"id": "stdio1", "transport": "stdio",
  69. "command_argv": ["npx", "x"]})
  70. cfg = reg.inject_into_config({"name": "a", "type": "simple"})
  71. nodes = cfg["app"]["mcp"]["custom"]["nodes"]
  72. assert "on" in nodes # 启用的 http 注入
  73. assert "off" not in nodes # 禁用的不注入(评审#14)
  74. assert "stdio1" not in nodes # P1 运行时桥不接 stdio
  75. def test_inject_credential_from_env(reg, monkeypatch):
  76. monkeypatch.setenv("MY_MCP_KEY", "secret123")
  77. reg.upsert_server({"id": "s", "transport": "http", "url": "http://h/mcp",
  78. "auth": {"kind": "env_bearer", "env_key": "MY_MCP_KEY"}})
  79. cfg = reg.inject_into_config({"name": "a"})
  80. hdr = cfg["app"]["mcp"]["custom"]["nodes"]["s"]["headers"]
  81. assert hdr["Authorization"] == "Bearer secret123"
  82. def test_inject_no_servers_passthrough(reg):
  83. cfg = {"name": "a", "type": "simple"}
  84. assert reg.inject_into_config(cfg) == cfg
  85. # ── ToolGateway MCP 风险修复(评审#15)──
  86. def test_gateway_mcp_risk():
  87. from lambdagent.tool_gateway import classify_tool_call, RiskLevel
  88. assert classify_tool_call("mcp_search", "")[0] == RiskLevel.MEDIUM
  89. assert classify_tool_call("arxiv.search", "")[0] == RiskLevel.MEDIUM
  90. assert classify_tool_call("mcp_delete_file", "")[0] == RiskLevel.HIGH
  91. assert classify_tool_call("github.create_issue", "")[0] == RiskLevel.HIGH
  92. # ── API 端点 ──
  93. @pytest.fixture()
  94. def api(tmp_path, monkeypatch):
  95. import secrets
  96. from fastapi.testclient import TestClient
  97. from agentpaas.api.app import app
  98. from agentpaas.api.middleware.auth import hash_key
  99. from agentpaas.db.models import Database, gen_id, now_utc
  100. from agentpaas.engine import mcp_registry as r
  101. import agentpaas.db.session as _session_mod
  102. monkeypatch.setattr(r, "MCP_FILE", str(tmp_path / "mcp.json"))
  103. prev = _session_mod._db
  104. _session_mod._db = Database("sqlite:///:memory:")
  105. db = _session_mod._db
  106. tid, uid = gen_id("tn_"), gen_id("usr_")
  107. raw = f"ap_{secrets.token_hex(16)}"
  108. now = now_utc()
  109. db.execute("INSERT INTO tenants (id,name,plan,status,created_at) VALUES (?,'t','free','active',?)", (tid, now))
  110. db.execute("INSERT INTO users (id,tenant_id,email,role,created_at) VALUES (?,?,'','admin',?)", (uid, tid, now))
  111. db.execute("INSERT INTO api_keys (id,tenant_id,user_id,key_hash,key_prefix,name,scopes,rate_limit,status,created_at) "
  112. "VALUES (?,?,?,?,?,'t',?,600,'active',?)",
  113. (gen_id("key_"), tid, uid, hash_key(raw), raw[:8], json.dumps(["agents:*"]), now))
  114. db.commit()
  115. with TestClient(app) as c:
  116. yield c, raw
  117. _session_mod._db = prev
  118. def _auth(k):
  119. return {"Authorization": f"Bearer {k}"}
  120. def test_api_crud_and_list(api):
  121. c, key = api
  122. r = c.post("/api/v1/mcp", headers=_auth(key),
  123. json={"id": "arxiv", "name": "arXiv", "transport": "http",
  124. "url": "http://localhost:9000/mcp"})
  125. assert r.status_code == 201, r.text[:200]
  126. lst = c.get("/api/v1/mcp", headers=_auth(key)).json()
  127. assert any(s["id"] == "arxiv" for s in lst["servers"])
  128. # 启停
  129. assert c.put("/api/v1/mcp/arxiv", headers=_auth(key), json={"enabled": False}).status_code == 200
  130. assert c.get("/api/v1/mcp", headers=_auth(key)).json()["servers"][0]["enabled"] is False
  131. assert c.delete("/api/v1/mcp/arxiv", headers=_auth(key)).status_code == 200
  132. def test_api_add_rejects_bad_argv(api):
  133. c, key = api
  134. r = c.post("/api/v1/mcp", headers=_auth(key),
  135. json={"id": "evil", "transport": "stdio", "command_argv": ["rm", "-rf", "/"]})
  136. assert r.status_code == 400 and "allowlist" in r.json()["detail"]
  137. def test_api_probe_unreachable(api):
  138. c, key = api
  139. c.post("/api/v1/mcp", headers=_auth(key),
  140. json={"id": "dead", "transport": "http", "url": "http://127.0.0.1:1/mcp"})
  141. r = c.post("/api/v1/mcp/dead/probe", headers=_auth(key))
  142. assert r.status_code == 200 and r.json()["status"] == "unreachable"
  143. def test_api_probe_unknown_404(api):
  144. c, key = api
  145. assert c.post("/api/v1/mcp/nope/probe", headers=_auth(key)).status_code == 404