oclaw/runtime/tools/mcp/adapter.py
oliver 420abac9f1 refactor: root-package imports (svc/runtime/interfaces) and fix PYTHONPATH
- Rename platform/ to svc/ to avoid shadowing stdlib platform.
- Replace from oclaw.* with from svc/runtime/interfaces; update -m CLI paths.
- tests/conftest: prepend repo root to sys.path (no parent-folder package name).
- CI: paths and offline_eval script under repo root.
- Ops scripts: PYTHONPATH must be repo root for python -m runtime.* (fixes gateway/WhatsApp sidecar startup).
- Fix default oclaw.json path in tabular/file attachment limits; stabilize attachment test config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 14:51:17 +08:00

192 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from dataclasses import dataclass
import json
import os
from typing import Any
from runtime.operations.mcp_env import mcp_env_allowlist_keys
from runtime.skills import SkillSpec, materialize_skills_from_tool_specs
from runtime.tools.base import ToolSpec
from runtime.tools.mcp.filesystem_argv import build_mcp_process_command
from runtime.tools.mcp.runtime import McpProcessRuntime
from runtime.tools.public.bailian_webparser_tool import bailian_webparser_tool
@dataclass
class _McpBoundTool:
server_id: str
tool_name: str
description: str
parameters: dict[str, Any]
command: list[str]
timeout_s: float = 30.0
required_permissions: frozenset[str] = frozenset()
env_allowlist: list[str] | None = None
def to_spec(self) -> ToolSpec:
rt = McpProcessRuntime(command=self.command, timeout_s=self.timeout_s, env_allowlist=self.env_allowlist)
def _handler(args: dict[str, Any]) -> dict[str, Any]:
res = rt.call_tool(tool_name=self.tool_name, arguments=args or {})
if not isinstance(res, dict):
return {"ok": False, "error_code": "mcp_runtime_invalid_payload", "error": "invalid_response"}
if "ok" not in res:
res["ok"] = False
return res
return ToolSpec(
name=f"mcp__{self.server_id}__{self.tool_name}",
description=self.description,
parameters=self.parameters or {"type": "object", "properties": {}},
handler=_handler,
tags=frozenset({"mcp", "plugin"}),
version="v1",
risk_level="high",
timeout_s=self.timeout_s,
required_permissions=self.required_permissions,
execution_mode="subprocess",
)
def materialize_mcp_tools(store: Any, *, policy_session_id: str | None = None) -> list[ToolSpec]:
return materialize_mcp_tools_for_specialist(
store,
specialist=None,
policy_session_id=policy_session_id,
)
def materialize_mcp_tools_for_specialist(
store: Any,
*,
specialist: str | None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> list[ToolSpec]:
def _is_bailian_webparser_remote_row(r: dict[str, Any]) -> bool:
cmd2 = str(r.get("entry_command") or "").strip().lower()
if cmd2 not in {"npx", "npx.cmd", "node"}:
return False
argv = [str(x or "").strip().lower() for x in (r.get("entry_args") or [])]
joined = " ".join(argv)
return "mcp-remote" in joined and "/api/v1/mcps/webparser/sse" in joined
sp = str(specialist or "").strip().lower()
if sp == "manager":
# Manager is a first-class binding role in admin UI/config.
# We keep it separate from generalist instead of aliasing.
sp = "manager"
# Preferred mapping: specialist -> server_ids
binding_server_ids: set[str] | None = None
try:
if store is not None and sp:
raw_binding = str(store.get_setting("mcp_specialist_server_binding") or "").strip()
if raw_binding:
obj = json.loads(raw_binding)
if isinstance(obj, dict):
rows = obj.get(sp)
# 缺键或 null:视为未配置该专家的绑定 → 走下方「仅 coarse allowlist」逻辑(可见全部已启用 MCP)。
# 仅当键存在且为 JSON 数组时,才按白名单过滤(含空数组 = 刻意不给该专家任何 MCP)。
if rows is None:
binding_server_ids = None
elif isinstance(rows, list):
binding_server_ids = {str(x).strip() for x in rows if str(x).strip()}
else:
binding_server_ids = set()
except Exception:
binding_server_ids = None
# Fallback to coarse specialist allowlist if no binding mapping is configured.
raw_allowed = ""
try:
if store is not None:
raw_allowed = str(store.get_setting("mcp_allowed_specialists") or "").strip()
except Exception:
raw_allowed = ""
if not raw_allowed:
raw_allowed = str(os.getenv("AIA_MCP_SPECIALISTS") or "generalist,manager").strip()
allowed = {x.strip().lower() for x in raw_allowed.split(",") if x.strip()}
if binding_server_ids is None and sp and sp not in allowed:
return []
out: list[ToolSpec] = []
rows = store.list_mcp_servers(enabled_only=True) if store else []
env_allowlist = mcp_env_allowlist_keys()
for row in rows:
server_id = str(row.get("server_id") or "").strip()
cmd = str(row.get("entry_command") or "").strip()
if not server_id or not cmd:
continue
if binding_server_ids is not None and sp and server_id not in binding_server_ids:
continue
raw_args = [x for x in (row.get("entry_args") or []) if isinstance(x, str)]
command = build_mcp_process_command(
cmd,
raw_args,
store=store,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
try:
tools = store.list_mcp_server_tools(server_id=server_id)
except Exception:
tools = []
for t in tools:
tname = str(t.get("tool_name") or "")
if _is_bailian_webparser_remote_row(row) and tname == "bailian_webparser_parse":
compat = bailian_webparser_tool()
out.append(
ToolSpec(
name=f"mcp__{server_id}__{tname}",
description=str(t.get("description") or compat.description),
parameters=t.get("parameters") if isinstance(t.get("parameters"), dict) else compat.parameters,
handler=compat.handler,
tags=frozenset({"mcp", "plugin", "compat"}),
version="v1",
risk_level="high",
timeout_s=float(row.get("timeout_s") or 30.0),
required_permissions=frozenset(str(x) for x in (row.get("required_permissions") or [])),
execution_mode="subprocess",
)
)
continue
spec = _McpBoundTool(
server_id=server_id,
tool_name=tname,
description=str(t.get("description") or f"MCP tool {t.get('tool_name') or ''}"),
parameters=t.get("parameters") if isinstance(t.get("parameters"), dict) else {},
command=command,
timeout_s=float(row.get("timeout_s") or 30.0),
required_permissions=frozenset(str(x) for x in (row.get("required_permissions") or [])),
env_allowlist=env_allowlist,
).to_spec()
out.append(spec)
return out
def materialize_mcp_skills_for_specialist(
store: Any,
*,
specialist: str | None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> tuple[SkillSpec, ...]:
tools = materialize_mcp_tools_for_specialist(
store=store,
specialist=specialist,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
return materialize_skills_from_tool_specs(tools)
__all__ = [
"materialize_mcp_tools",
"materialize_mcp_tools_for_specialist",
"materialize_mcp_skills_for_specialist",
]