fix: synchronize MCP clients on launch

This commit is contained in:
chenmingxuan committed 2026-09-24 12:07:42 +08:00
1 parent 8522fa3ca3
commit 0e13bb5c29
11 files changed
+475 -124

No files matched your search

+11 -4
View File
@@ -18,6 +18,7 @@ param(
[switch]$NoOpen = $false,
[switch]$SetupOnly = $false,
[switch]$SkipUpdate = $false,
[switch]$SkipMcpSync = $false,
[ValidateSet('Official', 'China')][string]$Mirror
)
@@ -50,6 +51,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
if ($NoOpen) { $reloadArgs += '-NoOpen' }
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
& powershell.exe @reloadArgs
exit $LASTEXITCODE
@@ -353,10 +355,15 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green
}
}
Write-Host (Get-LaunchText ' [CHECK] Refreshing already-configured MCP clients...' ' [检查] 正在检查并更新已有的 MCP 客户端配置...') -ForegroundColor Cyan
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
if ($LASTEXITCODE -ne 0) {
Write-Host (Get-LaunchText ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' ' [提示] MCP 配置更新失败,原配置仍保留;请查看上方错误。') -ForegroundColor Yellow
if (-not $SkipMcpSync) {
Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
if ($LASTEXITCODE -ne 0) {
Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
exit 1
}
} else {
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
}
if ($SetupOnly) {
Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
+280 -102
View File
@@ -1,4 +1,4 @@
"""Check an existing checkout and refresh only already-configured local MCP clients."""
"""Check an existing checkout and synchronize installed local MCP clients."""
from __future__ import annotations
@@ -140,10 +140,10 @@ def update_source(root: Path, port: int = 8000) -> int:
def _atomic_write(path: Path, text: str) -> None:
if path.read_text(encoding="utf-8") == text:
if path.is_file() and path.read_text(encoding="utf-8") == text:
return
backup = path.with_name(path.name + ".artemis-update.bak")
if not backup.exists():
if path.is_file() and not backup.exists():
shutil.copy2(path, backup)
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
try:
@@ -154,87 +154,120 @@ def _atomic_write(path: Path, text: str) -> None:
Path(name).unlink(missing_ok=True)
def _belongs_to_project(config: dict, root: Path) -> bool:
if config.get("url"):
def _is_standard_local_artemis(config: dict) -> bool:
if config.get("url") or config.get("type") not in (None, "stdio"):
return False
args = config.get("args", [])
if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]):
# A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone.
return False
candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")]
for value in candidates:
if isinstance(value, str) and Path(value).resolve() == root.resolve():
return True
command = config.get("command")
return (
isinstance(command, str)
and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve()
)
# Running this launcher selects this checkout, including after a user moves
# a release ZIP to a new directory. Do not take over custom wrappers/SSE.
return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"])
def _tool_updates(config: dict, names: tuple[str, ...]) -> dict:
updates = {}
if "enabled_tools" in config:
updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names]))
if isinstance(config.get("tools"), dict):
updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]}
return updates
def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict:
updated = {**config, "command": template["command"], "args": template["args"]}
if "cwd" in template or "cwd" in config:
updated["cwd"] = str(root)
environment = config.get("env", {})
if not isinstance(environment, dict):
raise ValueError("The existing Artemis MCP environment is not an object")
updated["env"] = {**environment, **template["env"]}
if "enabled_tools" in template or "enabled_tools" in config:
updated["enabled_tools"] = list(names)
if "tools" in template or "tools" in config:
current_tools = config.get("tools", {})
if not isinstance(current_tools, dict):
raise ValueError("The existing Artemis MCP tools setting is not an object")
updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names}
for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"):
if key in template and key not in updated:
updated[key] = template[key]
return updated
def refresh_json(
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...]
) -> bool:
if not path.is_file():
path: Path,
keys: tuple[str, ...],
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8")
raw = path.read_text(encoding="utf-8") if path.is_file() else "{}"
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("MCP configuration must be a JSON object")
parent = data
for key in keys:
parent = parent.get(key, {})
child = parent.get(key)
if child is None and install_missing:
child = parent[key] = {}
if not isinstance(child, dict):
raise ValueError(f"Invalid MCP configuration section: {key}")
parent = child
config = parent.get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False
updated = {**config, "command": python, **_tool_updates(config, names)}
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"env": {"PYTHONPATH": str(root)},
}
updated = _updated_entry(config, template, root, names)
elif config is not None and not isinstance(config, dict):
raise ValueError("The Artemis MCP configuration is not an object")
else:
return None
if updated == config:
return False
parent["artemis"] = updated
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
return True
def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool:
if not path.is_file():
return False
raw = path.read_text(encoding="utf-8")
config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False
wanted = {"command": python, **_tool_updates(config, names)}
wanted = {key: value for key, value in wanted.items() if config.get(key) != value}
if not wanted:
return False
def _toml_value(value: object) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return json.dumps(value, ensure_ascii=False)
def _toml_inline_table(values: dict) -> str:
return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }"
def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str:
"""Update only Artemis TOML tables; preserve every unrelated section and setting."""
lines = raw.splitlines(keepends=True)
inside = False
found = set()
output = []
output: list[str] = []
section = ""
found: dict[str, set[str]] = {name: set() for name in assignments}
seen: set[str] = set()
def append_missing():
for key, value in wanted.items():
if key not in found:
output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n")
found.add(key)
def append_missing() -> None:
if section in assignments:
for key, value in assignments[section].items():
if key not in found[section]:
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
# Replace complete assignment spans, including multiline arrays, using TOML parsing.
index = 0
while index < len(lines):
line = lines[index]
if line.lstrip().startswith("["):
if inside:
append_missing()
append_missing()
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
inside = header == "[mcp_servers.artemis]"
match = re.match(r"\s*(\w+)\s*=", line) if inside else None
if match and match[1] in wanted:
section = header[1:-1] if header.startswith("[") and header.endswith("]") else ""
seen.add(section)
match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None
if match and match[1] in assignments[section]:
key = match[1]
original = line
while True:
@@ -246,91 +279,236 @@ def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -
if index >= len(lines):
raise ValueError("Cannot safely parse existing MCP setting")
original += lines[index]
output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n")
found.add(key)
value = assignments[section][key]
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
else:
output.append(line)
index += 1
if inside:
append_missing()
updated = "".join(output)
parsed = tomllib.loads(updated)
if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()):
raise ValueError("Could not locate the existing Artemis table safely")
_atomic_write(path, updated)
return updated != raw
append_missing()
for name, values in assignments.items():
if name not in seen:
if output and not output[-1].endswith("\n"):
output.append("\n")
output.append(f"\n[{name}]\n")
for key, value in values.items():
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
return "".join(output)
def refresh_mcp(root: Path) -> None:
def refresh_codex(
path: Path,
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8") if path.is_file() else ""
parsed = tomllib.loads(raw)
config = parsed.get("mcp_servers", {}).get("artemis")
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"cwd": str(root),
"env": {"PYTHONPATH": str(root)},
"enabled_tools": list(names),
}
updated = _updated_entry(config, template, root, names)
else:
return None
top = {
key: value
for key, value in updated.items()
if key != "env" and (not config or config.get(key) != value)
}
environment = updated["env"]
if config and config.get("env") == environment:
env_updates = {}
else:
env_updates = {
key: value
for key, value in environment.items()
if not config or config.get("env", {}).get(key) != value
}
env_header = "mcp_servers.artemis.env"
has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None
if config and "env" in config and not has_env_table and env_updates:
top["env"] = environment
env_updates = {}
assignments = {"mcp_servers.artemis": top}
if env_updates:
assignments[env_header] = env_updates
if not top and not env_updates:
return False
result = _replace_toml_tables(raw, assignments)
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
if any(actual.get(key) != value for key, value in top.items()) or any(
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
):
raise ValueError("Could not safely refresh the Artemis MCP table")
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, result)
return result != raw
def refresh_mcp(root: Path) -> int:
sys.path.insert(0, str(root))
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir
from artemis.interfaces.cli.commands.mcp import (
ARTEMIS_MCP_TOOLS,
_get_config_snippet,
_get_vscode_user_dir,
)
home = Path.home()
vscode = _get_vscode_user_dir()
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
paths = [
(home / ".gemini/config/mcp_config.json", ("mcpServers",)),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",)),
(home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)),
(home / ".cursor/mcp.json", ("mcpServers",)),
(home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)),
(home / ".claude.json", ("mcpServers",)),
(appdata / "Claude/claude_desktop_config.json", ("mcpServers",)),
(vscode / "mcp.json", ("servers",)),
(home / ".copilot/mcp-config.json", ("servers",)),
(home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)),
(home / ".cline/mcp.json", ("mcpServers",)),
(home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
(
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
home / ".gemini/antigravity/mcp_config.json",
("mcpServers",),
"antigravity",
home / ".gemini",
),
(home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"),
(
home / ".codeium/windsurf/mcp_config.json",
("mcpServers",),
"windsurf",
home / ".codeium/windsurf",
),
(home / ".claude.json", ("mcpServers",), "claude", home / ".claude"),
(
appdata / "Claude/claude_desktop_config.json",
("mcpServers",),
"claude",
appdata / "Claude",
),
(vscode / "mcp.json", ("servers",), "vscode", vscode),
(home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"),
(
home / ".cline/data/settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
home / ".cline",
),
(home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"),
(
cline_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
cline_store,
),
(
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
roo_store / "settings/mcp_settings.json",
("mcpServers",),
"roo",
roo_store,
),
(
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json",
roo_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"roo",
roo_store,
),
(root / ".roo/mcp.json", ("mcpServers",)),
(home / ".openclaw/openclaw.json", ("mcp", "servers")),
(root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"),
(home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"),
]
changed = 0
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml"
for path, keys in [*paths, (codex, ())]:
codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex"))
paths.append((codex_home / "config.toml", (), "codex", codex_home))
installed = updated_count = ready = skipped = errors = 0
for path, keys, client, marker in paths:
if not path.is_file() and not marker.is_dir():
continue
existed = path.is_file()
template = _get_config_snippet(client, sys.executable, str(root))
for key in keys:
template = template[key]
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
try:
updated = (
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS)
changed = (
refresh_json(
path,
keys,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
if keys
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS)
else refresh_codex(
path,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
)
if updated:
if changed:
print(
launch_text(
f"[UPDATED] Existing MCP configuration: {path}",
f"[已更新] 已有 MCP 配置:{path}",
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
)
)
changed += 1
if existed:
updated_count += 1
else:
installed += 1
elif changed is None:
print(
launch_text(
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
)
)
skipped += 1
else:
ready += 1
except (OSError, ValueError, TypeError, AttributeError):
print(
launch_text(
f"[WARN] Could not safely refresh {path}; original configuration preserved.",
f"[提示] 无法安全更新 {path};原配置已保留。",
f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
f"[错误] 无法安全同步 {path};原配置已保留。",
)
)
errors += 1
print(
launch_text(
f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update.",
f"[完成] MCP 源码已就绪;更新了 {changed} 个已有客户端配置。源码升级后请在 AI 客户端重新加载 MCP。",
f"[CHECK] MCP clients: installed {installed}, updated {updated_count}, already current {ready}, custom skipped {skipped}, errors {errors}.",
f"[检查] MCP 客户端:新装 {installed} 个、更新 {updated_count} 个、已是最新 {ready} 个、自定义跳过 {skipped} 个、失败 {errors} 个。",
)
)
print(
launch_text(
"[TIP] For a new client run: uv run artemis mcp --install <client>",
"[提示] 新客户端可执行:uv run artemis mcp --install <client>",
"[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
"[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
)
)
if not (installed or updated_count or ready or skipped or errors):
print(
launch_text(
"[TIP] No supported client detected; install one with: uv run artemis mcp --install <client>",
"[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install <client>。",
)
)
return 1 if errors else 0
def frontend_fingerprint(root: Path) -> str:
@@ -362,7 +540,7 @@ if __name__ == "__main__":
if args.action == "source":
sys.exit(update_source(args.root, args.port))
elif args.action == "mcp":
refresh_mcp(args.root)
sys.exit(refresh_mcp(args.root))
else:
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
current = frontend_fingerprint(args.root)