mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(dashboard): add HTTP MCP authentication (#65146)
This commit is contained in:
parent
170959d80a
commit
e0e7cfa673
7 changed files with 380 additions and 58 deletions
|
|
@ -171,6 +171,31 @@ def _strip_bearer_prefix(token: str) -> str:
|
|||
return stripped
|
||||
|
||||
|
||||
def _bearer_auth_headers(name: str) -> Dict[str, str]:
|
||||
"""Build the persisted Authorization header for a named MCP server.
|
||||
|
||||
The secret itself lives in the active profile's ``.env`` file. Keeping
|
||||
this template construction beside ``_env_key_for_server`` ensures the CLI
|
||||
and Dashboard produce byte-equivalent MCP configuration.
|
||||
"""
|
||||
env_key = _env_key_for_server(name)
|
||||
return {"Authorization": f"Bearer ${{{env_key}}}"}
|
||||
|
||||
|
||||
def _save_bearer_auth_token(name: str, token: str) -> Dict[str, str]:
|
||||
"""Persist a Bearer token in the active profile and return safe headers.
|
||||
|
||||
``token`` is a one-time provisioning value. It is normalized and written
|
||||
only to ``.env``; callers persist the returned interpolation template in
|
||||
``config.yaml``.
|
||||
"""
|
||||
normalized = _strip_bearer_prefix(token)
|
||||
if not normalized or normalized.lower() == "bearer":
|
||||
raise ValueError("Bearer token is required")
|
||||
save_env_value(_env_key_for_server(name), normalized)
|
||||
return _bearer_auth_headers(name)
|
||||
|
||||
|
||||
def _parse_env_assignments(raw_env: Optional[List[str]]) -> Dict[str, str]:
|
||||
"""Parse ``KEY=VALUE`` strings from CLI args into an env dict."""
|
||||
parsed: Dict[str, str] = {}
|
||||
|
|
@ -492,19 +517,17 @@ def cmd_mcp_add(args):
|
|||
existing_key = get_env_value(env_key)
|
||||
if existing_key:
|
||||
_success(f"{env_key}: already configured")
|
||||
api_key = existing_key
|
||||
else:
|
||||
api_key = _prompt("API key / Bearer token", password=True)
|
||||
if api_key:
|
||||
api_key = _strip_bearer_prefix(api_key)
|
||||
save_env_value(env_key, api_key)
|
||||
server_config["headers"] = _save_bearer_auth_token(
|
||||
name, api_key
|
||||
)
|
||||
_success(f"Saved to {display_hermes_home()}/.env as {env_key}")
|
||||
|
||||
# Set header with env var interpolation
|
||||
if api_key or existing_key:
|
||||
server_config["headers"] = {
|
||||
"Authorization": f"Bearer ${{{env_key}}}"
|
||||
}
|
||||
if existing_key:
|
||||
server_config["headers"] = _bearer_auth_headers(name)
|
||||
|
||||
# ── Discovery: connect and list tools ─────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ try:
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
except ImportError:
|
||||
# First try lazy-installing the dashboard extras. Only the user actually
|
||||
|
|
@ -115,7 +115,7 @@ except ImportError:
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
except Exception:
|
||||
raise SystemExit(
|
||||
|
|
@ -10943,8 +10943,10 @@ class MCPServerCreate(BaseModel):
|
|||
args: List[str] = []
|
||||
# env: KEY=VALUE map for stdio servers (API keys, etc.)
|
||||
env: Dict[str, str] = {}
|
||||
# auth: "oauth" | "header" | None
|
||||
# auth: "none" | "oauth" | "header" | None
|
||||
auth: Optional[str] = None
|
||||
# One-time provisioning input; persisted only to the profile's .env.
|
||||
bearer_token: Optional[SecretStr] = None
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
|
|
@ -10967,6 +10969,12 @@ def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
|
|||
|
||||
def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown")
|
||||
auth = cfg.get("auth")
|
||||
headers = cfg.get("headers") or {}
|
||||
if not auth and isinstance(headers, dict) and any(
|
||||
str(key).lower() == "authorization" for key in headers
|
||||
):
|
||||
auth = "header"
|
||||
return {
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
|
|
@ -10974,7 +10982,7 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"command": cfg.get("command"),
|
||||
"args": list(cfg.get("args") or []),
|
||||
"env": _redact_mcp_env(cfg.get("env") or {}),
|
||||
"auth": cfg.get("auth"),
|
||||
"auth": auth,
|
||||
"enabled": cfg.get("enabled", True) is not False,
|
||||
# Tool selection: list of enabled tool names, or None = all.
|
||||
"tools": cfg.get("tools"),
|
||||
|
|
@ -10996,35 +11004,78 @@ async def list_mcp_servers(profile: Optional[str] = None):
|
|||
|
||||
@app.post("/api/mcp/servers")
|
||||
async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
|
||||
from hermes_cli.mcp_config import _get_mcp_servers, _save_mcp_server
|
||||
from hermes_cli.mcp_config import (
|
||||
_get_mcp_servers,
|
||||
_save_bearer_auth_token,
|
||||
_save_mcp_server,
|
||||
)
|
||||
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Server name is required")
|
||||
|
||||
url = (body.url or "").strip()
|
||||
command = (body.command or "").strip()
|
||||
auth = (body.auth or "none").strip().lower()
|
||||
bearer_token = (
|
||||
body.bearer_token.get_secret_value()
|
||||
if body.bearer_token is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if bool(url) == bool(command):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provide exactly one of URL (HTTP/SSE) or command (stdio)",
|
||||
)
|
||||
if auth not in {"none", "header", "oauth"}:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported auth mode: {auth}")
|
||||
|
||||
if url:
|
||||
if body.args:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Arguments are only supported for stdio MCP servers",
|
||||
)
|
||||
if body.env:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Environment variables are only supported for stdio MCP servers",
|
||||
)
|
||||
if auth == "header" and bearer_token is None:
|
||||
raise HTTPException(status_code=400, detail="Bearer token is required")
|
||||
if auth != "header" and body.bearer_token is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Bearer token requires header authentication",
|
||||
)
|
||||
else:
|
||||
if auth != "none" or body.bearer_token is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="HTTP authentication is not supported for stdio MCP servers",
|
||||
)
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
existing = _get_mcp_servers()
|
||||
if name in existing:
|
||||
raise HTTPException(status_code=409, detail=f"Server '{name}' already exists")
|
||||
if not body.url and not body.command:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provide either a URL (HTTP/SSE server) or a command (stdio server)",
|
||||
)
|
||||
|
||||
server_config: Dict[str, Any] = {}
|
||||
if body.url:
|
||||
server_config["url"] = body.url.strip()
|
||||
if body.command:
|
||||
server_config["command"] = body.command.strip()
|
||||
if url:
|
||||
server_config["url"] = url
|
||||
if auth == "oauth":
|
||||
server_config["auth"] = "oauth"
|
||||
else:
|
||||
server_config["command"] = command
|
||||
if body.args:
|
||||
server_config["args"] = list(body.args)
|
||||
if body.env:
|
||||
server_config["env"] = dict(body.env)
|
||||
if body.auth:
|
||||
server_config["auth"] = body.auth
|
||||
if body.env:
|
||||
server_config["env"] = dict(body.env)
|
||||
|
||||
try:
|
||||
with _profile_scope(body.profile or profile):
|
||||
if auth == "header" and bearer_token is not None:
|
||||
server_config["headers"] = _save_bearer_auth_token(name, bearer_token)
|
||||
if not _save_mcp_server(name, server_config):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,98 @@ class TestMcpEndpoints:
|
|||
srv = self.client.get("/api/mcp/servers").json()["servers"][0]
|
||||
assert srv["env"]["API_KEY"] != "sk-secret-1234567890"
|
||||
|
||||
def test_http_bearer_auth_separates_secret_from_config(
|
||||
self, _isolate_hermes_home
|
||||
):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
secret = "dashboard-secret-value"
|
||||
response = self.client.post(
|
||||
"/api/mcp/servers",
|
||||
json={
|
||||
"name": "Bearer Server",
|
||||
"url": "https://example.com/mcp",
|
||||
"auth": "header",
|
||||
"bearer_token": f"Bearer {secret}",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["auth"] == "header"
|
||||
assert "bearer_token" not in response.json()
|
||||
|
||||
hermes_home = get_hermes_home()
|
||||
config_text = (hermes_home / "config.yaml").read_text()
|
||||
env_text = (hermes_home / ".env").read_text()
|
||||
assert secret not in config_text
|
||||
assert "Bearer ${MCP_BEARER_SERVER_API_KEY}" in config_text
|
||||
assert f"MCP_BEARER_SERVER_API_KEY={secret}" in env_text
|
||||
|
||||
def test_http_oauth_mode_is_persisted_for_existing_auth_flow(self):
|
||||
response = self.client.post(
|
||||
"/api/mcp/servers",
|
||||
json={
|
||||
"name": "oauth-server",
|
||||
"url": "https://example.com/mcp",
|
||||
"auth": "oauth",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["auth"] == "oauth"
|
||||
|
||||
from hermes_cli.mcp_config import _get_mcp_servers
|
||||
|
||||
assert _get_mcp_servers()["oauth-server"]["auth"] == "oauth"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "error"),
|
||||
[
|
||||
(
|
||||
{"name": "bad", "url": "https://x/mcp", "env": {"KEY": "value"}},
|
||||
"only supported for stdio",
|
||||
),
|
||||
(
|
||||
{"name": "bad", "url": "https://x/mcp", "args": ["ignored"]},
|
||||
"only supported for stdio",
|
||||
),
|
||||
(
|
||||
{"name": "bad", "command": "npx", "auth": "oauth"},
|
||||
"not supported for stdio",
|
||||
),
|
||||
(
|
||||
{"name": "bad", "url": "https://x/mcp", "auth": "header"},
|
||||
"Bearer token is required",
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "bad",
|
||||
"url": "https://x/mcp",
|
||||
"auth": "header",
|
||||
"bearer_token": "Bearer ",
|
||||
},
|
||||
"Bearer token is required",
|
||||
),
|
||||
(
|
||||
{"name": "bad", "url": "https://x/mcp", "bearer_token": "secret"},
|
||||
"requires header authentication",
|
||||
),
|
||||
(
|
||||
{"name": "bad", "url": "https://x/mcp", "command": "npx"},
|
||||
"exactly one",
|
||||
),
|
||||
(
|
||||
{"name": "bad", "url": "https://x/mcp", "auth": "unknown"},
|
||||
"Unsupported auth mode",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_transport_auth_contract_is_enforced(self, payload, error):
|
||||
response = self.client.post("/api/mcp/servers", json=payload)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert error in response.json()["detail"]
|
||||
|
||||
def test_duplicate_rejected(self):
|
||||
self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"})
|
||||
r = self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"})
|
||||
|
|
|
|||
|
|
@ -739,6 +739,25 @@ class TestStripBearerPrefix:
|
|||
assert _strip_bearer_prefix(None) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestBearerAuthPersistence:
|
||||
def test_secret_and_header_are_persisted_separately(self):
|
||||
from hermes_cli.config import get_env_value
|
||||
from hermes_cli.mcp_config import _save_bearer_auth_token
|
||||
|
||||
headers = _save_bearer_auth_token("My Server", "Bearer secret-value")
|
||||
|
||||
assert headers == {
|
||||
"Authorization": "Bearer ${MCP_MY_SERVER_API_KEY}",
|
||||
}
|
||||
assert get_env_value("MCP_MY_SERVER_API_KEY") == "secret-value"
|
||||
|
||||
def test_empty_token_is_rejected(self):
|
||||
from hermes_cli.mcp_config import _save_bearer_auth_token
|
||||
|
||||
with pytest.raises(ValueError, match="Bearer token is required"):
|
||||
_save_bearer_auth_token("empty", "Bearer ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -161,6 +161,30 @@ class TestProfileScopedMcp:
|
|||
listing = client.get("/api/mcp/servers").json()
|
||||
assert not any(s["name"] == "scoped-srv" for s in listing["servers"])
|
||||
|
||||
def test_mcp_bearer_secret_is_profile_scoped(self, client, isolated_profiles):
|
||||
secret = "worker-only-secret"
|
||||
response = client.post(
|
||||
"/api/mcp/servers",
|
||||
params={"profile": "worker_beta"},
|
||||
json={
|
||||
"name": "profile-bearer",
|
||||
"url": "https://example.com/mcp",
|
||||
"auth": "header",
|
||||
"bearer_token": secret,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
assert worker_cfg["mcp_servers"]["profile-bearer"]["headers"] == {
|
||||
"Authorization": "Bearer ${MCP_PROFILE_BEARER_API_KEY}",
|
||||
}
|
||||
assert secret in (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
assert not (isolated_profiles["default"] / ".env").exists()
|
||||
assert "profile-bearer" not in _cfg(isolated_profiles["default"]).get(
|
||||
"mcp_servers", {}
|
||||
)
|
||||
|
||||
def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8"
|
||||
|
|
|
|||
|
|
@ -995,6 +995,11 @@ export const api = {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
authMcpServer: (name: string) =>
|
||||
fetchJSON<McpTestResult>(
|
||||
`/api/mcp/servers/${encodeURIComponent(name)}/auth`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
removeMcpServer: (name: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/mcp/servers/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
|
|
@ -1407,7 +1412,7 @@ export interface McpServer {
|
|||
command: string | null;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
auth: string | null;
|
||||
auth: "header" | "oauth" | null;
|
||||
enabled: boolean;
|
||||
tools: string[] | null;
|
||||
}
|
||||
|
|
@ -1442,13 +1447,16 @@ export interface McpCatalogDiagnostic {
|
|||
}
|
||||
|
||||
|
||||
export type McpHttpAuth = "none" | "header" | "oauth";
|
||||
|
||||
export interface McpServerCreate {
|
||||
name: string;
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
auth?: string;
|
||||
auth?: McpHttpAuth;
|
||||
bearer_token?: string;
|
||||
}
|
||||
|
||||
export interface McpTestResult {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Package, Power, Server, Trash2, X, Zap } from "lucide-react";
|
||||
import { KeyRound, Package, Power, Server, Trash2, X, Zap } from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
|
|
@ -9,6 +9,7 @@ import { api } from "@/lib/api";
|
|||
import type {
|
||||
McpCatalogDiagnostic,
|
||||
McpCatalogEntry,
|
||||
McpHttpAuth,
|
||||
McpServer,
|
||||
McpServerCreate,
|
||||
McpTestResult,
|
||||
|
|
@ -76,11 +77,16 @@ export default function McpPage() {
|
|||
const [name, setName] = useState("");
|
||||
const [transport, setTransport] = useState<Transport>("http");
|
||||
const [url, setUrl] = useState("");
|
||||
const [httpAuth, setHttpAuth] = useState<McpHttpAuth>("none");
|
||||
const [bearerToken, setBearerToken] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [args, setArgs] = useState("");
|
||||
const [env, setEnv] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
||||
const closeCreateModal = useCallback(() => {
|
||||
setBearerToken("");
|
||||
setCreateModalOpen(false);
|
||||
}, []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
|
|
@ -88,6 +94,7 @@ export default function McpPage() {
|
|||
|
||||
// Test results keyed by server name
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [authenticating, setAuthenticating] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<
|
||||
Record<string, McpTestResult>
|
||||
>({});
|
||||
|
|
@ -140,6 +147,10 @@ export default function McpPage() {
|
|||
showToast("URL required", "error");
|
||||
return;
|
||||
}
|
||||
if (transport === "http" && httpAuth === "header" && !bearerToken.trim()) {
|
||||
showToast("Bearer token required", "error");
|
||||
return;
|
||||
}
|
||||
if (transport === "stdio" && !command.trim()) {
|
||||
showToast("Command required", "error");
|
||||
return;
|
||||
|
|
@ -149,18 +160,27 @@ export default function McpPage() {
|
|||
const body: McpServerCreate = { name: name.trim() };
|
||||
if (transport === "http") {
|
||||
body.url = url.trim();
|
||||
if (httpAuth !== "none") body.auth = httpAuth;
|
||||
if (httpAuth === "header") body.bearer_token = bearerToken;
|
||||
} else {
|
||||
body.command = command.trim();
|
||||
const argList = parseArgs(args);
|
||||
if (argList.length) body.args = argList;
|
||||
const envMap = parseEnv(env);
|
||||
if (Object.keys(envMap).length) body.env = envMap;
|
||||
}
|
||||
const envMap = parseEnv(env);
|
||||
if (Object.keys(envMap).length) body.env = envMap;
|
||||
|
||||
await api.addMcpServer(body);
|
||||
showToast("Add ✓", "success");
|
||||
showToast(
|
||||
transport === "http" && httpAuth === "oauth"
|
||||
? "Added — authenticate with OAuth"
|
||||
: "Add ✓",
|
||||
"success",
|
||||
);
|
||||
setName("");
|
||||
setUrl("");
|
||||
setHttpAuth("none");
|
||||
setBearerToken("");
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
setEnv("");
|
||||
|
|
@ -191,6 +211,23 @@ export default function McpPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleAuthenticate = async (server: McpServer) => {
|
||||
setAuthenticating(server.name);
|
||||
try {
|
||||
const result = await api.authMcpServer(server.name);
|
||||
setTestResults((prev) => ({ ...prev, [server.name]: result }));
|
||||
if (result.ok) {
|
||||
showToast(`${server.name}: OAuth authentication complete`, "success");
|
||||
} else {
|
||||
showToast(`${server.name}: ${result.error ?? "OAuth failed"}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`OAuth error: ${e}`, "error");
|
||||
} finally {
|
||||
setAuthenticating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (server: McpServer) => {
|
||||
const next = !server.enabled;
|
||||
setTogglingName(server.name);
|
||||
|
|
@ -335,9 +372,7 @@ export default function McpPage() {
|
|||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) =>
|
||||
e.target === e.currentTarget && setCreateModalOpen(false)
|
||||
}
|
||||
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-mcp-title"
|
||||
|
|
@ -351,7 +386,7 @@ export default function McpPage() {
|
|||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={() => setCreateModalOpen(false)}
|
||||
onClick={closeCreateModal}
|
||||
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
|
|
@ -384,7 +419,11 @@ export default function McpPage() {
|
|||
<Select
|
||||
id="mcp-transport"
|
||||
value={transport}
|
||||
onValueChange={(v) => setTransport(v as Transport)}
|
||||
onValueChange={(value) => {
|
||||
const nextTransport = value as Transport;
|
||||
setTransport(nextTransport);
|
||||
if (nextTransport === "stdio") setBearerToken("");
|
||||
}}
|
||||
>
|
||||
<SelectOption value="http">HTTP/SSE</SelectOption>
|
||||
<SelectOption value="stdio">stdio</SelectOption>
|
||||
|
|
@ -392,15 +431,56 @@ export default function McpPage() {
|
|||
</div>
|
||||
|
||||
{transport === "http" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-url">URL</Label>
|
||||
<Input
|
||||
id="mcp-url"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-url">URL</Label>
|
||||
<Input
|
||||
id="mcp-url"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-auth">Authentication</Label>
|
||||
<Select
|
||||
id="mcp-auth"
|
||||
value={httpAuth}
|
||||
onValueChange={(value) => {
|
||||
const nextAuth = value as McpHttpAuth;
|
||||
setHttpAuth(nextAuth);
|
||||
if (nextAuth !== "header") setBearerToken("");
|
||||
}}
|
||||
>
|
||||
<SelectOption value="none">None</SelectOption>
|
||||
<SelectOption value="header">Bearer token</SelectOption>
|
||||
<SelectOption value="oauth">OAuth</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
{httpAuth === "header" && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-bearer-token">Bearer token</Label>
|
||||
<Input
|
||||
id="mcp-bearer-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Token or Bearer token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stored in this profile's .env; config.yaml keeps only
|
||||
an environment-variable reference.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{httpAuth === "oauth" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add the server, then use Authenticate. Hermes opens the
|
||||
OAuth browser on the machine running the Dashboard backend.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -421,20 +501,21 @@ export default function McpPage() {
|
|||
onChange={(e) => setArgs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-env">
|
||||
Environment (KEY=VALUE per line)
|
||||
</Label>
|
||||
<textarea
|
||||
id="mcp-env"
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder={"API_KEY=secret\nDEBUG=1"}
|
||||
value={env}
|
||||
onChange={(e) => setEnv(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-env">Environment (KEY=VALUE per line)</Label>
|
||||
<textarea
|
||||
id="mcp-env"
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder={"API_KEY=secret\nDEBUG=1"}
|
||||
value={env}
|
||||
onChange={(e) => setEnv(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
|
|
@ -581,6 +662,11 @@ export default function McpPage() {
|
|||
>
|
||||
{server.transport}
|
||||
</Badge>
|
||||
{server.auth && (
|
||||
<Badge tone="outline">
|
||||
auth: {server.auth === "header" ? "bearer" : server.auth}
|
||||
</Badge>
|
||||
)}
|
||||
{!server.enabled && (
|
||||
<Badge tone="outline">disabled</Badge>
|
||||
)}
|
||||
|
|
@ -623,6 +709,25 @@ export default function McpPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{server.auth === "oauth" && (
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
title="Authenticate with OAuth"
|
||||
onClick={() => handleAuthenticate(server)}
|
||||
disabled={authenticating === server.name}
|
||||
prefix={
|
||||
authenticating === server.name ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<KeyRound />
|
||||
)
|
||||
}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue