feat: PATCH /api/mcp/servers/{name} — enable/disable toggle
Add `PATCH /api/mcp/servers/{name}` endpoint that accepts `{"enabled": bool}`,
updates `mcp_servers.<name>.enabled` in config.yaml, and calls `reload_config()`.
Mirrors the existing DELETE pattern.
Also wire the previously-defined-but-unrouted `_handle_mcp_server_delete` into
`handle_delete`, and `_handle_mcp_server_update` into a new `handle_put` +
`do_PUT` in server.py — fixing a pre-existing bug where those handlers existed
but were never reachable over HTTP.
UI: add a toggle button in each MCP server row in the system settings panel
(panels.js). Clicking it calls PATCH and reloads the list. Toggle button is
styled with `.mcp-toggle-enabled` / `.mcp-toggle-disabled` CSS classes. The
`toggle_supported` flag in the list response is now `True`.
i18n: add 5 new keys (`mcp_enable_server`, `mcp_disable_server`,
`mcp_enabled_toast`, `mcp_disabled_toast`, `mcp_toggle_failed`) to all 9
non-English locales (English values as placeholder translations).
Tests: add `TestMcpToggle` class with 7 tests covering disable, enable,
404-not-found, empty name, missing field, response payload, and URL-encoded name.
Update `test_empty_config` and visibility panel assertions to reflect
`toggle_supported: True` and the new toggle button in panels.js.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
a290af61b8
commit
7be9a26018
7 files changed
+201
-7
No files matched your search
+42
-1
@@ -6229,6 +6229,9 @@ def handle_patch(handler, parsed) -> bool:
|
|||||||
if not _check_csrf(handler):
|
if not _check_csrf(handler):
|
||||||
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
|
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
|
||||||
body = read_body(handler)
|
body = read_body(handler)
|
||||||
|
if parsed.path.startswith("/api/mcp/servers/"):
|
||||||
|
name = parsed.path[len("/api/mcp/servers/"):]
|
||||||
|
return _handle_mcp_server_toggle(handler, name, body)
|
||||||
if parsed.path.startswith("/api/kanban/"):
|
if parsed.path.startswith("/api/kanban/"):
|
||||||
from api.kanban_bridge import handle_kanban_patch
|
from api.kanban_bridge import handle_kanban_patch
|
||||||
|
|
||||||
@@ -6244,6 +6247,9 @@ def handle_delete(handler, parsed) -> bool:
|
|||||||
if not _check_csrf(handler):
|
if not _check_csrf(handler):
|
||||||
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
|
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
|
||||||
body = read_body(handler)
|
body = read_body(handler)
|
||||||
|
if parsed.path.startswith("/api/mcp/servers/"):
|
||||||
|
name = parsed.path[len("/api/mcp/servers/"):]
|
||||||
|
return _handle_mcp_server_delete(handler, name)
|
||||||
if parsed.path.startswith("/api/kanban/"):
|
if parsed.path.startswith("/api/kanban/"):
|
||||||
from api.kanban_bridge import handle_kanban_delete
|
from api.kanban_bridge import handle_kanban_delete
|
||||||
|
|
||||||
@@ -6253,6 +6259,17 @@ def handle_delete(handler, parsed) -> bool:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def handle_put(handler, parsed) -> bool:
|
||||||
|
"""Handle all PUT routes. Returns True if handled, False for 404."""
|
||||||
|
if not _check_csrf(handler):
|
||||||
|
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
|
||||||
|
body = read_body(handler)
|
||||||
|
if parsed.path.startswith("/api/mcp/servers/"):
|
||||||
|
name = parsed.path[len("/api/mcp/servers/"):]
|
||||||
|
return _handle_mcp_server_update(handler, name, body)
|
||||||
|
return False
|
||||||
|
|
||||||
# ── GET route helpers ─────────────────────────────────────────────────────────
|
# ── GET route helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# MIME types for static file serving. Hoisted to module scope to avoid
|
# MIME types for static file serving. Hoisted to module scope to avoid
|
||||||
@@ -11888,7 +11905,7 @@ def _handle_mcp_servers_list(handler):
|
|||||||
]
|
]
|
||||||
return j(handler, {
|
return j(handler, {
|
||||||
"servers": result,
|
"servers": result,
|
||||||
"toggle_supported": False,
|
"toggle_supported": True,
|
||||||
"reload_required": True,
|
"reload_required": True,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -11912,6 +11929,30 @@ def _handle_mcp_server_delete(handler, name):
|
|||||||
return j(handler, {"ok": True, "deleted": name})
|
return j(handler, {"ok": True, "deleted": name})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_mcp_server_toggle(handler, name, body):
|
||||||
|
"""Toggle enabled state for an MCP server (PATCH /api/mcp/servers/{name})."""
|
||||||
|
from urllib.parse import unquote
|
||||||
|
name = unquote(name)
|
||||||
|
if not name:
|
||||||
|
return bad(handler, "name is required")
|
||||||
|
if "enabled" not in body:
|
||||||
|
return bad(handler, "enabled field is required")
|
||||||
|
enabled = bool(body["enabled"])
|
||||||
|
cfg = get_config()
|
||||||
|
servers = cfg.get("mcp_servers", {})
|
||||||
|
if not isinstance(servers, dict):
|
||||||
|
servers = {}
|
||||||
|
if name not in servers:
|
||||||
|
return bad(handler, f"MCP server '{name}' not found", 404)
|
||||||
|
if not isinstance(servers[name], dict):
|
||||||
|
return bad(handler, f"MCP server '{name}' has invalid config", 400)
|
||||||
|
servers[name]["enabled"] = enabled
|
||||||
|
cfg["mcp_servers"] = servers
|
||||||
|
_save_yaml_config_file(_get_config_path(), cfg)
|
||||||
|
reload_config()
|
||||||
|
return j(handler, {"ok": True, "name": name, "enabled": enabled})
|
||||||
|
|
||||||
|
|
||||||
_MASKED_PLACEHOLDER = "••••••"
|
_MASKED_PLACEHOLDER = "••••••"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ from api.auth import check_auth
|
|||||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||||
from api.helpers import j, get_profile_cookie
|
from api.helpers import j, get_profile_cookie
|
||||||
from api.profiles import set_request_profile, clear_request_profile
|
from api.profiles import set_request_profile, clear_request_profile
|
||||||
from api.routes import handle_delete, handle_get, handle_patch, handle_post
|
from api.routes import handle_delete, handle_get, handle_patch, handle_post, handle_put
|
||||||
from api.startup import auto_install_agent_deps, fix_credential_permissions
|
from api.startup import auto_install_agent_deps, fix_credential_permissions
|
||||||
from api.updates import WEBUI_VERSION
|
from api.updates import WEBUI_VERSION
|
||||||
|
|
||||||
@@ -286,6 +286,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
self._handle_write(handle_post)
|
self._handle_write(handle_post)
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
self._handle_write(handle_put)
|
||||||
|
|
||||||
def do_PATCH(self) -> None:
|
def do_PATCH(self) -> None:
|
||||||
self._handle_write(handle_patch)
|
self._handle_write(handle_patch)
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} tools',
|
mcp_tool_count: '{0} tools',
|
||||||
mcp_enabled_yes: 'Enabled',
|
mcp_enabled_yes: 'Enabled',
|
||||||
mcp_enabled_no: 'Disabled',
|
mcp_enabled_no: 'Disabled',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'MCP Tools',
|
mcp_tools_title: 'MCP Tools',
|
||||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||||
@@ -1325,6 +1330,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} strumenti',
|
mcp_tool_count: '{0} strumenti',
|
||||||
mcp_enabled_yes: 'Abilitato',
|
mcp_enabled_yes: 'Abilitato',
|
||||||
mcp_enabled_no: 'Disabilitato',
|
mcp_enabled_no: 'Disabilitato',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'Strumenti MCP',
|
mcp_tools_title: 'Strumenti MCP',
|
||||||
mcp_tools_desc: 'Cerca strumenti noti tra i server MCP attivi.',
|
mcp_tools_desc: 'Cerca strumenti noti tra i server MCP attivi.',
|
||||||
mcp_tools_search_placeholder: 'Cerca strumenti per nome, server o descrizione…',
|
mcp_tools_search_placeholder: 'Cerca strumenti per nome, server o descrizione…',
|
||||||
@@ -2555,6 +2565,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} 個のツール',
|
mcp_tool_count: '{0} 個のツール',
|
||||||
mcp_enabled_yes: '有効',
|
mcp_enabled_yes: '有効',
|
||||||
mcp_enabled_no: '無効',
|
mcp_enabled_no: '無効',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'MCP ツール',
|
mcp_tools_title: 'MCP ツール',
|
||||||
mcp_tools_desc: 'アクティブな MCP サーバー全体から既知のツールを検索します。',
|
mcp_tools_desc: 'アクティブな MCP サーバー全体から既知のツールを検索します。',
|
||||||
mcp_tools_search_placeholder: '名前、サーバー、説明でツールを検索…',
|
mcp_tools_search_placeholder: '名前、サーバー、説明でツールを検索…',
|
||||||
@@ -3787,6 +3802,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} tools',
|
mcp_tool_count: '{0} tools',
|
||||||
mcp_enabled_yes: 'Enabled',
|
mcp_enabled_yes: 'Enabled',
|
||||||
mcp_enabled_no: 'Disabled',
|
mcp_enabled_no: 'Disabled',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'MCP Tools',
|
mcp_tools_title: 'MCP Tools',
|
||||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||||
@@ -4951,6 +4971,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} tools',
|
mcp_tool_count: '{0} tools',
|
||||||
mcp_enabled_yes: 'Enabled',
|
mcp_enabled_yes: 'Enabled',
|
||||||
mcp_enabled_no: 'Disabled',
|
mcp_enabled_no: 'Disabled',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'MCP Tools',
|
mcp_tools_title: 'MCP Tools',
|
||||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||||
@@ -6118,6 +6143,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} tools',
|
mcp_tool_count: '{0} tools',
|
||||||
mcp_enabled_yes: 'Enabled',
|
mcp_enabled_yes: 'Enabled',
|
||||||
mcp_enabled_no: 'Disabled',
|
mcp_enabled_no: 'Disabled',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'MCP Tools',
|
mcp_tools_title: 'MCP Tools',
|
||||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||||
@@ -7289,6 +7319,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} 个工具',
|
mcp_tool_count: '{0} 个工具',
|
||||||
mcp_enabled_yes: '已启用',
|
mcp_enabled_yes: '已启用',
|
||||||
mcp_enabled_no: '已禁用',
|
mcp_enabled_no: '已禁用',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'MCP 工具',
|
mcp_tools_title: 'MCP 工具',
|
||||||
mcp_tools_desc: '搜索活跃 MCP 服务器中的已知工具。',
|
mcp_tools_desc: '搜索活跃 MCP 服务器中的已知工具。',
|
||||||
mcp_tools_search_placeholder: '按名称、服务器或描述搜索工具…',
|
mcp_tools_search_placeholder: '按名称、服务器或描述搜索工具…',
|
||||||
@@ -8447,6 +8482,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} tools',
|
mcp_tool_count: '{0} tools',
|
||||||
mcp_enabled_yes: 'Enabled',
|
mcp_enabled_yes: 'Enabled',
|
||||||
mcp_enabled_no: 'Disabled',
|
mcp_enabled_no: 'Disabled',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'MCP Tools',
|
mcp_tools_title: 'MCP Tools',
|
||||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||||
@@ -10754,6 +10794,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} tools',
|
mcp_tool_count: '{0} tools',
|
||||||
mcp_enabled_yes: 'Enabled',
|
mcp_enabled_yes: 'Enabled',
|
||||||
mcp_enabled_no: 'Disabled',
|
mcp_enabled_no: 'Disabled',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'MCP Tools',
|
mcp_tools_title: 'MCP Tools',
|
||||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||||
@@ -11981,6 +12026,11 @@ const LOCALES = {
|
|||||||
mcp_tool_count: '{0} outils',
|
mcp_tool_count: '{0} outils',
|
||||||
mcp_enabled_yes: 'Activé',
|
mcp_enabled_yes: 'Activé',
|
||||||
mcp_enabled_no: 'Désactivé',
|
mcp_enabled_no: 'Désactivé',
|
||||||
|
mcp_enable_server: 'Enable this MCP server',
|
||||||
|
mcp_disable_server: 'Disable this MCP server',
|
||||||
|
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||||
|
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||||
|
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||||
mcp_tools_title: 'Outils MCP',
|
mcp_tools_title: 'Outils MCP',
|
||||||
mcp_tools_desc: 'Recherchez des outils connus sur les serveurs MCP actifs.',
|
mcp_tools_desc: 'Recherchez des outils connus sur les serveurs MCP actifs.',
|
||||||
mcp_tools_search_placeholder: 'Outils de recherche par nom, serveur ou description…',
|
mcp_tools_search_placeholder: 'Outils de recherche par nom, serveur ou description…',
|
||||||
|
|||||||
+17
-3
@@ -7120,6 +7120,16 @@ function _mcpStatusLabel(status){
|
|||||||
}[status]||'mcp_status_unknown';
|
}[status]||'mcp_status_unknown';
|
||||||
return t(key);
|
return t(key);
|
||||||
}
|
}
|
||||||
|
function toggleMcpServer(name, enabled){
|
||||||
|
api('/api/mcp/servers/'+encodeURIComponent(name),{
|
||||||
|
method:'PATCH',
|
||||||
|
body:JSON.stringify({enabled:enabled}),
|
||||||
|
}).then(r=>{
|
||||||
|
if(r&&r.ok) showToast(t(enabled?'mcp_enabled_toast':'mcp_disabled_toast',name));
|
||||||
|
else showToast(t('mcp_toggle_failed'),'error');
|
||||||
|
loadMcpServers();
|
||||||
|
}).catch(()=>{showToast(t('mcp_toggle_failed'),'error');loadMcpServers();});
|
||||||
|
}
|
||||||
function loadMcpServers(){
|
function loadMcpServers(){
|
||||||
const list=$('mcpServerList');
|
const list=$('mcpServerList');
|
||||||
if(!list) return;
|
if(!list) return;
|
||||||
@@ -7130,7 +7140,6 @@ function loadMcpServers(){
|
|||||||
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
|
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const toggleNote=r.toggle_supported?'':'<div class="mcp-readonly-note">'+esc(t('mcp_toggle_followup'))+'</div>';
|
|
||||||
list.innerHTML=r.servers.map(s=>{
|
list.innerHTML=r.servers.map(s=>{
|
||||||
const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown'));
|
const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown'));
|
||||||
const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
|
const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
|
||||||
@@ -7144,6 +7153,11 @@ function loadMcpServers(){
|
|||||||
const envInfo=s.env?Object.entries(s.env).map(([k,v])=>`${k}=${v}`).join(', '):'';
|
const envInfo=s.env?Object.entries(s.env).map(([k,v])=>`${k}=${v}`).join(', '):'';
|
||||||
const headersInfo=s.headers?Object.entries(s.headers).map(([k,v])=>`${k}=${v}`).join(', '):'';
|
const headersInfo=s.headers?Object.entries(s.headers).map(([k,v])=>`${k}=${v}`).join(', '):'';
|
||||||
const secretInfo=[envInfo,headersInfo].filter(Boolean).join(' | ');
|
const secretInfo=[envInfo,headersInfo].filter(Boolean).join(' | ');
|
||||||
|
const isEnabled=s.enabled!==false;
|
||||||
|
const encodedName=encodeURIComponent(s.name).replace(/'/g,"\\'");
|
||||||
|
const toggleBtn=r.toggle_supported
|
||||||
|
?`<button type="button" class="mcp-toggle-btn ${isEnabled?'mcp-toggle-enabled':'mcp-toggle-disabled'}" title="${esc(t(isEnabled?'mcp_disable_server':'mcp_enable_server'))}" onclick="toggleMcpServer('${encodedName}',${!isEnabled})">${esc(t(isEnabled?'mcp_enabled_yes':'mcp_enabled_no'))}</button>`
|
||||||
|
:`<span>${esc(t(isEnabled?'mcp_enabled_yes':'mcp_enabled_no'))}</span>`;
|
||||||
return `<div class="mcp-server-row">
|
return `<div class="mcp-server-row">
|
||||||
<div class="mcp-server-row-head">
|
<div class="mcp-server-row-head">
|
||||||
<span class="mcp-server-name">${esc(s.name)}</span>
|
<span class="mcp-server-name">${esc(s.name)}</span>
|
||||||
@@ -7151,9 +7165,9 @@ function loadMcpServers(){
|
|||||||
${statusBadge}
|
${statusBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="mcp-server-detail">${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}</div>
|
<div class="mcp-server-detail">${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}</div>
|
||||||
<div class="mcp-server-meta"><span class="mcp-tool-count">${esc(t('mcp_tool_count',toolCount))}</span><span>${esc(t(s.enabled===false?'mcp_enabled_no':'mcp_enabled_yes'))}</span></div>
|
<div class="mcp-server-meta"><span class="mcp-tool-count">${esc(t('mcp_tool_count',toolCount))}</span>${toggleBtn}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')+toggleNote;
|
}).join('');
|
||||||
}).catch(()=>{list.innerHTML=`<div class="mcp-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_load_failed'))}</div>`});
|
}).catch(()=>{list.innerHTML=`<div class="mcp-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_load_failed'))}</div>`});
|
||||||
}
|
}
|
||||||
let _mcpToolsCache=[];
|
let _mcpToolsCache=[];
|
||||||
|
|||||||
@@ -2956,6 +2956,10 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
|||||||
.mcp-status-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;}
|
.mcp-status-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;}
|
||||||
.mcp-status-invalid_config,.mcp-status-unknown{background:rgba(239,68,68,.12);color:#f87171;}
|
.mcp-status-invalid_config,.mcp-status-unknown{background:rgba(239,68,68,.12);color:#f87171;}
|
||||||
.mcp-tool-count{color:var(--text);}
|
.mcp-tool-count{color:var(--text);}
|
||||||
|
.mcp-toggle-btn{font-size:10px;font-weight:600;padding:2px 8px;border-radius:999px;border:1px solid transparent;cursor:pointer;transition:opacity .15s;}
|
||||||
|
.mcp-toggle-btn:hover{opacity:.8;}
|
||||||
|
.mcp-toggle-enabled{background:rgba(34,197,94,.15);color:#4ade80;border-color:rgba(34,197,94,.3);}
|
||||||
|
.mcp-toggle-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;border-color:rgba(161,161,170,.25);}
|
||||||
.mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;}
|
.mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;}
|
||||||
.mcp-tool-search{width:100%;margin:0 0 8px 0;padding:8px 10px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;font-size:12px;outline:none;}
|
.mcp-tool-search{width:100%;margin:0 0 8px 0;padding:8px 10px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;font-size:12px;outline:none;}
|
||||||
.mcp-tool-search:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg-soft);}
|
.mcp-tool-search:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg-soft);}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from api.routes import (
|
|||||||
_handle_mcp_servers_list,
|
_handle_mcp_servers_list,
|
||||||
_handle_mcp_server_update,
|
_handle_mcp_server_update,
|
||||||
_handle_mcp_server_delete,
|
_handle_mcp_server_delete,
|
||||||
|
_handle_mcp_server_toggle,
|
||||||
_mask_secrets,
|
_mask_secrets,
|
||||||
_parse_mcp_enabled,
|
_parse_mcp_enabled,
|
||||||
_server_summary,
|
_server_summary,
|
||||||
@@ -60,7 +61,7 @@ class TestMcpList:
|
|||||||
assert status == 200
|
assert status == 200
|
||||||
payload = _json_payload(h)
|
payload = _json_payload(h)
|
||||||
assert payload['servers'] == []
|
assert payload['servers'] == []
|
||||||
assert payload['toggle_supported'] is False
|
assert payload['toggle_supported'] is True
|
||||||
assert payload['reload_required'] is True
|
assert payload['reload_required'] is True
|
||||||
|
|
||||||
@patch('api.routes._mcp_runtime_status_by_name')
|
@patch('api.routes._mcp_runtime_status_by_name')
|
||||||
@@ -307,3 +308,83 @@ class TestStripMaskedValues:
|
|||||||
def test_empty_dicts(self):
|
def test_empty_dicts(self):
|
||||||
assert _strip_masked_values({}, {}) == {}
|
assert _strip_masked_values({}, {}) == {}
|
||||||
assert _strip_masked_values({"k": "v"}, {}) == {"k": "v"}
|
assert _strip_masked_values({"k": "v"}, {}) == {"k": "v"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpToggle:
|
||||||
|
"""PATCH /api/mcp/servers/<name> — enable/disable."""
|
||||||
|
|
||||||
|
@patch('api.routes.reload_config')
|
||||||
|
@patch('api.routes._save_yaml_config_file')
|
||||||
|
@patch('api.routes._get_config_path', return_value='/tmp/test.yaml')
|
||||||
|
@patch('api.routes.get_config')
|
||||||
|
def test_disable_server(self, mock_cfg, mock_path, mock_save, mock_reload):
|
||||||
|
mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run'}}}
|
||||||
|
h = _make_handler()
|
||||||
|
h.command = 'PATCH'
|
||||||
|
_handle_mcp_server_toggle(h, 'myserver', {'enabled': False})
|
||||||
|
assert mock_save.called
|
||||||
|
saved = mock_save.call_args[0][1]
|
||||||
|
assert saved['mcp_servers']['myserver']['enabled'] is False
|
||||||
|
assert mock_reload.called
|
||||||
|
|
||||||
|
@patch('api.routes.reload_config')
|
||||||
|
@patch('api.routes._save_yaml_config_file')
|
||||||
|
@patch('api.routes._get_config_path', return_value='/tmp/test.yaml')
|
||||||
|
@patch('api.routes.get_config')
|
||||||
|
def test_enable_server(self, mock_cfg, mock_path, mock_save, mock_reload):
|
||||||
|
mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run', 'enabled': False}}}
|
||||||
|
h = _make_handler()
|
||||||
|
h.command = 'PATCH'
|
||||||
|
_handle_mcp_server_toggle(h, 'myserver', {'enabled': True})
|
||||||
|
saved = mock_save.call_args[0][1]
|
||||||
|
assert saved['mcp_servers']['myserver']['enabled'] is True
|
||||||
|
|
||||||
|
@patch('api.routes.get_config')
|
||||||
|
def test_nonexistent_server_returns_404(self, mock_cfg):
|
||||||
|
mock_cfg.return_value = {'mcp_servers': {}}
|
||||||
|
h = _make_handler()
|
||||||
|
h.command = 'PATCH'
|
||||||
|
_handle_mcp_server_toggle(h, 'ghost', {'enabled': True})
|
||||||
|
status = h.send_response.call_args[0][0]
|
||||||
|
assert status == 404
|
||||||
|
|
||||||
|
def test_empty_name_rejected(self):
|
||||||
|
h = _make_handler()
|
||||||
|
h.command = 'PATCH'
|
||||||
|
_handle_mcp_server_toggle(h, '', {'enabled': True})
|
||||||
|
status = h.send_response.call_args[0][0]
|
||||||
|
assert status == 400
|
||||||
|
|
||||||
|
def test_missing_enabled_field_rejected(self):
|
||||||
|
h = _make_handler()
|
||||||
|
h.command = 'PATCH'
|
||||||
|
_handle_mcp_server_toggle(h, 'myserver', {})
|
||||||
|
status = h.send_response.call_args[0][0]
|
||||||
|
assert status == 400
|
||||||
|
|
||||||
|
@patch('api.routes.reload_config')
|
||||||
|
@patch('api.routes._save_yaml_config_file')
|
||||||
|
@patch('api.routes._get_config_path', return_value='/tmp/test.yaml')
|
||||||
|
@patch('api.routes.get_config')
|
||||||
|
def test_response_payload(self, mock_cfg, mock_path, mock_save, mock_reload):
|
||||||
|
mock_cfg.return_value = {'mcp_servers': {'srv': {'url': 'http://localhost'}}}
|
||||||
|
h = _make_handler()
|
||||||
|
h.command = 'PATCH'
|
||||||
|
_handle_mcp_server_toggle(h, 'srv', {'enabled': False})
|
||||||
|
body = h.wfile.write.call_args[0][0]
|
||||||
|
payload = json.loads(body.decode('utf-8'))
|
||||||
|
assert payload == {'ok': True, 'name': 'srv', 'enabled': False}
|
||||||
|
|
||||||
|
@patch('api.routes.reload_config')
|
||||||
|
@patch('api.routes._save_yaml_config_file')
|
||||||
|
@patch('api.routes._get_config_path', return_value='/tmp/test.yaml')
|
||||||
|
@patch('api.routes.get_config')
|
||||||
|
def test_url_encoded_name(self, mock_cfg, mock_path, mock_save, mock_reload):
|
||||||
|
"""Names with special characters must be URL-decoded."""
|
||||||
|
mock_cfg.return_value = {'mcp_servers': {'my server': {'command': 'x'}}}
|
||||||
|
h = _make_handler()
|
||||||
|
h.command = 'PATCH'
|
||||||
|
_handle_mcp_server_toggle(h, 'my%20server', {'enabled': False})
|
||||||
|
saved = mock_save.call_args[0][1]
|
||||||
|
assert 'my server' in saved['mcp_servers']
|
||||||
|
assert saved['mcp_servers']['my server']['enabled'] is False
|
||||||
@@ -24,7 +24,8 @@ def test_mcp_panel_renders_status_badges_tool_counts_and_empty_error_states():
|
|||||||
assert "mcp-tool-count" in js
|
assert "mcp-tool-count" in js
|
||||||
assert "mcp-empty-state" in js
|
assert "mcp-empty-state" in js
|
||||||
assert "mcp-error-state" in js
|
assert "mcp-error-state" in js
|
||||||
assert "mcp_toggle_followup" in js
|
assert "toggleMcpServer" in js
|
||||||
|
assert "mcp-toggle-btn" in js
|
||||||
assert "api('/api/mcp/servers')" in js
|
assert "api('/api/mcp/servers')" in js
|
||||||
assert "mcp-delete-btn" not in js
|
assert "mcp-delete-btn" not in js
|
||||||
assert "showMcpAddForm" not in js
|
assert "showMcpAddForm" not in js
|
||||||
|
|||||||
Reference in new issue
Block a user