From f473be403336a20155cf2e2e6f85d666199e8800 Mon Sep 17 00:00:00 2001 From: "Zachariah K. Sharma" Date: Wed, 10 Jun 2026 21:19:05 -0600 Subject: [PATCH] feat: add authenticated URL-mode MCP servers from Control Panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional auth-token (bearer) field and a custom-headers textarea to the MCP 'Add server' pane. URL-mode servers are now registered by writing hermes config directly via the venv's config helpers (save_env_value + save_config) instead of shelling into the interactive, network-probing 'hermes mcp add --url' flow that hung/504'd on auth challenges. - Token is stored in ~/.hermes/.env (mode 600) and referenced in config.yaml as 'Authorization: Bearer ${MCP__API_KEY}' — never plaintext config. - Registration is non-blocking: no live probe, so a slow/unreachable server can't hang the request. - Add a per-server 'Test' button (POST /api/mcp/test -> hermes mcp test). - Contract tests for the new fields, payload, no-probe path, and test route. --- app.js | 28 ++++++- index.html | 3 + server.cjs | 154 +++++++++++++++++++++++++++++++++++--- style.css | 17 ++++- test/ui-contract.test.cjs | 40 ++++++++++ 5 files changed, 225 insertions(+), 17 deletions(-) diff --git a/app.js b/app.js index 14da816..42cf3cb 100644 --- a/app.js +++ b/app.js @@ -865,9 +865,15 @@ async function loadMcp() { ${escapeHtml(s.name)} · ${escapeHtml(s.detail || "")} - + + + + ` - li.querySelector("button").addEventListener("click", async () => { + li.querySelector("[data-mcp-test]").addEventListener("click", () => { + testMcp(s.name, $("#mcp-log")) + }) + li.querySelector("[data-mcp-remove]").addEventListener("click", async () => { if (!confirm(`Remove MCP server "${s.name}"?`)) return await api("/api/mcp/remove", { method: "POST", body: JSON.stringify({ name: s.name }) }) setMsg(`Removed ▸ ${s.name}`, "ok") @@ -880,23 +886,37 @@ async function loadMcp() { async function addMcp() { const name = $("#mcp-name").value.trim() const url = $("#mcp-url").value.trim() + const token = $("#mcp-token").value.trim() + const headers = $("#mcp-headers").value.trim() const command = $("#mcp-command").value.trim() const args = $("#mcp-args").value.trim().split(/\s+/).filter(Boolean) if (!name) { setMsg("Name required", "err"); return } + if (!url && !command) { setMsg("URL or command required", "err"); return } const log = $("#mcp-log") log.hidden = false log.textContent = "▷ Adding…\n" const r = await api("/api/mcp/add", { - method: "POST", body: JSON.stringify({ name, url, command, args }) + method: "POST", body: JSON.stringify({ name, url, token, headers, command, args }) }) log.textContent += r.output || "" if (r.exitCode === 0) { setMsg(`Added ▸ ${name}`, "ok") - $("#mcp-name").value = $("#mcp-url").value = $("#mcp-command").value = $("#mcp-args").value = "" + $("#mcp-name").value = $("#mcp-url").value = $("#mcp-token").value = "" + $("#mcp-headers").value = $("#mcp-command").value = $("#mcp-args").value = "" loadMcp() } else setMsg(`Add failed (${r.exitCode})`, "err") } +async function testMcp(name, logEl) { + logEl.hidden = false + logEl.textContent = `▷ Testing ${name}…\n` + const r = await api("/api/mcp/test", { + method: "POST", body: JSON.stringify({ name }) + }) + logEl.textContent += r.output || "" + setMsg(r.exitCode === 0 ? `Connected ▸ ${name}` : `Test failed ▸ ${name}`, r.exitCode === 0 ? "ok" : "err") +} + // ── Cron ─────────────────────────────────────────────────────────────────── async function loadCron() { const r = await api("/api/cron") diff --git a/index.html b/index.html index ddaaa78..0cb7121 100644 --- a/index.html +++ b/index.html @@ -277,10 +277,13 @@
+ +
+

URL servers are saved instantly without a live probe. The token is stored in .env (never in plaintext config) and sent as Authorization: Bearer …. Use Test on a server to verify it connects.

