[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:
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user