重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。

本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-25 01:24:23 +08:00
parent ba3836f00f
commit 4a23b715a2
498 changed files with 2760 additions and 2200 deletions

View 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",
]

View 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",
]

View 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",
]

View 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"]

View 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
View 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",
]

View 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"]

View 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