Merge pull request #161 from MervinPraison/claude/issue-154-20260624-0952

feat: MCP manager view + Ctrl+K command palette (B-08, B-02)
This commit is contained in:
Mervin Praison authored and GitHub committed 2026-06-24 23:17:08 +01:00
commit 420f3776d8
4 files changed
+353 -1

No files matched your search

+9
View File
@@ -3314,6 +3314,14 @@ def create_app(
"description": "Execution nodes & presence",
"order": 15,
},
{
"id": "mcp",
"title": "MCP",
"icon": "🔌",
"group": "Agent",
"description": "Model Context Protocol servers",
"order": 30,
},
{
"id": "config",
"title": "Config",
@@ -3408,6 +3416,7 @@ def create_app(
"sessions": "/sessions",
"cron": "/api/schedules",
"channels": "/api/channels",
"mcp": "/api/mcp/servers",
}
# Read page whitelist from config.yaml if set_pages() was not called
@@ -174,6 +174,7 @@ const BUILTIN_VIEWS = {
'jobs-board': '/plugins/views/jobs-board.js',
auth: '/plugins/views/auth.js',
api: '/plugins/views/api.js',
mcp: '/plugins/views/mcp.js',
};
const SLOT_REGISTRY = {};
@@ -747,6 +748,20 @@ const DASHBOARD_STYLE = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* ── Command Palette (Ctrl+K) ────────────────────────── */
.db-cmdk-overlay { position: fixed; inset: 0; z-index: 10002; display: flex; align-items: flex-start; justify-content: center; padding-top: 12vh; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); animation: fadeIn 0.15s ease; }
.db-cmdk-box { width: 560px; max-width: 92vw; background: var(--db-sidebar-bg); border: 1px solid var(--db-border); border-radius: 12px; box-shadow: 0 16px 60px rgba(0,0,0,0.55); overflow: hidden; animation: slideIn 0.15s ease; }
.db-cmdk-input { width: 100%; box-sizing: border-box; padding: 16px 20px; font-size: 15px; color: var(--db-text); background: transparent; border: none; border-bottom: 1px solid var(--db-border); outline: none; }
.db-cmdk-input::placeholder { color: var(--db-text-dim); }
.db-cmdk-list { max-height: 50vh; overflow-y: auto; padding: 6px; }
.db-cmdk-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 8px; cursor: pointer; font-size: 14px; color: var(--db-text); }
.db-cmdk-item.active { background: var(--db-accent); color: #fff; }
.db-cmdk-item.active .db-cmdk-group { color: rgba(255,255,255,0.8); }
.db-cmdk-icon { width: 20px; text-align: center; flex-shrink: 0; }
.db-cmdk-title { flex: 1; }
.db-cmdk-group { font-size: 11px; color: var(--db-text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
.db-cmdk-empty { padding: 24px; text-align: center; font-size: 13px; color: var(--db-text-dim); }
`;
let activePageId = null;
@@ -909,6 +924,104 @@ function setupMobileNav(root, sidebar) {
}
}
let _paletteEl = null;
let _paletteActive = -1;
let _paletteResults = [];
function openCommandPalette() {
if (_paletteEl) { closeCommandPalette(); return; }
const overlay = document.createElement('div');
overlay.className = 'db-cmdk-overlay';
overlay.innerHTML = `
<div class="db-cmdk-box" role="dialog" aria-label="Command palette">
<input class="db-cmdk-input" type="text" placeholder="Search pages and actions…" autocomplete="off" spellcheck="false">
<div class="db-cmdk-list"></div>
</div>`;
document.body.appendChild(overlay);
_paletteEl = overlay;
const input = overlay.querySelector('.db-cmdk-input');
const listEl = overlay.querySelector('.db-cmdk-list');
const renderResults = (q) => {
const query = q.trim().toLowerCase();
const items = pagesData
.filter((p) => p.id !== activePageId || query)
.map((p) => ({ page: p, score: paletteScore(p, query) }))
.filter((x) => query === '' || x.score > 0)
.sort((a, b) => b.score - a.score || (a.page.order || 0) - (b.page.order || 0))
.slice(0, 50)
.map((x) => x.page);
_paletteResults = items;
_paletteActive = items.length ? 0 : -1;
listEl.innerHTML = items.length
? items
.map(
(p, i) =>
`<div class="db-cmdk-item${i === 0 ? ' active' : ''}" data-idx="${i}" data-id="${p.id}"><span class="db-cmdk-icon">${p.icon || '•'}</span><span class="db-cmdk-title">${p.title}</span>${p.group ? `<span class="db-cmdk-group">${p.group}</span>` : ''}</div>`
)
.join('')
: '<div class="db-cmdk-empty">No matches</div>';
listEl.querySelectorAll('.db-cmdk-item').forEach((el) => {
el.addEventListener('click', () => runPaletteItem(parseInt(el.dataset.idx, 10)));
el.addEventListener('mousemove', () => setPaletteActive(parseInt(el.dataset.idx, 10)));
});
};
input.addEventListener('input', () => renderResults(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setPaletteActive(_paletteActive + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setPaletteActive(_paletteActive - 1); }
else if (e.key === 'Enter') { e.preventDefault(); runPaletteItem(_paletteActive); }
else if (e.key === 'Escape') { e.preventDefault(); closeCommandPalette(); }
});
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeCommandPalette(); });
renderResults('');
input.focus();
}
function paletteScore(page, query) {
if (!query) return 1;
const hay = `${page.title} ${page.id} ${page.group || ''} ${page.description || ''}`.toLowerCase();
if (hay.includes(query)) return 100 - hay.indexOf(query);
let qi = 0;
for (let i = 0; i < hay.length && qi < query.length; i++) {
if (hay[i] === query[qi]) qi++;
}
return qi === query.length ? 1 : 0;
}
function setPaletteActive(idx) {
if (!_paletteEl || !_paletteResults.length) return;
const n = _paletteResults.length;
_paletteActive = ((idx % n) + n) % n;
const items = _paletteEl.querySelectorAll('.db-cmdk-item');
items.forEach((el, i) => el.classList.toggle('active', i === _paletteActive));
const activeEl = items[_paletteActive];
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
}
function runPaletteItem(idx) {
const page = _paletteResults[idx];
if (!page) return;
closeCommandPalette();
selectPage(page.id);
}
function closeCommandPalette() {
if (_paletteEl) { _paletteEl.remove(); _paletteEl = null; }
_paletteActive = -1;
_paletteResults = [];
}
function initCommandPalette() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
openCommandPalette();
}
});
}
async function init() {
// Inject styles
const style = document.createElement('style');
@@ -925,7 +1038,10 @@ async function init() {
}
await buildDashboard();
// Initialize Ctrl+K / Cmd+K command palette
initCommandPalette();
// Initialize approval stream for modal notifications
initApprovalStream();
@@ -0,0 +1,214 @@
/**
* MCP Manager View — list, connect, and disconnect Model Context Protocol servers.
* API: GET /api/mcp/servers, POST /api/mcp/connect, POST /api/mcp/disconnect/{name}
*/
import { showToast, showConfirm } from '../toast.js';
const STATUS_STYLE = {
connected: 'background:rgba(34,197,94,0.15);color:#22c55e',
connecting: 'background:rgba(234,179,8,0.15);color:#eab308',
error: 'background:rgba(239,68,68,0.15);color:#ef4444',
disconnected: 'background:rgba(113,113,122,0.2);color:var(--db-text-dim)',
};
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let servers = [];
let loadError = null;
try {
const r = await fetch('/api/mcp/servers');
const d = await r.json();
servers = d.servers || [];
if (d.error) loadError = d.error;
} catch (e) {
loadError = e.message;
}
const connected = servers.filter((s) => s.status === 'connected').length;
const totalTools = servers.reduce((n, s) => n + ((s.tools && s.tools.length) || 0), 0);
container.innerHTML = `
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
<div class="db-card"><div class="db-card-title">Servers</div><div class="db-card-value">${servers.length}</div><div class="db-card-footer">${connected} connected</div></div>
<div class="db-card"><div class="db-card-title">Tools</div><div class="db-card-value">${totalTools}</div></div>
<div class="db-card"><div class="db-card-title">Health</div><div class="db-card-value">${servers.length ? Math.round((connected / servers.length) * 100) : 0}%</div></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h3 style="margin:0;font-size:15px;font-weight:600">MCP Servers</h3>
<button id="mcp-add" class="db-btn db-btn-primary">+ Connect Server</button>
</div>
${loadError ? `<div class="db-alert db-alert-warning"><span>⚠️</span><span>${esc(loadError)}</span></div>` : ''}
<div id="mcp-list"></div>
<div id="mcp-form-host"></div>
`;
const list = container.querySelector('#mcp-list');
servers.forEach((s) => {
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'margin-bottom:10px;padding:16px 20px';
const isConnected = s.status === 'connected';
const statusStyle = STATUS_STYLE[s.status] || STATUS_STYLE.disconnected;
const tools = s.tools || [];
card.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0">
<span style="font-size:20px">🔌</span>
<div style="min-width:0">
<div style="font-weight:500;font-size:14px">${esc(s.name)}</div>
<div style="font-size:12px;color:var(--db-text-dim)">${esc(s.transport || '')} · ${tools.length} tool${tools.length !== 1 ? 's' : ''}</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:11px;padding:3px 10px;border-radius:12px;${statusStyle}">${esc(s.status || 'unknown')}</span>
${isConnected ? `<button class="mcp-disconnect db-btn db-btn-destructive" data-name="${esc(s.name)}" style="padding:4px 12px;font-size:11px">Disconnect</button>` : ''}
</div>
</div>
${s.last_error ? `<div style="font-size:12px;color:#ef4444;margin-top:8px">${esc(s.last_error)}</div>` : ''}
${tools.length ? `<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--db-text-dim)">Tools (${tools.length})</summary><div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">${tools
.map(
(t) =>
`<div style="font-size:12px"><code style="color:var(--db-text)">${esc(t.name)}</code>${t.description ? ` <span style="color:var(--db-text-dim)">— ${esc(t.description)}</span>` : ''}</div>`
)
.join('')}</div></details>` : ''}
`;
list.appendChild(card);
});
if (servers.length === 0 && !loadError) {
list.innerHTML = '<div class="db-viewer"><pre>No MCP servers connected. Click "Connect Server" to add one.</pre></div>';
}
container.querySelectorAll('.mcp-disconnect').forEach((b) =>
b.addEventListener('click', async () => {
const name = b.dataset.name;
if (!(await showConfirm('Disconnect MCP Server', `Disconnect "${name}"?`))) return;
try {
const r = await fetch(`/api/mcp/disconnect/${encodeURIComponent(name)}`, { method: 'POST' });
const d = await r.json();
if (r.ok && d.success) {
showToast(`Disconnected from ${name}`, 'success');
render(container);
} else {
showToast(d.error || 'Disconnect failed', 'error');
}
} catch (e) {
showToast(e.message, 'error');
}
})
);
const addBtn = container.querySelector('#mcp-add');
const formHost = container.querySelector('#mcp-form-host');
addBtn.addEventListener('click', () => openConnectForm(formHost, container));
}
function openConnectForm(host, container) {
host.innerHTML = `
<div class="db-card" style="margin-top:16px;padding:20px">
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600">Connect MCP Server</h3>
<div class="db-form-group">
<label class="db-form-label">Name</label>
<input id="mcp-f-name" class="db-form-input" placeholder="my-server">
</div>
<div class="db-form-group">
<label class="db-form-label">Transport</label>
<select id="mcp-f-transport" class="db-form-select">
<option value="stdio">stdio (command)</option>
<option value="sse">SSE / HTTP (url)</option>
</select>
</div>
<div id="mcp-f-stdio">
<div class="db-form-group">
<label class="db-form-label">Command</label>
<input id="mcp-f-command" class="db-form-input" placeholder="npx">
</div>
<div class="db-form-group">
<label class="db-form-label">Arguments (space-separated)</label>
<input id="mcp-f-args" class="db-form-input" placeholder="-y @modelcontextprotocol/server-filesystem /tmp">
</div>
</div>
<div id="mcp-f-url" style="display:none">
<div class="db-form-group">
<label class="db-form-label">URL</label>
<input id="mcp-f-urlval" class="db-form-input" placeholder="https://example.com/sse">
</div>
</div>
<div class="db-button-group" style="margin:0">
<button id="mcp-f-submit" class="db-btn db-btn-primary">Connect</button>
<button id="mcp-f-cancel" class="db-btn">Cancel</button>
</div>
</div>
`;
const transportSel = host.querySelector('#mcp-f-transport');
const stdioBlock = host.querySelector('#mcp-f-stdio');
const urlBlock = host.querySelector('#mcp-f-url');
transportSel.addEventListener('change', () => {
const isStdio = transportSel.value === 'stdio';
stdioBlock.style.display = isStdio ? '' : 'none';
urlBlock.style.display = isStdio ? 'none' : '';
});
host.querySelector('#mcp-f-cancel').addEventListener('click', () => {
host.innerHTML = '';
});
host.querySelector('#mcp-f-submit').addEventListener('click', async () => {
const name = host.querySelector('#mcp-f-name').value.trim();
if (!name) {
showToast('Name is required', 'error');
return;
}
const body = { name };
if (transportSel.value === 'stdio') {
const command = host.querySelector('#mcp-f-command').value.trim();
if (!command) {
showToast('Command is required for stdio', 'error');
return;
}
body.command = command;
const argsRaw = host.querySelector('#mcp-f-args').value.trim();
body.args = argsRaw ? argsRaw.split(/\s+/) : [];
} else {
const url = host.querySelector('#mcp-f-urlval').value.trim();
if (!url) {
showToast('URL is required', 'error');
return;
}
body.url = url;
}
const submitBtn = host.querySelector('#mcp-f-submit');
submitBtn.disabled = true;
submitBtn.textContent = 'Connecting…';
try {
const r = await fetch('/api/mcp/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (r.ok && d.server) {
showToast(`Connected to ${d.server.name}`, 'success');
host.innerHTML = '';
render(container);
} else {
showToast(d.error || d.hint || 'Connection failed', 'error');
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
} catch (e) {
showToast(e.message, 'error');
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
});
}
+13
View File
@@ -734,6 +734,19 @@ class TestPagesAPI:
if ev:
assert ev["group"] == "Control"
def test_mcp_page_registered(self, client):
r = client.get("/api/pages")
pages = r.json().get("pages", r.json())
mcp = next((p for p in pages if p["id"] == "mcp"), None)
assert mcp is not None, "MCP page should be registered"
assert mcp["group"] == "Agent"
assert mcp["api_endpoint"] == "/api/mcp/servers"
def test_mcp_servers_endpoint(self, client):
r = client.get("/api/mcp/servers")
assert r.status_code == 200
assert "servers" in r.json()
# ===========================================================================
# Persistence across restart