mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 23:30:53 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
16
runtime/tools/mcp/__init__.py
Normal file
16
runtime/tools/mcp/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from .manifest import McpServerManifest
|
||||
from .installer import McpInstallResult, install_mcp_server
|
||||
from .runtime import McpProcessRuntime
|
||||
from .adapter import materialize_mcp_tools
|
||||
from .registry import McpRegistry
|
||||
from .market import search_mcp_market
|
||||
|
||||
__all__ = [
|
||||
"McpServerManifest",
|
||||
"McpInstallResult",
|
||||
"McpProcessRuntime",
|
||||
"install_mcp_server",
|
||||
"materialize_mcp_tools",
|
||||
"McpRegistry",
|
||||
"search_mcp_market",
|
||||
]
|
||||
165
runtime/tools/mcp/adapter.py
Normal file
165
runtime/tools/mcp/adapter.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.operations.mcp_env import mcp_env_allowlist_keys
|
||||
from oclaw.runtime.skills import SkillSpec, materialize_skills_from_tool_specs
|
||||
from oclaw.runtime.tools.base import ToolSpec
|
||||
from oclaw.runtime.tools.mcp.filesystem_argv import build_mcp_process_command
|
||||
from oclaw.runtime.tools.mcp.runtime import McpProcessRuntime
|
||||
|
||||
|
||||
@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]:
|
||||
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:
|
||||
spec = _McpBoundTool(
|
||||
server_id=server_id,
|
||||
tool_name=str(t.get("tool_name") or ""),
|
||||
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",
|
||||
]
|
||||
|
||||
230
runtime/tools/mcp/filesystem_argv.py
Normal file
230
runtime/tools/mcp/filesystem_argv.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"""Augment MCP @modelcontextprotocol/server-filesystem argv with extra allowed directories.
|
||||
|
||||
The filesystem MCP only exposes directories passed on the command line at process start.
|
||||
Gateway workspace policy (env + per-user DB) must be mirrored here so list_directory sees the same roots.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _split_pipe_paths(raw: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
for part in (raw or "").split("|"):
|
||||
s = part.strip().strip('"').strip("'")
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _dedup_key(p: str) -> str:
|
||||
"""Stable key for deduplicating directory roots (Windows: case-insensitive)."""
|
||||
ra = _resolved_abs_for_argv(p)
|
||||
if not ra:
|
||||
return ""
|
||||
if os.name == "nt":
|
||||
return os.path.normcase(ra)
|
||||
return ra
|
||||
|
||||
|
||||
def _resolved_abs_for_argv(p: str) -> str:
|
||||
"""Absolute path string suitable for MCP process argv; avoids silent drop on resolve quirks."""
|
||||
raw = (p or "").strip().strip('"').strip("'")
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
exp = Path(raw).expanduser()
|
||||
s = str(exp.resolve())
|
||||
if s:
|
||||
return s
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass
|
||||
try:
|
||||
return os.path.normpath(os.path.abspath(raw))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _allowlist_extras_tenant_user(*, store: Any, tenant_id: str, user_id: str) -> list[str]:
|
||||
t = (tenant_id or "").strip()
|
||||
u = (user_id or "").strip()
|
||||
if not t or not u or store is None:
|
||||
return []
|
||||
try:
|
||||
row = store.get_user_workspace_path_allowlist(tenant_id=t, user_id=u)
|
||||
except Exception:
|
||||
return []
|
||||
if not row or not isinstance(row, dict):
|
||||
return []
|
||||
out: list[str] = []
|
||||
for s in _split_pipe_paths(str(row.get("extra_roots") or "")):
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _extra_roots_for_policy_session(*, store: Any, policy_session_id: str) -> list[str]:
|
||||
"""Per-user ``extra_roots`` from DB for the chat session that owns the tool run (not a global union)."""
|
||||
out: list[str] = []
|
||||
sid = str(policy_session_id or "").strip()
|
||||
if not sid:
|
||||
return out
|
||||
try:
|
||||
own = store.get_ui_session_owner(session_id=sid)
|
||||
except Exception:
|
||||
own = None
|
||||
if not own:
|
||||
return out
|
||||
tid = str(own.get("tenant_id") or "").strip()
|
||||
uid = str(own.get("user_id") or "").strip()
|
||||
if not tid or not uid:
|
||||
return out
|
||||
try:
|
||||
row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid)
|
||||
except Exception:
|
||||
row = None
|
||||
if not row:
|
||||
return out
|
||||
for s in _split_pipe_paths(str(row.get("extra_roots") or "")):
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def collect_filesystem_mcp_extra_roots(
|
||||
*,
|
||||
store: Any | None,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Paths to append to server-filesystem argv (deduped after resolve).
|
||||
|
||||
Per-user roots from SQLite only when ``policy_session_id`` resolves via
|
||||
``ui_session_owner`` (typically the user's chat session id). Without it, only
|
||||
env/settings roots are merged (safe for admin Health/Sync and shared agents).
|
||||
You may also pass ``path_policy_tenant_id`` / ``path_policy_user_id`` to mirror
|
||||
the same allowlist when the request ``metadata`` carries the effective user, but
|
||||
``ui_session_owner`` is not yet set (e.g. legacy data).
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for raw in (
|
||||
str(os.getenv("AIA_WORKSPACE_EXTRA_ROOTS") or os.getenv("OPS_WORKSPACE_EXTRA_ROOTS") or "").strip(),
|
||||
str(os.getenv("AIA_MCP_FILESYSTEM_EXTRA_ROOTS") or os.getenv("OPS_MCP_FILESYSTEM_EXTRA_ROOTS") or "").strip(),
|
||||
):
|
||||
for s in _split_pipe_paths(raw):
|
||||
dk = _dedup_key(s)
|
||||
ra = _resolved_abs_for_argv(s)
|
||||
if dk and ra and dk not in seen:
|
||||
seen.add(dk)
|
||||
ordered.append(ra)
|
||||
if store is not None:
|
||||
try:
|
||||
raw2 = str(store.get_setting("AIA_MCP_FILESYSTEM_EXTRA_ROOTS") or "").strip()
|
||||
for s in _split_pipe_paths(raw2):
|
||||
dk = _dedup_key(s)
|
||||
ra = _resolved_abs_for_argv(s)
|
||||
if dk and ra and dk not in seen:
|
||||
seen.add(dk)
|
||||
ordered.append(ra)
|
||||
except Exception:
|
||||
pass
|
||||
ps = str(policy_session_id or "").strip()
|
||||
if ps:
|
||||
try:
|
||||
for s in _extra_roots_for_policy_session(store=store, policy_session_id=ps):
|
||||
dk = _dedup_key(s)
|
||||
ra = _resolved_abs_for_argv(s)
|
||||
if dk and ra and dk not in seen:
|
||||
seen.add(dk)
|
||||
ordered.append(ra)
|
||||
except Exception:
|
||||
pass
|
||||
t_id = (path_policy_tenant_id or "").strip()
|
||||
u_id = (path_policy_user_id or "").strip()
|
||||
if t_id and u_id and store is not None:
|
||||
try:
|
||||
for s in _allowlist_extras_tenant_user(store=store, tenant_id=t_id, user_id=u_id):
|
||||
dk = _dedup_key(s)
|
||||
ra = _resolved_abs_for_argv(s)
|
||||
if dk and ra and dk not in seen:
|
||||
seen.add(dk)
|
||||
ordered.append(ra)
|
||||
except Exception:
|
||||
pass
|
||||
return ordered
|
||||
|
||||
|
||||
def is_modelcontext_filesystem_command(command: list[str]) -> bool:
|
||||
return any("server-filesystem" in str(x) for x in command)
|
||||
|
||||
|
||||
def augment_filesystem_mcp_argv(
|
||||
command: list[str],
|
||||
*,
|
||||
store: Any | None,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
If ``command`` starts the official Model Context Protocol filesystem server, append
|
||||
extra directory roots from env / settings / DB so tools/list and tools/call match gateway policy.
|
||||
"""
|
||||
if not command or not is_modelcontext_filesystem_command(command):
|
||||
return command
|
||||
extras = collect_filesystem_mcp_extra_roots(
|
||||
store=store,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
if not extras:
|
||||
return command
|
||||
existing: set[str] = set()
|
||||
for p in command:
|
||||
s = str(p).strip()
|
||||
if not s or s.startswith("-") or "server-filesystem" in s:
|
||||
continue
|
||||
if s in ("npx", "pnpm", "yarn", "uvx", "bun"):
|
||||
continue
|
||||
dk = _dedup_key(s)
|
||||
if dk:
|
||||
existing.add(dk)
|
||||
out = list(command)
|
||||
for extra_abs in extras:
|
||||
dk = _dedup_key(extra_abs)
|
||||
if dk and dk not in existing:
|
||||
out.append(extra_abs)
|
||||
existing.add(dk)
|
||||
return out
|
||||
|
||||
|
||||
def build_mcp_process_command(
|
||||
cmd: str,
|
||||
args: list[str],
|
||||
*,
|
||||
store: Any | None,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> list[str]:
|
||||
"""``[cmd] + args`` after filesystem argv augmentation."""
|
||||
return augment_filesystem_mcp_argv(
|
||||
[cmd] + [x for x in args if str(x).strip()],
|
||||
store=store,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"augment_filesystem_mcp_argv",
|
||||
"build_mcp_process_command",
|
||||
"collect_filesystem_mcp_extra_roots",
|
||||
"is_modelcontext_filesystem_command",
|
||||
]
|
||||
184
runtime/tools/mcp/installer.py
Normal file
184
runtime/tools/mcp/installer.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .manifest import McpServerManifest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpInstallResult:
|
||||
ok: bool
|
||||
error_code: str = ""
|
||||
error: str = ""
|
||||
install_command: str = ""
|
||||
details: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _run_command(cmd: list[str], *, timeout: float) -> subprocess.CompletedProcess[str]:
|
||||
kwargs: dict[str, Any] = {"capture_output": True, "text": True, "timeout": timeout}
|
||||
if os.name == "nt":
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
if creationflags:
|
||||
kwargs["creationflags"] = creationflags
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = 0
|
||||
kwargs["startupinfo"] = startupinfo
|
||||
return subprocess.run(cmd, **kwargs)
|
||||
|
||||
|
||||
def _safe_server_id(seed: str) -> str:
|
||||
v = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(seed or "").strip().lower()).strip("-")
|
||||
return v or "mcp-server"
|
||||
|
||||
|
||||
def _install_command(manifest: McpServerManifest) -> list[str]:
|
||||
def _bin(name: str) -> str:
|
||||
n = str(name or "").strip()
|
||||
if not n:
|
||||
return n
|
||||
p = shutil.which(n)
|
||||
if p:
|
||||
return p
|
||||
if os.name == "nt":
|
||||
for suffix in (".cmd", ".exe", ".bat"):
|
||||
alt = shutil.which(n + suffix)
|
||||
if alt:
|
||||
return alt
|
||||
return n
|
||||
|
||||
if manifest.source_type == "github":
|
||||
return [_bin("git"), "clone", "--depth", "1", manifest.source_ref]
|
||||
if manifest.source_type == "npm":
|
||||
pkg = manifest.source_ref + (f"@{manifest.version}" if manifest.version else "")
|
||||
return [_bin("npm"), "install", "-g", pkg]
|
||||
if manifest.source_type == "pypi":
|
||||
pkg = manifest.source_ref + (f"=={manifest.version}" if manifest.version else "")
|
||||
return [sys.executable, "-m", "pip", "install", pkg]
|
||||
raise ValueError(f"unsupported_source_type:{manifest.source_type}")
|
||||
|
||||
|
||||
def _uninstall_command(manifest: McpServerManifest) -> list[str]:
|
||||
def _bin(name: str) -> str:
|
||||
n = str(name or "").strip()
|
||||
if not n:
|
||||
return n
|
||||
p = shutil.which(n)
|
||||
if p:
|
||||
return p
|
||||
if os.name == "nt":
|
||||
for suffix in (".cmd", ".exe", ".bat"):
|
||||
alt = shutil.which(n + suffix)
|
||||
if alt:
|
||||
return alt
|
||||
return n
|
||||
|
||||
if manifest.source_type == "npm":
|
||||
return [_bin("npm"), "uninstall", "-g", str(manifest.source_ref or "").strip()]
|
||||
if manifest.source_type == "pypi":
|
||||
return [sys.executable, "-m", "pip", "uninstall", "-y", str(manifest.source_ref or "").strip()]
|
||||
if manifest.source_type == "github":
|
||||
return []
|
||||
raise ValueError(f"unsupported_source_type:{manifest.source_type}")
|
||||
|
||||
|
||||
def install_mcp_server(manifest: McpServerManifest, *, dry_run: bool = False) -> McpInstallResult:
|
||||
try:
|
||||
cmd = _install_command(manifest)
|
||||
except Exception as exc:
|
||||
return McpInstallResult(ok=False, error_code="mcp_invalid_source", error=str(exc))
|
||||
cmd_text = " ".join(cmd)
|
||||
if dry_run:
|
||||
return McpInstallResult(ok=True, install_command=cmd_text, details={"dry_run": True})
|
||||
try:
|
||||
cp = _run_command(cmd, timeout=180)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return McpInstallResult(ok=False, error_code="mcp_install_timeout", error=str(exc), install_command=cmd_text)
|
||||
except FileNotFoundError as exc:
|
||||
return McpInstallResult(ok=False, error_code="mcp_installer_missing", error=str(exc), install_command=cmd_text)
|
||||
except Exception as exc:
|
||||
return McpInstallResult(ok=False, error_code="mcp_install_failed", error=str(exc), install_command=cmd_text)
|
||||
if cp.returncode != 0:
|
||||
err = (cp.stderr or cp.stdout or "").strip()
|
||||
return McpInstallResult(ok=False, error_code="mcp_install_failed", error=err[:800] or f"exit_code:{cp.returncode}", install_command=cmd_text)
|
||||
return McpInstallResult(ok=True, install_command=cmd_text, details={"stdout": (cp.stdout or "")[:800]})
|
||||
|
||||
|
||||
def uninstall_mcp_server(manifest: McpServerManifest, *, dry_run: bool = False) -> McpInstallResult:
|
||||
try:
|
||||
cmd = _uninstall_command(manifest)
|
||||
except Exception as exc:
|
||||
return McpInstallResult(ok=False, error_code="mcp_invalid_source", error=str(exc))
|
||||
if not cmd:
|
||||
return McpInstallResult(ok=True, install_command="", details={"skipped": True, "reason": "unsupported_or_not_required"})
|
||||
cmd_text = " ".join(cmd)
|
||||
if dry_run:
|
||||
return McpInstallResult(ok=True, install_command=cmd_text, details={"dry_run": True})
|
||||
try:
|
||||
cp = _run_command(cmd, timeout=180)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return McpInstallResult(ok=False, error_code="mcp_uninstall_timeout", error=str(exc), install_command=cmd_text)
|
||||
except FileNotFoundError as exc:
|
||||
return McpInstallResult(ok=False, error_code="mcp_installer_missing", error=str(exc), install_command=cmd_text)
|
||||
except Exception as exc:
|
||||
return McpInstallResult(ok=False, error_code="mcp_uninstall_failed", error=str(exc), install_command=cmd_text)
|
||||
if cp.returncode != 0:
|
||||
err = (cp.stderr or cp.stdout or "").strip()
|
||||
return McpInstallResult(ok=False, error_code="mcp_uninstall_failed", error=err[:800] or f"exit_code:{cp.returncode}", install_command=cmd_text)
|
||||
return McpInstallResult(ok=True, install_command=cmd_text, details={"stdout": (cp.stdout or "")[:800]})
|
||||
|
||||
|
||||
def preflight_mcp_server(manifest: McpServerManifest) -> dict[str, Any]:
|
||||
warnings: list[str] = []
|
||||
fix_suggestions: list[dict[str, str]] = []
|
||||
entry = str(manifest.entry_command or "").strip()
|
||||
if not entry:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "mcp_entry_missing",
|
||||
"error": "entry_command_missing",
|
||||
"warnings": warnings,
|
||||
"fix_suggestions": [{"title": "Set entry command", "command": "npx <pkg> | python -m <module> | node <script.js>"}],
|
||||
}
|
||||
found = shutil.which(entry)
|
||||
if not found:
|
||||
if entry in {"npx", "npm", "node"}:
|
||||
fix_suggestions.append({"title": "Install Node.js", "command": "https://nodejs.org/en/download"})
|
||||
elif entry in {"python", "pip"}:
|
||||
fix_suggestions.append({"title": "Install Python", "command": "https://www.python.org/downloads/"})
|
||||
elif entry == "git":
|
||||
fix_suggestions.append({"title": "Install Git", "command": "https://git-scm.com/downloads"})
|
||||
else:
|
||||
fix_suggestions.append({"title": "Check PATH", "command": f"where {entry}"})
|
||||
return {"ok": False, "error_code": "mcp_entry_not_found", "error": f"entry_command_not_found:{entry}", "warnings": warnings, "fix_suggestions": fix_suggestions}
|
||||
env_schema = manifest.env_schema if isinstance(manifest.env_schema, dict) else {}
|
||||
required_env = [str(k) for k, v in env_schema.items() if isinstance(v, dict) and bool(v.get("required"))]
|
||||
return {"ok": True, "error_code": "", "error": "", "entry_command_path": found, "required_env": required_env, "warnings": warnings, "fix_suggestions": fix_suggestions}
|
||||
|
||||
|
||||
def detect_local_dependencies() -> list[dict[str, Any]]:
|
||||
deps = [{"name": "git", "version_args": ["--version"]}, {"name": "node", "version_args": ["--version"]}, {"name": "npm", "version_args": ["--version"]}, {"name": "npx", "version_args": ["--version"]}, {"name": "python", "version_args": ["--version"]}, {"name": "pip", "version_args": ["--version"]}]
|
||||
out: list[dict[str, Any]] = []
|
||||
for d in deps:
|
||||
name = str(d["name"])
|
||||
path = shutil.which(name)
|
||||
if not path:
|
||||
out.append({"name": name, "ok": False, "path": "", "version": ""})
|
||||
continue
|
||||
ver = ""
|
||||
try:
|
||||
cp = _run_command([name] + list(d["version_args"]), timeout=4)
|
||||
ver = (cp.stdout or cp.stderr or "").strip().splitlines()[0] if (cp.stdout or cp.stderr) else ""
|
||||
except Exception:
|
||||
ver = ""
|
||||
out.append({"name": name, "ok": True, "path": path, "version": ver})
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["McpInstallResult", "install_mcp_server", "uninstall_mcp_server", "preflight_mcp_server", "detect_local_dependencies", "_safe_server_id"]
|
||||
20
runtime/tools/mcp/manifest.py
Normal file
20
runtime/tools/mcp/manifest.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpServerManifest:
|
||||
server_id: str
|
||||
source_type: str # github|npm|pypi
|
||||
source_ref: str
|
||||
version: str = ""
|
||||
entry_command: str = ""
|
||||
entry_args: list[str] = field(default_factory=list)
|
||||
env_schema: dict[str, Any] = field(default_factory=dict)
|
||||
permissions: list[str] = field(default_factory=list)
|
||||
risk_level: str = "high"
|
||||
enabled: bool = False
|
||||
timeout_s: float = 30.0
|
||||
|
||||
153
runtime/tools/mcp/market.py
Normal file
153
runtime/tools/mcp/market.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
_TRENDING_CACHE: dict[str, Any] = {"ts": 0.0, "items": []}
|
||||
_TRENDING_TTL_S = 1800
|
||||
|
||||
|
||||
def _safe_get_json(url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
with httpx.Client(timeout=8.0, follow_redirects=True) as c:
|
||||
r = c.get(url, params=params or {}, headers=headers or {})
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
obj = r.json()
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def search_github_repos(query: str, *, limit: int = 8) -> list[dict[str, Any]]:
|
||||
q = str(query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
blob = _safe_get_json(
|
||||
"https://api.github.com/search/repositories",
|
||||
params={"q": f"{q} mcp server", "sort": "stars", "order": "desc", "per_page": max(1, min(limit, 20))},
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
items = blob.get("items") if isinstance(blob.get("items"), list) else []
|
||||
out: list[dict[str, Any]] = []
|
||||
for it in items[:limit]:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"source_type": "github",
|
||||
"name": str(it.get("full_name") or ""),
|
||||
"source_ref": str(it.get("clone_url") or it.get("html_url") or ""),
|
||||
"description": str(it.get("description") or ""),
|
||||
"version": "",
|
||||
"homepage": str(it.get("html_url") or ""),
|
||||
"stars": int(it.get("stargazers_count") or 0),
|
||||
"install_template": infer_install_template("github", str(it.get("clone_url") or it.get("html_url") or "")),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def search_npm_packages(query: str, *, limit: int = 8) -> list[dict[str, Any]]:
|
||||
q = str(query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
blob = _safe_get_json(
|
||||
"https://registry.npmjs.org/-/v1/search",
|
||||
params={"text": f"{q} mcp", "size": max(1, min(limit, 20))},
|
||||
)
|
||||
items = blob.get("objects") if isinstance(blob.get("objects"), list) else []
|
||||
out: list[dict[str, Any]] = []
|
||||
for it in items[:limit]:
|
||||
pkg = it.get("package") if isinstance(it, dict) else None
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"source_type": "npm",
|
||||
"name": str(pkg.get("name") or ""),
|
||||
"source_ref": str(pkg.get("name") or ""),
|
||||
"description": str(pkg.get("description") or ""),
|
||||
"version": str(pkg.get("version") or ""),
|
||||
"homepage": str(pkg.get("links", {}).get("npm") if isinstance(pkg.get("links"), dict) else ""),
|
||||
"stars": 0,
|
||||
"install_template": infer_install_template("npm", str(pkg.get("name") or "")),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def search_pypi_packages(query: str, *, limit: int = 8) -> list[dict[str, Any]]:
|
||||
q = str(query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
blob = _safe_get_json(
|
||||
"https://pypi.org/search/",
|
||||
params={"q": f"{q} mcp"},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
# PyPI JSON search API is not officially stable; keep best-effort.
|
||||
projects = blob.get("projects") if isinstance(blob.get("projects"), list) else []
|
||||
out: list[dict[str, Any]] = []
|
||||
for it in projects[:limit]:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
name = str(it.get("name") or "")
|
||||
out.append(
|
||||
{
|
||||
"source_type": "pypi",
|
||||
"name": name,
|
||||
"source_ref": name,
|
||||
"description": str(it.get("description") or ""),
|
||||
"version": str(it.get("version") or ""),
|
||||
"homepage": f"https://pypi.org/project/{name}/" if name else "",
|
||||
"stars": 0,
|
||||
"install_template": infer_install_template("pypi", name),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def search_mcp_market(query: str, *, per_source_limit: int = 6) -> list[dict[str, Any]]:
|
||||
lim = max(1, min(int(per_source_limit or 6), 20))
|
||||
out: list[dict[str, Any]] = []
|
||||
out.extend(search_github_repos(query, limit=lim))
|
||||
out.extend(search_npm_packages(query, limit=lim))
|
||||
out.extend(search_pypi_packages(query, limit=lim))
|
||||
return out
|
||||
|
||||
|
||||
def infer_install_template(source_type: str, source_ref: str) -> dict[str, Any]:
|
||||
st = str(source_type or "").strip().lower()
|
||||
sr = str(source_ref or "").strip()
|
||||
if st == "npm":
|
||||
pkg = sr.split("/")[-1] if sr else ""
|
||||
return {"entry_command": "npx", "entry_args": [pkg] if pkg else []}
|
||||
if st == "pypi":
|
||||
pkg = sr.replace("-", "_")
|
||||
return {"entry_command": "python", "entry_args": ["-m", pkg] if pkg else []}
|
||||
return {"entry_command": "python", "entry_args": []}
|
||||
|
||||
|
||||
def trending_mcp_market(*, force_refresh: bool = False, per_source_limit: int = 5) -> list[dict[str, Any]]:
|
||||
now = time.time()
|
||||
if not force_refresh and _TRENDING_CACHE["items"] and (now - float(_TRENDING_CACHE["ts"] or 0.0) < _TRENDING_TTL_S):
|
||||
return list(_TRENDING_CACHE["items"])
|
||||
items = search_mcp_market("mcp", per_source_limit=per_source_limit)
|
||||
items = sorted(items, key=lambda x: int(x.get("stars") or 0), reverse=True)
|
||||
_TRENDING_CACHE["ts"] = now
|
||||
_TRENDING_CACHE["items"] = list(items)
|
||||
return items
|
||||
|
||||
|
||||
__all__ = [
|
||||
"search_mcp_market",
|
||||
"search_github_repos",
|
||||
"search_npm_packages",
|
||||
"search_pypi_packages",
|
||||
"infer_install_template",
|
||||
"trending_mcp_market",
|
||||
]
|
||||
|
||||
42
runtime/tools/mcp/registry.py
Normal file
42
runtime/tools/mcp/registry.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.tools.mcp.manifest import McpServerManifest
|
||||
|
||||
|
||||
class McpRegistry:
|
||||
def __init__(self, store: SqliteStore):
|
||||
self.store = store
|
||||
|
||||
def upsert_manifest(self, manifest: McpServerManifest) -> None:
|
||||
self.store.upsert_mcp_server(
|
||||
server_id=manifest.server_id,
|
||||
source_type=manifest.source_type,
|
||||
source_ref=manifest.source_ref,
|
||||
version=manifest.version,
|
||||
entry_command=manifest.entry_command,
|
||||
entry_args=manifest.entry_args,
|
||||
env_schema=manifest.env_schema,
|
||||
required_permissions=manifest.permissions,
|
||||
risk_level=manifest.risk_level,
|
||||
timeout_s=manifest.timeout_s,
|
||||
enabled=manifest.enabled,
|
||||
)
|
||||
|
||||
def list_servers(self, *, enabled_only: bool = False) -> list[dict[str, Any]]:
|
||||
return self.store.list_mcp_servers(enabled_only=enabled_only)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
rows = self.list_servers(enabled_only=False)
|
||||
return {"count": len(rows), "servers": rows}
|
||||
|
||||
@staticmethod
|
||||
def manifest_to_dict(manifest: McpServerManifest) -> dict[str, Any]:
|
||||
return asdict(manifest)
|
||||
|
||||
|
||||
__all__ = ["McpRegistry"]
|
||||
|
||||
288
runtime/tools/mcp/runtime.py
Normal file
288
runtime/tools/mcp/runtime.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class McpProcessRuntime:
|
||||
command: list[str]
|
||||
timeout_s: float = 30.0
|
||||
env_allowlist: list[str] | None = None
|
||||
_proc: subprocess.Popen[str] | None = None
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
_initialized: bool = False
|
||||
_request_id: int = 0
|
||||
|
||||
@staticmethod
|
||||
def _build_runtime_env(env_allowlist: list[str] | None) -> dict[str, str] | None:
|
||||
if env_allowlist is None:
|
||||
return None
|
||||
keep_keys = {"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "TEMP", "TMP", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "SYSTEMDRIVE"}
|
||||
env: dict[str, str] = {}
|
||||
for k in keep_keys:
|
||||
if k in os.environ:
|
||||
env[k] = os.environ[k]
|
||||
for k in env_allowlist:
|
||||
key = str(k or "").strip()
|
||||
if key and key in os.environ:
|
||||
env[key] = os.environ[key]
|
||||
return env
|
||||
|
||||
@staticmethod
|
||||
def _resolve_command(executable: str, env: dict[str, str] | None) -> str:
|
||||
cmd = str(executable or "").strip()
|
||||
if not cmd:
|
||||
return cmd
|
||||
if os.path.isabs(cmd) or os.path.sep in cmd or (os.path.altsep and os.path.altsep in cmd):
|
||||
return cmd
|
||||
resolved = shutil.which(cmd, path=(env or os.environ).get("PATH"))
|
||||
if resolved:
|
||||
return resolved
|
||||
if os.name == "nt":
|
||||
for suffix in (".cmd", ".exe", ".bat"):
|
||||
alt = shutil.which(cmd + suffix, path=(env or os.environ).get("PATH"))
|
||||
if alt:
|
||||
return alt
|
||||
return cmd
|
||||
|
||||
def start(self) -> None:
|
||||
if self._proc and self._proc.poll() is None:
|
||||
return
|
||||
env = self._build_runtime_env(self.env_allowlist)
|
||||
cmd = list(self.command or [])
|
||||
if cmd:
|
||||
cmd[0] = self._resolve_command(str(cmd[0]), env)
|
||||
popen_kwargs: dict[str, Any] = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", "env": env}
|
||||
if os.name == "nt":
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = 0
|
||||
popen_kwargs["startupinfo"] = startupinfo
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
self._proc = subprocess.Popen(cmd, **popen_kwargs)
|
||||
self._initialized = False
|
||||
self._request_id = 0
|
||||
|
||||
def stop(self) -> None:
|
||||
p = self._proc
|
||||
if not p:
|
||||
return
|
||||
try:
|
||||
if p.poll() is None:
|
||||
try:
|
||||
p.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
p.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
p.kill()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
for fp in (p.stdin, p.stdout, p.stderr):
|
||||
try:
|
||||
if fp:
|
||||
fp.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
self._initialized = False
|
||||
self._request_id = 0
|
||||
|
||||
def request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.request_with_retry(payload=payload, retries=0)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
res = self._request_jsonrpc("tools/list", {})
|
||||
if not bool(res.get("ok")):
|
||||
return res
|
||||
tools = self._normalize_tools(res.get("result"))
|
||||
return {"ok": True, "status": "ok", "tools_count": len(tools)}
|
||||
|
||||
def tools_list(self) -> dict[str, Any]:
|
||||
res = self._request_jsonrpc("tools/list", {})
|
||||
if not bool(res.get("ok")):
|
||||
return res
|
||||
return {"ok": True, "tools": self._normalize_tools(res.get("result"))}
|
||||
|
||||
def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
args = arguments if isinstance(arguments, dict) else {}
|
||||
res = self._request_jsonrpc("tools/call", {"name": str(tool_name or ""), "arguments": args})
|
||||
if not bool(res.get("ok")):
|
||||
return res
|
||||
return self._normalize_tool_call_result(res.get("result"))
|
||||
|
||||
def request_with_retry(self, payload: dict[str, Any], *, retries: int = 1) -> dict[str, Any]:
|
||||
tries = max(0, int(retries)) + 1
|
||||
last: dict[str, Any] = {"ok": False, "error_code": "mcp_runtime_failed", "error": "unknown"}
|
||||
for i in range(tries):
|
||||
self.start()
|
||||
ex = ThreadPoolExecutor(max_workers=1)
|
||||
fut = ex.submit(self._dispatch_request, payload)
|
||||
try:
|
||||
res = fut.result(timeout=max(0.1, float(self.timeout_s or 30.0)))
|
||||
except FuturesTimeoutError:
|
||||
try:
|
||||
fut.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self.stop()
|
||||
last = {"ok": False, "error_code": "mcp_runtime_timeout", "error": "request_timeout"}
|
||||
ex.shutdown(wait=False, cancel_futures=True)
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.stop()
|
||||
last = {"ok": False, "error_code": "mcp_runtime_request_failed", "error": f"{type(exc).__name__}: {exc}"}
|
||||
ex.shutdown(wait=False, cancel_futures=True)
|
||||
continue
|
||||
else:
|
||||
ex.shutdown(wait=False, cancel_futures=True)
|
||||
if bool(res.get("ok")):
|
||||
return res
|
||||
last = res
|
||||
if i + 1 < tries:
|
||||
self.stop()
|
||||
return last
|
||||
|
||||
def _dispatch_request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
op = str((payload or {}).get("op") or "").strip().lower()
|
||||
if op:
|
||||
res = self._dispatch_op_jsonrpc(payload)
|
||||
if bool(res.get("ok")):
|
||||
return res
|
||||
if str(res.get("error_code") or "").startswith("mcp_runtime_"):
|
||||
try:
|
||||
return self._exchange_legacy(payload)
|
||||
except Exception:
|
||||
return res
|
||||
return res
|
||||
method = str((payload or {}).get("method") or "").strip()
|
||||
if method:
|
||||
params = (payload or {}).get("params")
|
||||
return self._request_jsonrpc(method, params if isinstance(params, dict) else {})
|
||||
return self._exchange_legacy(payload)
|
||||
|
||||
def _request_jsonrpc(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
self.start()
|
||||
with self._lock:
|
||||
return self._jsonrpc_call_locked(method=method, params=params, skip_init=False)
|
||||
|
||||
def _jsonrpc_call_locked(self, *, method: str, params: dict[str, Any], skip_init: bool) -> dict[str, Any]:
|
||||
if not skip_init and not self._initialized:
|
||||
init_res = self._jsonrpc_call_locked(
|
||||
method="initialize",
|
||||
params={"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "ops-assistant", "version": "0.1.0"}},
|
||||
skip_init=True,
|
||||
)
|
||||
if not bool(init_res.get("ok")):
|
||||
return init_res
|
||||
self._jsonrpc_notify_locked("notifications/initialized", {})
|
||||
self._initialized = True
|
||||
p = self._proc
|
||||
if p is None or p.stdin is None or p.stdout is None:
|
||||
return {"ok": False, "error_code": "mcp_runtime_not_started", "error": "process_not_started"}
|
||||
self._request_id += 1
|
||||
rid = self._request_id
|
||||
req = {"jsonrpc": "2.0", "id": rid, "method": str(method), "params": params or {}}
|
||||
p.stdin.write(json.dumps(req, ensure_ascii=False) + "\n")
|
||||
p.stdin.flush()
|
||||
while True:
|
||||
line = p.stdout.readline()
|
||||
if not line:
|
||||
return {"ok": False, "error_code": "mcp_runtime_empty_response", "error": "empty_response"}
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error_code": "mcp_runtime_bad_json", "error": str(exc)}
|
||||
if not isinstance(obj, dict):
|
||||
return {"ok": False, "error_code": "mcp_runtime_invalid_payload", "error": "response_not_object"}
|
||||
if "jsonrpc" not in obj and "id" not in obj:
|
||||
return {"ok": False, "error_code": "mcp_runtime_protocol_mismatch", "error": "non_jsonrpc_response"}
|
||||
if obj.get("id") != rid:
|
||||
continue
|
||||
if isinstance(obj.get("error"), dict):
|
||||
err = obj.get("error") if isinstance(obj.get("error"), dict) else {}
|
||||
code = int(err.get("code") or 0)
|
||||
msg = str(err.get("message") or "jsonrpc_error")
|
||||
return {"ok": False, "error_code": f"mcp_rpc_error_{code}", "error": msg, "rpc_error": err}
|
||||
return {"ok": True, "result": obj.get("result"), "raw": obj}
|
||||
|
||||
def _jsonrpc_notify_locked(self, method: str, params: dict[str, Any]) -> None:
|
||||
p = self._proc
|
||||
if p is None or p.stdin is None:
|
||||
return
|
||||
req = {"jsonrpc": "2.0", "method": str(method), "params": params or {}}
|
||||
p.stdin.write(json.dumps(req, ensure_ascii=False) + "\n")
|
||||
p.stdin.flush()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tools(result: Any) -> list[dict[str, Any]]:
|
||||
row = result if isinstance(result, dict) else {}
|
||||
items = row.get("tools") if isinstance(row.get("tools"), list) else []
|
||||
out: list[dict[str, Any]] = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
name = str(it.get("name") or it.get("tool_name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
params = it.get("inputSchema")
|
||||
if not isinstance(params, dict):
|
||||
params = it.get("parameters")
|
||||
out.append({"tool_name": name, "description": str(it.get("description") or ""), "parameters": params if isinstance(params, dict) else {}})
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tool_call_result(result: Any) -> dict[str, Any]:
|
||||
row = result if isinstance(result, dict) else {"raw": result}
|
||||
if bool(row.get("isError")):
|
||||
content = row.get("content") if isinstance(row.get("content"), list) else []
|
||||
text = ""
|
||||
for it in content:
|
||||
if isinstance(it, dict) and str(it.get("type") or "") == "text":
|
||||
text = str(it.get("text") or "").strip()
|
||||
if text:
|
||||
break
|
||||
return {"ok": False, "error_code": "mcp_tool_call_failed", "error": text or "mcp_tool_call_failed", "result": row}
|
||||
return {"ok": True, "result": row, "data": row}
|
||||
|
||||
def _dispatch_op_jsonrpc(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
op = str((payload or {}).get("op") or "").strip().lower()
|
||||
if op == "tools/list":
|
||||
return self.tools_list()
|
||||
if op == "health":
|
||||
return self.health()
|
||||
if op == "call_tool":
|
||||
tool_name = str((payload or {}).get("tool_name") or "").strip()
|
||||
args = (payload or {}).get("arguments")
|
||||
return self.call_tool(tool_name=tool_name, arguments=args if isinstance(args, dict) else {})
|
||||
return {"ok": False, "error_code": "mcp_runtime_unsupported_op", "error": f"unsupported_op:{op}"}
|
||||
|
||||
def _exchange_legacy(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
p = self._proc
|
||||
if p is None or p.stdin is None or p.stdout is None:
|
||||
return {"ok": False, "error_code": "mcp_runtime_not_started", "error": "process_not_started"}
|
||||
req = json.dumps(payload, ensure_ascii=False) + "\n"
|
||||
with self._lock:
|
||||
p.stdin.write(req)
|
||||
p.stdin.flush()
|
||||
line = p.stdout.readline()
|
||||
if not line:
|
||||
return {"ok": False, "error_code": "mcp_runtime_empty_response", "error": "empty_response"}
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error_code": "mcp_runtime_bad_json", "error": str(exc)}
|
||||
if not isinstance(obj, dict):
|
||||
return {"ok": False, "error_code": "mcp_runtime_invalid_payload", "error": "response_not_object"}
|
||||
return obj
|
||||
Loading…
Add table
Add a link
Reference in a new issue