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:
@@ -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")
|
||||
|
||||
@@ -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. 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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user