[codex] fix MCP management lifecycle (#1144)

* feat(mcp): add MCP server management UI

- Server CRUD: add/edit/remove with YAML/JSON Monaco editor
- raw_config passthrough: zero field loss on edit/toggle
- tool_details embedding: single-request card data (1+N → 1)
- Auto-retry exponential backoff (2s→32s, max 5 retries)
- Route safety guards (hasRoute) for dynamic sidebar
- i18n: 9 languages (de/en/es/fr/ja/ko/pt/zh/zh-TW)
- 19 unit tests + 8 UX browser tests
- 35 files, +2933 lines

* fix mcp management lifecycle

---------

Co-authored-by: Crafter-feng <succeed_happu@163.com>
This commit is contained in:
ekko
2026-05-30 11:06:08 +08:00
committed by GitHub
parent 675ddb8282
commit b015e70b9d
37 changed files with 2717 additions and 7 deletions
@@ -0,0 +1,117 @@
import type { Context } from 'koa'
import { bridgeMcpAction } from '../../services/hermes/mcp'
function getProfile(ctx: Context): string | undefined {
return (ctx.state as any)?.profile?.name || undefined
}
/** Validate server name: non-empty, no control chars, no path separators */
function isValidServerName(name: string): boolean {
if (!name || name.trim().length === 0) return false
if (name.length > 128) return false
// Reject path separators and control characters
if (/[/\\\x00-\x1f]/.test(name)) return false
return true
}
export async function listServers(ctx: Context) {
try {
ctx.body = await bridgeMcpAction('mcp_list', {}, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'MCP bridge not available' }
}
}
export async function addServer(ctx: Context) {
try {
const { name, config } = (ctx.request.body || {}) as Record<string, unknown>
if (typeof name !== 'string' || !isValidServerName(name)) {
ctx.status = 400
ctx.body = { error: 'Valid server name is required' }
return
}
if (!config || typeof config !== 'object') {
ctx.status = 400
ctx.body = { error: 'config object is required' }
return
}
ctx.body = await bridgeMcpAction('mcp_server_add', { name: name.trim(), config }, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to add MCP server' }
}
}
export async function updateServer(ctx: Context) {
try {
const name = ctx.params.name as string
const { config } = (ctx.request.body || {}) as Record<string, unknown>
if (!name || !isValidServerName(name)) {
ctx.status = 400
ctx.body = { error: 'Valid server name is required' }
return
}
if (!config || typeof config !== 'object') {
ctx.status = 400
ctx.body = { error: 'config object is required' }
return
}
ctx.body = await bridgeMcpAction('mcp_server_update', { name, config }, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to update MCP server' }
}
}
export async function removeServer(ctx: Context) {
try {
const name = ctx.params.name as string
if (!name || !isValidServerName(name)) {
ctx.status = 400
ctx.body = { error: 'Valid server name is required' }
return
}
ctx.body = await bridgeMcpAction('mcp_server_remove', { name }, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to remove MCP server' }
}
}
export async function testServer(ctx: Context) {
try {
const name = ctx.params.name as string
if (!name || !isValidServerName(name)) {
ctx.status = 400
ctx.body = { error: 'Valid server name is required' }
return
}
ctx.body = await bridgeMcpAction('mcp_server_test', { name }, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to test MCP server' }
}
}
export async function listTools(ctx: Context) {
try {
const server = ctx.query.server as string | undefined
const payload = server ? { server } : {}
ctx.body = await bridgeMcpAction('mcp_tools_list', payload, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'MCP bridge not available' }
}
}
export async function reloadMcp(ctx: Context) {
try {
const server = ctx.query.server as string | undefined
const payload = server ? { server } : {}
ctx.body = await bridgeMcpAction('mcp_reload', payload, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to reload MCP' }
}
}
+12
View File
@@ -0,0 +1,12 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/mcp'
export const mcpRoutes = new Router()
mcpRoutes.get('/api/hermes/mcp/servers', ctrl.listServers)
mcpRoutes.post('/api/hermes/mcp/servers', ctrl.addServer)
mcpRoutes.patch('/api/hermes/mcp/servers/:name', ctrl.updateServer)
mcpRoutes.delete('/api/hermes/mcp/servers/:name', ctrl.removeServer)
mcpRoutes.post('/api/hermes/mcp/servers/:name/test', ctrl.testServer)
mcpRoutes.get('/api/hermes/mcp/tools', ctrl.listTools)
mcpRoutes.post('/api/hermes/mcp/reload', ctrl.reloadMcp)
+2
View File
@@ -35,6 +35,7 @@ import { mediaRoutes } from './hermes/media'
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
import { performanceMonitorRoutes } from './hermes/performance-monitor'
import { mcpRoutes } from './hermes/mcp'
/**
* Register all routes on the Koa app.
@@ -80,6 +81,7 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne
app.use(kanbanRoutes.routes()) // Must be before proxy
app.use(mediaRoutes.routes()) // Must be before proxy
app.use(performanceMonitorRoutes.routes()) // Must be before proxy
app.use(mcpRoutes.routes()) // MCP management
app.use(proxyRoutes.routes())
// Proxy catch-all middleware (must be last)
@@ -5,6 +5,7 @@ import { URL } from 'url'
import { join } from 'path'
import { bridgeLogger } from '../../logger'
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
import type { McpActionResponse } from '../mcp-types'
function resolveDefaultAgentBridgeEndpoint(): string {
if (process.env.VITEST) {
@@ -585,6 +586,36 @@ export class AgentBridgeClient {
shutdown(): Promise<AgentBridgeResponse> {
return this.request({ action: 'shutdown' }, { serialize: true })
}
// ───── MCP Management ─────
mcpList(profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_list', ...(profile ? { profile } : {}) })
}
mcpAdd(name: string, config: Record<string, unknown>, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_server_add', name, config, ...(profile ? { profile } : {}) }, { serialize: true })
}
mcpUpdate(name: string, config: Record<string, unknown>, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_server_update', name, config, ...(profile ? { profile } : {}) }, { serialize: true })
}
mcpRemove(name: string, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_server_remove', name, ...(profile ? { profile } : {}) }, { serialize: true })
}
mcpTest(name: string, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_server_test', name, ...(profile ? { profile } : {}) }, { timeoutMs: 180_000 })
}
mcpTools(server?: string, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_tools_list', ...(server ? { server } : {}), ...(profile ? { profile } : {}) })
}
mcpReload(server?: string, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_reload', ...(server ? { server } : {}), ...(profile ? { profile } : {}) }, { serialize: true })
}
}
export default AgentBridgeClient
@@ -2297,8 +2297,345 @@ class BridgeServer:
self._stop.set()
return {"status": "shutting_down"}
# ───── MCP Management (forwarded from broker) ─────
if action.startswith("mcp_"):
return self._handle_mcp_action(action, req, req.get("profile"))
raise ValueError(f"unknown action: {action}")
# ───── MCP Management Methods (for BridgeServer worker process) ─────
def _read_mcp_config(self, profile=None):
"""Read config.yaml for the given profile."""
import yaml
config_path = _profile_home(profile) / "config.yaml"
try:
with open(config_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception:
return {}
def _save_mcp_config(self, cfg, profile=None):
"""Save config.yaml for the given profile using atomic write."""
import yaml
from utils import atomic_yaml_write
config_path = _profile_home(profile) / "config.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
try:
atomic_yaml_write(config_path, cfg, sort_keys=False)
except Exception as e:
raise RuntimeError(f"Failed to save config to {config_path}: {e}")
@staticmethod
def _run_mcp_discovery_bg(discover_fn, profile: str | None = None):
"""Run MCP discovery in a background thread to avoid blocking."""
def _bg():
original = _apply_profile_env(profile)
try:
discover_fn()
except Exception as e:
print(f"[mcp-discovery-bg] failed: {e}", file=sys.stderr, flush=True)
finally:
_restore_profile_env(original)
threading.Thread(target=_bg, daemon=True).start()
def _handle_mcp_action(self, action: str, req: dict[str, Any], profile: str | None = None) -> dict[str, Any]:
"""Handle MCP management actions in worker process."""
try:
from tools.mcp_tool import discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock
except ImportError:
return {"error": "MCP tool module not available", "ok": False}
if profile is None:
profile = _worker_profile() or "default"
dispatch = {
"mcp_list": lambda: self._mcp_list(profile, _servers, _lock),
"mcp_server_add": lambda: self._mcp_server_add(req, profile, discover_mcp_tools),
"mcp_server_update": lambda: self._mcp_server_update(req, profile, _servers, _lock, _run_on_mcp_loop, discover_mcp_tools),
"mcp_server_remove": lambda: self._mcp_server_remove(req, profile, _servers, _lock, _run_on_mcp_loop),
"mcp_server_test": lambda: self._mcp_server_test(req, _servers, _lock),
"mcp_tools_list": lambda: self._mcp_tools_list(req, profile, _servers, _lock),
"mcp_reload": lambda: self._mcp_reload(req, profile, _servers, _lock, _run_on_mcp_loop, discover_mcp_tools, register_mcp_servers),
}
handler = dispatch.get(action)
if handler:
return handler()
return {"error": f"unknown MCP action: {action}", "ok": False}
# ───── MCP sub-handlers ─────
def _build_server_entry(self, name: str, cfg: dict, connected: bool = False,
tools_count: int = 0, registered_count: int = 0,
raw_names: list | None = None, registered_names: list | None = None,
tool_details: list | None = None,
error: str | None = None) -> dict[str, Any]:
"""Build a normalized server entry dict for API responses."""
transport = "http" if cfg.get("url") else "stdio"
return {
"name": name,
"transport": transport,
"connected": connected,
"tools": tools_count,
"tools_registered": registered_count,
"tool_names": raw_names or [],
"tool_names_registered": registered_names or [],
"tool_details": tool_details or [],
"error": error,
"raw_config": cfg if isinstance(cfg, dict) else {},
}
def _mcp_list(self, profile: str, _servers, _lock) -> dict[str, Any]:
servers = []
total_tools = 0
config = self._read_mcp_config(profile)
mcp_configs = config.get("mcp_servers", {}) or {} if config else {}
profile_server_names = set(mcp_configs.keys())
with _lock:
server_snapshot = list(_servers.items())
for name, task in server_snapshot:
if name not in profile_server_names:
continue
raw_tool_names = []
try:
for mcp_tool in getattr(task, "_tools", []):
if hasattr(mcp_tool, "name"):
raw_tool_names.append(mcp_tool.name)
except Exception:
pass
registered = list(getattr(task, "_registered_tool_names", None) or [])
if not registered:
registered = list(raw_tool_names)
t = getattr(task, "_task", None)
connected = bool(t and not t.done())
err = getattr(task, "_error", None)
cfg = getattr(task, "_config", {})
# Build filtered tool_details (name + description) for card display
srv_cfg = mcp_configs.get(name, {}) if isinstance(mcp_configs.get(name), dict) else {}
tools_filter = srv_cfg.get("tools") or {}
include_set = set(tools_filter.get("include") or [])
exclude_set = set(tools_filter.get("exclude") or [])
tool_details = []
try:
for mcp_tool in getattr(task, "_tools", []):
tname = getattr(mcp_tool, "name", "?")
if include_set and tname not in include_set:
continue
if exclude_set and tname in exclude_set:
continue
tool_details.append({
"name": tname,
"description": getattr(mcp_tool, "description", ""),
})
except Exception:
pass
entry = self._build_server_entry(
name, cfg, connected=connected,
tools_count=len(raw_tool_names), registered_count=len(registered),
raw_names=raw_tool_names, registered_names=registered,
tool_details=tool_details,
error=str(err) if err else None,
)
servers.append(entry)
total_tools += len(registered)
# Add servers from config that are not in runtime _servers
if config:
existing = {s["name"] for s in servers}
for name, cfg in mcp_configs.items():
if name not in existing and isinstance(cfg, dict):
servers.append(self._build_server_entry(name, cfg))
return {"servers": servers, "total_tools": total_tools, "ok": True}
def _mcp_server_add(self, req: dict, profile: str, discover_mcp_tools) -> dict[str, Any]:
name = str(req.get("name") or "").strip()
config = req.get("config", {})
if not name or not isinstance(config, dict):
return {"error": "name and config are required", "ok": False}
cfg = self._read_mcp_config(profile)
if not cfg:
return {"error": "config.yaml not found", "ok": False}
mcp_servers = cfg.setdefault("mcp_servers", {})
if not isinstance(mcp_servers, dict):
mcp_servers = {}
cfg["mcp_servers"] = mcp_servers
if name in mcp_servers:
return {"error": f"server '{name}' already exists, use update instead", "ok": False}
mcp_servers[name] = config
self._save_mcp_config(cfg, profile)
self._run_mcp_discovery_bg(discover_mcp_tools, profile)
return {"ok": True, "name": name}
@staticmethod
def _shutdown_mcp_server(name: str, _servers, _lock, run_on_mcp_loop) -> bool:
with _lock:
task = _servers.get(name)
if task is None:
return False
try:
run_on_mcp_loop(lambda: task.shutdown(), timeout=15)
except Exception as e:
print(f"[mcp-server-shutdown] failed for {name}: {e}", file=sys.stderr, flush=True)
finally:
with _lock:
if _servers.get(name) is task:
_servers.pop(name, None)
return True
def _shutdown_mcp_servers(self, names: list[str], _servers, _lock, run_on_mcp_loop) -> int:
stopped = 0
for name in names:
if self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop):
stopped += 1
return stopped
def _mcp_server_update(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop, discover_mcp_tools) -> dict[str, Any]:
name = str(req.get("name") or "").strip()
config = req.get("config", {})
if not name or not isinstance(config, dict):
return {"error": "name and config are required", "ok": False}
cfg = self._read_mcp_config(profile)
if not cfg:
return {"error": "config.yaml not found", "ok": False}
mcp_servers = cfg.setdefault("mcp_servers", {})
if not isinstance(mcp_servers, dict):
mcp_servers = {}
cfg["mcp_servers"] = mcp_servers
if name not in mcp_servers:
return {"error": f"server \'{name}\' not found in config", "ok": False}
mcp_servers[name] = config
self._save_mcp_config(cfg, profile)
self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop)
self._run_mcp_discovery_bg(discover_mcp_tools, profile)
return {"ok": True}
def _mcp_server_remove(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop) -> dict[str, Any]:
name = str(req.get("name") or "").strip()
if not name:
return {"error": "name is required", "ok": False}
# Write config first, then remove from memory
cfg = self._read_mcp_config(profile)
if cfg:
mcp_servers = cfg.get("mcp_servers", {})
if isinstance(mcp_servers, dict) and name in mcp_servers:
del mcp_servers[name]
self._save_mcp_config(cfg, profile)
self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop)
return {"ok": True}
def _mcp_server_test(self, req: dict, _servers, _lock) -> dict[str, Any]:
name = str(req.get("name") or "").strip()
if not name:
return {"error": "name is required", "ok": False}
with _lock:
task = _servers.get(name)
if not task:
return {"error": f"server \'{name}\' is not connected", "ok": False}
tool_names = []
try:
for mcp_tool in getattr(task, "_tools", []):
if hasattr(mcp_tool, "name"):
tool_names.append(mcp_tool.name)
except Exception as e:
return {"error": f"failed to list tools: {e}", "ok": False}
return {"ok": True, "tools": tool_names}
def _mcp_tools_list(self, req: dict, profile: str, _servers, _lock) -> dict[str, Any]:
server_filter = str(req.get("server") or "").strip() or None
results = []
config = self._read_mcp_config(profile)
mcp_configs = config.get("mcp_servers", {}) or {} if config else {}
profile_server_names = set(mcp_configs.keys())
with _lock:
server_snapshot = list(_servers.items())
for sname, task in server_snapshot:
if sname not in profile_server_names:
continue
if server_filter and sname != server_filter:
continue
registered = set(getattr(task, "_registered_tool_names", None) or [])
tools = []
srv_cfg = mcp_configs.get(sname, {}) if isinstance(mcp_configs.get(sname), dict) else {}
tools_filter = srv_cfg.get("tools") or {}
include_set = set(tools_filter.get("include") or [])
exclude_set = set(tools_filter.get("exclude") or [])
def _should_include(tn):
if include_set:
return tn in include_set
if exclude_set:
return tn not in exclude_set
return True
try:
for mcp_tool in getattr(task, "_tools", []):
tname = getattr(mcp_tool, "name", "?")
if not _should_include(tname):
continue
tools.append({
"name": tname,
"description": getattr(mcp_tool, "description", ""),
"input_schema": getattr(mcp_tool, "inputSchema", {}),
})
except Exception as e:
results.append({"server": sname, "tools": [], "error": str(e)})
continue
results.append({"server": sname, "tools": tools})
return {"ok": True, "results": results}
def _mcp_reload(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop,
discover_mcp_tools, register_mcp_servers) -> dict[str, Any]:
target = str(req.get("server") or "").strip() or None
config = self._read_mcp_config(profile)
mcp_configs = config.get("mcp_servers", {}) or {} if config else {}
profile_server_names = set(mcp_configs.keys())
if target and target not in mcp_configs:
return {"error": "server \'%s\' not found in config" % target, "ok": False}
if target:
self._shutdown_mcp_server(target, _servers, _lock, run_on_mcp_loop)
else:
self._shutdown_mcp_servers(list(profile_server_names), _servers, _lock, run_on_mcp_loop)
# Run discovery in background to avoid blocking the request
if target:
def _reload_single():
original = _apply_profile_env(profile)
try:
server_config = {target: mcp_configs.get(target, {})}
register_mcp_servers(server_config)
finally:
_restore_profile_env(original)
self._run_mcp_discovery_bg(_reload_single, profile)
else:
self._run_mcp_discovery_bg(discover_mcp_tools, profile)
return {"ok": True, "message": "MCP servers reloaded"}
def _make_server_socket(self) -> socket.socket:
return _make_listen_socket(self.endpoint)
@@ -2829,9 +3166,13 @@ class BridgeBroker:
forwarded = dict(req)
forwarded["profile"] = profile
forwarded.pop("worker_key", None)
resp = worker.request(forwarded, self._worker_request_timeout(req))
self._record_response_routes(profile, key, resp)
return resp
try:
resp = worker.request(forwarded, self._worker_request_timeout(req))
self._record_response_routes(profile, key, resp)
return resp
except RuntimeError as e:
# Worker returned ok=false or connection error — return error response
return {"ok": False, "error": str(e)}
def _worker_request_timeout(self, req: dict[str, Any]) -> float:
try:
@@ -3037,6 +3378,11 @@ class BridgeBroker:
self.stop()
return {"status": "shutting_down"}
# ───── MCP Management ─────
if action.startswith("mcp_"):
profile = self._normalize_profile(req.get("profile"))
return self._forward(profile, req)
raise ValueError(f"unknown action: {action}")
def _make_server_socket(self) -> socket.socket:
@@ -0,0 +1,67 @@
/**
* Shared MCP types used by both the bridge client and the service layer.
*/
export interface McpServerEntry {
name: string
transport: string
connected: boolean
tools: number
tools_registered: number
tool_names: string[]
tool_names_registered: string[]
error?: string | null
command?: string
args?: string[]
url?: string
env?: Record<string, string>
headers?: Record<string, string>
tools_config?: { include?: string[]; exclude?: string[] }
prompts?: boolean
resources?: boolean
enabled?: boolean
}
export interface McpToolEntry {
name: string
description: string
input_schema: Record<string, unknown>
}
export interface McpActionResult {
ok: boolean
error?: string
}
export interface McpListResponse extends McpActionResult {
servers: McpServerEntry[]
total_tools: number
}
export interface McpAddResponse extends McpActionResult {
name?: string
}
export interface McpTestResponse extends McpActionResult {
tools?: string[]
}
export interface McpToolsListResponse extends McpActionResult {
results?: Array<{ server: string; tools: McpToolEntry[] }>
}
export interface McpReloadResponse extends McpActionResult {
message?: string
}
/**
* Union of all MCP action responses.
* Bridge client methods return this; controllers narrow by action.
*/
export type McpActionResponse =
| McpListResponse
| McpAddResponse
| McpTestResponse
| McpToolsListResponse
| McpReloadResponse
| McpActionResult
@@ -0,0 +1,67 @@
import { AgentBridgeClient } from './agent-bridge/client'
import type { McpActionResponse } from './mcp-types'
export type { McpServerEntry, McpActionResponse } from './mcp-types'
let bridgeClient: AgentBridgeClient | null = null
export function getBridgeClient(): AgentBridgeClient {
if (!bridgeClient) {
bridgeClient = new AgentBridgeClient()
}
return bridgeClient
}
/**
* Send an MCP action to the AgentBridge using typed client methods.
*/
export async function bridgeMcpAction(
action: string,
payload: Record<string, unknown> = {},
profile?: string
): Promise<McpActionResponse> {
const client = getBridgeClient()
let raw: McpActionResponse
switch (action) {
case 'mcp_list':
raw = await client.mcpList(profile)
break
case 'mcp_server_add': {
const addName = String(payload.name || '')
const addConfig = payload.config as Record<string, unknown> | undefined
if (!addName || !addConfig) throw new Error('name and config are required')
raw = await client.mcpAdd(addName, addConfig, profile)
break
}
case 'mcp_server_update': {
const updName = String(payload.name || '')
const updConfig = payload.config as Record<string, unknown> | undefined
if (!updName || !updConfig) throw new Error('name and config are required')
raw = await client.mcpUpdate(updName, updConfig, profile)
break
}
case 'mcp_server_remove': {
const rmName = String(payload.name || '')
if (!rmName) throw new Error('name is required')
raw = await client.mcpRemove(rmName, profile)
break
}
case 'mcp_server_test': {
const testName = String(payload.name || '')
if (!testName) throw new Error('name is required')
raw = await client.mcpTest(testName, profile)
break
}
case 'mcp_tools_list':
raw = await client.mcpTools(payload.server as string | undefined, profile)
break
case 'mcp_reload':
raw = await client.mcpReload(payload.server as string | undefined, profile)
break
default:
throw new Error(`Unknown MCP action: ${action}`)
}
return raw
}
@@ -22,6 +22,7 @@ type CommandName =
| 'compress'
| 'steer'
| 'destroy'
| 'reload-mcp'
interface ParsedSessionCommand {
name: CommandName
@@ -57,6 +58,7 @@ const COMMAND_ALIASES: Record<string, CommandName> = {
steer: 'steer',
destroy: 'destroy',
destory: 'destroy',
'reload-mcp': 'reload-mcp',
}
export function parseSessionCommand(input: string | ContentBlock[]): ParsedSessionCommand | null {
@@ -475,6 +477,35 @@ export async function handleSessionCommand(
return
}
case 'reload-mcp': {
if (state.isWorking) {
emitCommand({
ok: false,
action: 'reload-mcp',
terminal: false,
message: 'MCP reload can only run while the session is idle. Wait for the current run to finish or abort it first.',
})
return
}
try {
const server = command.args || undefined
const result = await ctx.bridge.mcpReload(server, ctx.profile)
emitCommand({
action: 'reload-mcp',
message: `MCP reloaded successfully.${server ? ` Server: ${server}` : ' All servers.'}`,
result,
})
} catch (err) {
emitCommand({
ok: false,
action: 'reload-mcp',
terminal: !state.isWorking,
message: `MCP reload failed: ${err instanceof Error ? err.message : String(err)}`,
})
}
return
}
case 'destroy': {
const wasWorking = state.isWorking
let bridgeReachable = true