feat: add guided exploration and share user-started MCP backend

This commit is contained in:
chenmingxuan committed 2026-10-16 18:32:46 +08:00
1 parent f86db1ced6
commit ee256ad7ce
144 files changed
+8625 -2075

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
"""Lightweight, local discovery of user-started services. Never starts processes.
This module deliberately avoids config/runtime imports: it is also used by the
stdio MCP bridge, which must not load agents, device controllers or model SDKs.
"""
from __future__ import annotations
import hashlib
import http.client
import json
import os
from pathlib import Path
import tempfile
from typing import Any
import urllib.error
import urllib.request
from filelock import FileLock, Timeout
import psutil
PROTOCOL_VERSION = 1
ROOT = Path(__file__).resolve().parent.parent
MCP_PATH = "/api/mcp/"
TOKEN_HEADER = "X-Artemis-MCP-Token"
class ServiceUnavailableError(RuntimeError):
"""The user must start/select a compatible Artemis service."""
def canonical_path(path: str | Path) -> str:
return os.path.normcase(str(Path(path).expanduser().resolve()))
def installation_id(root: Path = ROOT) -> str:
return hashlib.sha256(canonical_path(root).encode()).hexdigest()[:24]
def registry_directory(root: Path = ROOT) -> Path:
user_id = hashlib.sha256(canonical_path(Path.home()).encode()).hexdigest()[:16]
return Path(tempfile.gettempdir()) / "artemis-services" / user_id / installation_id(root)
def _alive(pid: Any) -> bool:
return isinstance(pid, int) and pid > 0 and psutil.pid_exists(pid)
def _selected(info: dict, host: str | None, port: int | None) -> bool:
if host is not None and host not in {"localhost", "127.0.0.1", "::1"}:
return False
if port is not None and info.get("port") != port:
return False
app_dir = os.getenv("ARTEMIS_APP_DIR") or os.getenv("ANTIGRAVITY_APP_DIR")
traces_dir = os.getenv("ARTEMIS_TRACES_DIR")
return not (
(app_dir and canonical_path(app_dir) != info.get("app_dir"))
or (traces_dir and canonical_path(traces_dir) != info.get("traces_dir"))
)
def probe_service(info: dict, timeout: float = 1.5) -> bool:
port = info.get("port")
if not isinstance(port, int) or not 0 < port < 65536 or not _alive(info.get("pid")):
return False
token = info.get("token")
if not isinstance(token, str) or not token:
return False
request = urllib.request.Request(
f"http://127.0.0.1:{port}/api/system/shared-service",
headers={TOKEN_HEADER: token},
)
try:
# A local service connection must not go through the user's HTTP proxy.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(request, timeout=timeout) as response:
result = json.loads(response.read())
except (OSError, ValueError, urllib.error.URLError, http.client.HTTPException):
return False
return (
isinstance(result, dict)
and result.get("protocol") == PROTOCOL_VERSION
and result.get("pid") == info.get("pid")
and result.get("installation") == info.get("installation")
and result.get("instance") == info.get("instance")
)
def discover_service(root: Path = ROOT) -> dict:
"""Choose one live service in this installation, respecting explicit scope.
An unspecified data directory follows the sole running instance (including a
custom web port). Ambiguous instances require an explicit daemon port; we never
guess or cross installation boundaries.
"""
host = os.getenv("ARTEMIS_DAEMON_HOST")
try:
port = int(os.environ["ARTEMIS_DAEMON_PORT"]) if os.getenv("ARTEMIS_DAEMON_PORT") else None
except ValueError as exc:
raise ServiceUnavailableError("ARTEMIS_DAEMON_PORT 必须是有效端口号。") from exc
candidates = []
for path in registry_directory(root).glob("*.json"):
try:
info = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if (
isinstance(info, dict)
and info.get("installation") == installation_id(root)
and _selected(info, host, port)
and probe_service(info)
):
candidates.append(info)
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
raise ServiceUnavailableError(
"检测到多个 ARTEMIS 数据空间,请设置 ARTEMIS_DAEMON_PORT 选择要连接的网页服务。"
)
raise ServiceUnavailableError(
"未找到可连接的 ARTEMIS 主程序。请先运行 start.bat / start-cn.bat 或 artemis ui;"
"若网页已启动,请确认使用同一安装目录的新版本,并重新启动网页服务。"
"MCP 仅连接主程序,不会自动启动后台或独立执行任务。"
)
class ServiceRegistration:
"""One server owner per installation and trace directory, released by the OS."""
def __init__(
self, *, port: int, token: str, app_dir: Path, traces_dir: Path, root: Path = ROOT
):
import uuid
directory = registry_directory(root)
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
scope = hashlib.sha256(canonical_path(traces_dir).encode()).hexdigest()[:24]
self.lock = FileLock(directory / f"{scope}.lock", thread_local=False)
self.path = directory / f"{scope}.json"
self.info = {
"protocol": PROTOCOL_VERSION,
"installation": installation_id(root),
"instance": uuid.uuid4().hex,
"pid": os.getpid(),
"port": port,
"token": token,
"app_dir": canonical_path(app_dir),
"traces_dir": canonical_path(traces_dir),
}
def acquire(self) -> None:
try:
self.lock.acquire(timeout=0)
except Timeout as exc:
raise ServiceUnavailableError(
"此 ARTEMIS 数据目录已有主程序运行,请使用现有网页服务;不会启动第二份后台。"
) from exc
try:
self.path.write_text(json.dumps(self.info), encoding="utf-8")
self.path.chmod(0o600)
except OSError:
self.lock.release()
raise
def close(self) -> None:
if self.lock.is_locked:
try:
self.path.unlink(missing_ok=True)
finally:
self.lock.release()