feat: add authenticated URL-mode MCP servers from Control Panel

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_<NAME>_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.
This commit is contained in:
2026-06-10 21:19:05 -06:00
parent be9c9ae6d4
commit f473be4033
5 changed files with 225 additions and 17 deletions
+24 -4
View File
@@ -865,9 +865,15 @@ async function loadMcp() {
<strong>${escapeHtml(s.name)}</strong>
<span class="id"> · ${escapeHtml(s.detail || "")}</span>
</span>
<button class="btn-danger btn-mini" data-mcp-remove="${escapeHtml(s.name)}">Remove</button>
<span class="row-actions">
<button class="btn-ghost btn-mini" data-mcp-test="${escapeHtml(s.name)}">Test</button>
<button class="btn-danger btn-mini" data-mcp-remove="${escapeHtml(s.name)}">Remove</button>
</span>
`
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")
+3
View File
@@ -277,10 +277,13 @@
<div class="mcp-form">
<input id="mcp-name" placeholder="name (e.g. github)" />
<input id="mcp-url" placeholder="https://… (url-mode)" />
<input id="mcp-token" placeholder="auth token / bearer (url-mode, optional)" type="password" autocomplete="off" />
<input id="mcp-command" placeholder="command (stdio-mode)" />
<input id="mcp-args" placeholder="args (space-separated)" />
<textarea id="mcp-headers" rows="2" placeholder="extra headers (optional) — one per line, e.g.&#10;X-Api-Key: abc123"></textarea>
<button class="btn-primary" data-action="add-mcp">Add</button>
</div>
<p class="mcp-hint">URL servers are saved instantly without a live probe. The token is stored in <code>.env</code> (never in plaintext config) and sent as <code>Authorization: Bearer …</code>. Use <strong>Test</strong> on a server to verify it connects.</p>
<pre class="log" id="mcp-log" hidden></pre>
</section>
</section>
+142 -12
View File
@@ -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: <name> <url>. 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,
+16 -1
View File
@@ -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 {
+40
View File
@@ -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/)
})