diff --git a/server.cjs b/server.cjs index 1a19a93..2c19ae9 100644 --- a/server.cjs +++ b/server.cjs @@ -47,6 +47,15 @@ const defaultHermesExe = process.platform === "win32" const HERMES_HOME = process.env.HERMES_HOME || defaultHermesHome const HERMES_EXE = process.env.HERMES_EXE || defaultHermesExe +// Python interpreter that sits beside the hermes entrypoint in the same venv. +// Used to drive hermes's own config helpers (save_env_value / save_config) when +// registering a URL-mode MCP server, so we never trigger the interactive + +// network-probing `hermes mcp add` flow (which hangs/504s on auth challenges). +const defaultHermesPython = path.join( + path.dirname(HERMES_EXE), + process.platform === "win32" ? "python.exe" : "python" +) +const HERMES_PYTHON = process.env.HERMES_PYTHON || defaultHermesPython const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), ".codex") const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude") const GEMINI_CONFIG_DIR = process.env.GEMINI_CONFIG_DIR || path.join(os.homedir(), ".gemini") @@ -983,22 +992,142 @@ async function h_mcpList(_req, res) { send(res, 200, { servers: parseMcpList(r.stdout + r.stderr), raw: r.stdout + r.stderr }) } +// Inline Python that registers a URL-mode MCP server via hermes's own config +// helpers. Writes the bearer token to ~/.hermes/.env (mode 600) and stores only +// an env-interpolated placeholder in config.yaml, so the raw token never lands +// in plaintext config. No live probe — registration is instant and never hangs +// on an auth challenge. argv: . Secrets arrive via env (HCP_*). +const PY_MCP_URL_ADD = ` +import json, os, re, sys +from hermes_cli.config import load_config, save_config, save_env_value + +name, url = sys.argv[1], sys.argv[2] +if not re.match(r"^[A-Za-z0-9_-]+$", name): + print("invalid server name", file=sys.stderr); sys.exit(2) + +token = os.environ.get("HCP_MCP_TOKEN") or "" +try: + extra = json.loads(os.environ.get("HCP_MCP_HEADERS") or "{}") + if not isinstance(extra, dict): extra = {} +except Exception: + extra = {} + +headers = {} +# Custom headers first; the token (if any) owns Authorization and wins. +for k, v in extra.items(): + if k and isinstance(v, str): + headers[str(k)] = v +if token: + env_key = "MCP_%s_API_KEY" % re.sub(r"[^A-Za-z0-9]", "_", name).upper() + save_env_value(env_key, token) + headers["Authorization"] = "Bearer \${%s}" % env_key + +server = {"url": url, "enabled": True} +if headers: + server["headers"] = headers + +cfg = load_config() +cfg.setdefault("mcp_servers", {})[name] = server +save_config(cfg) +print(json.dumps({"ok": True, "name": name, "auth": bool(token), "headers": sorted(headers)})) +` + +// Parse a free-form "Name: Value" headers blob (one per line) into an object. +function parseHeaderLines(raw) { + const out = {} + for (const line of String(raw || "").split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed) continue + const idx = trimmed.indexOf(":") + if (idx <= 0) continue + const key = trimmed.slice(0, idx).trim() + const val = trimmed.slice(idx + 1).trim() + if (key) out[key] = val + } + return out +} + +// Register a URL-mode MCP server without any network probe (non-blocking). +function mcpWriteUrlServer({ name, url, token, headers }, timeoutMs = 15000) { + return new Promise((resolve) => { + // No cwd override: the venv's editable .pth resolves `hermes_cli` imports + // regardless of cwd, and the hermes source dir differs between the local + // install ($HERMES_HOME/hermes-agent) and the container (/opt/hermes-agent) + // — pinning a cwd that may not exist would make spawn fail with ENOENT. + const proc = spawn(HERMES_PYTHON, ["-c", PY_MCP_URL_ADD, name, url], { + env: { + ...process.env, + NO_COLOR: "1", + PYTHONIOENCODING: "utf-8", + HCP_MCP_TOKEN: token || "", + HCP_MCP_HEADERS: JSON.stringify(headers || {}), + }, + windowsHide: true, + }) + let stdout = "", stderr = "", settled = false + const settle = (r) => { if (!settled) { settled = true; resolve(r) } } + const timer = setTimeout(() => { + try { proc.kill("SIGKILL") } catch {} + settle({ code: -1, stdout, stderr: stderr + "\ntimeout" }) + }, timeoutMs) + proc.stdout.on("data", (d) => { stdout += d.toString("utf-8") }) + proc.stderr.on("data", (d) => { stderr += d.toString("utf-8") }) + proc.on("close", (code) => { clearTimeout(timer); settle({ code, stdout, stderr }) }) + proc.on("error", (err) => { clearTimeout(timer); settle({ code: -1, stdout, stderr: stderr + "\n" + err.message }) }) + }) +} + async function h_mcpAdd(req, res) { const body = await readBody(req) - const name = String(body.name || "") - const url = String(body.url || "") - const command = String(body.command || "") + const name = String(body.name || "").trim() + const url = String(body.url || "").trim() + const command = String(body.command || "").trim() if (!name) return send(res, 400, { error: "name required" }) - let args - if (url) { - args = ["mcp", "add", name, "--url", url] - } else if (command) { - const extra = Array.isArray(body.args) ? body.args : [] - args = ["mcp", "add", name, "--command", command, ...(extra.length ? ["--args", ...extra] : [])] - } else { - return send(res, 400, { error: "url or command required" }) + if (!/^[A-Za-z0-9_-]+$/.test(name)) { + return send(res, 400, { error: "name may only contain letters, numbers, '-' and '_'" }) } - const r = await runHermes(args, 30000) + + if (url) { + if (!/^https?:\/\//i.test(url)) { + return send(res, 400, { error: "url must start with http:// or https://" }) + } + // Auth/headers may arrive as a token string and/or a headers blob (object + // or "Name: Value" lines). Token → bearer in .env; headers → inline config. + const token = String(body.token || "").trim() + let headers = {} + if (body.headers && typeof body.headers === "object") headers = body.headers + else if (typeof body.headers === "string") headers = parseHeaderLines(body.headers) + // Direct config write — no interactive prompt, no network probe, no hang. + const r = await mcpWriteUrlServer({ name, url, token, headers }) + if (r.code === 0) { + return send(res, 200, { + exitCode: 0, + output: `✓ Registered '${name}' (${url})${token ? " with bearer auth" : ""}.\n` + + `Use “Test” to verify the connection.\n`, + }) + } + return send(res, 200, { + exitCode: r.code, + output: (r.stdout + r.stderr).trim() || "Failed to write MCP server config.", + }) + } + + if (command) { + const extra = Array.isArray(body.args) ? body.args : [] + const args = ["mcp", "add", name, "--command", command, ...(extra.length ? ["--args", ...extra] : [])] + const r = await runHermes(args, 30000) + return send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) + } + + return send(res, 400, { error: "url or command required" }) +} + +// Probe an already-registered MCP server (used by the UI "Test" button). +async function h_mcpTest(req, res) { + const body = await readBody(req) + const name = String(body.name || "").trim() + if (!name) return send(res, 400, { error: "name required" }) + const r = await runHermes(["mcp", "test", name], 45000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } @@ -1958,6 +2087,7 @@ const ROUTES = { // mcp "GET /api/mcp": h_mcpList, "POST /api/mcp/add": h_mcpAdd, + "POST /api/mcp/test": h_mcpTest, "POST /api/mcp/remove": h_mcpRemove, // cron "GET /api/cron": h_cronList, diff --git a/style.css b/style.css index 88e98ec..954d48a 100644 --- a/style.css +++ b/style.css @@ -978,8 +978,23 @@ select { display: grid; grid-template-columns: repeat(2, 1fr) auto; gap: 10px; + align-items: start; } -.mcp-form button { grid-row: 1 / span 2; align-self: stretch; } +.mcp-form textarea { + grid-column: 1 / 3; + resize: vertical; + min-height: 2.4em; + font-family: var(--ff-mono); +} +.mcp-form button { grid-row: 1 / -1; align-self: stretch; } +.mcp-hint { + margin: 10px 0 0; + font-size: 0.8rem; + line-height: 1.5; + color: var(--ink-60, #555); +} +.mcp-hint code { font-family: var(--ff-mono); font-size: 0.92em; } +.row-actions { display: inline-flex; gap: 8px; align-items: center; } /* ─── Readout / bottom status bar ────────────────────────────────────── */ .readout { diff --git a/test/ui-contract.test.cjs b/test/ui-contract.test.cjs index 876c678..8477b14 100644 --- a/test/ui-contract.test.cjs +++ b/test/ui-contract.test.cjs @@ -81,3 +81,43 @@ test("app.js preset handler sets expires input value", (t) => { assert.match(app, /preset === "30d"/) assert.match(app, /preset === "1y"/) }) + +// ── MCP add: auth token / headers (URL-mode) ─────────────────────────────── +const server = fs.readFileSync(path.join(root, "server.cjs"), "utf-8") + +test("index.html MCP add pane has auth token + headers fields", (t) => { + assert.match(index, /id="mcp-token"/) + assert.match(index, /id="mcp-headers"/) + // token field must be masked + assert.match(index, /id="mcp-token"[^>]*type="password"/) + assert.match(index, /class="mcp-hint"/) +}) + +test("app.js sends token + headers to /api/mcp/add and clears them", (t) => { + assert.match(app, /#mcp-token/) + assert.match(app, /#mcp-headers/) + assert.match(app, /JSON\.stringify\(\{\s*name,\s*url,\s*token,\s*headers,\s*command,\s*args\s*\}\)/) +}) + +test("app.js exposes a Test action per MCP server", (t) => { + assert.match(app, /function testMcp/) + assert.match(app, /data-mcp-test=/) + assert.match(app, /\/api\/mcp\/test/) +}) + +test("server.cjs registers URL-mode MCP servers without a live probe", (t) => { + // Must NOT shell into the interactive/probing `hermes mcp add --url` path. + assert.doesNotMatch(server, /"mcp",\s*"add",\s*name,\s*"--url"/) + // Uses hermes's own config helpers via the venv python. + assert.match(server, /save_env_value/) + assert.match(server, /save_config/) + assert.match(server, /mcpWriteUrlServer/) + // Token goes to .env as a bearer placeholder, never plaintext in config. + assert.match(server, /Bearer \\\$\{%s\}/) + assert.match(server, /HCP_MCP_TOKEN/) +}) + +test("server.cjs exposes POST /api/mcp/test route", (t) => { + assert.match(server, /"POST \/api\/mcp\/test":\s*h_mcpTest/) + assert.match(server, /function h_mcpTest/) +})