Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 88 additions & 79 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10956,6 +10956,77 @@ class MCPServersReplace(BaseModel):
profile: Optional[str] = None


def _normalize_mcp_server_create(
body: MCPServerCreate,
) -> tuple[str, Dict[str, Any], Optional[str]]:
"""Validate a Dashboard MCP create request and build its safe config.

The returned config never contains the submitted Bearer token. Callers
persist the token with the shared Bearer helper only after they enter the
intended profile scope. Keeping this conversion shared makes the
standalone MCP page and the Profile Builder enforce the same
transport/auth contract.
"""
from hermes_cli.mcp_config import (
_bearer_auth_headers,
_strip_bearer_prefix,
)
from hermes_cli.mcp_security import validate_mcp_server_entry

name = (body.name or "").strip()
if not name:
raise ValueError("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 ValueError("Provide exactly one of URL (HTTP/SSE) or command (stdio)")
if auth not in {"none", "header", "oauth"}:
raise ValueError(f"Unsupported auth mode: {auth}")

server_config: Dict[str, Any] = {}
if url:
if body.args:
raise ValueError("Arguments are only supported for stdio MCP servers")
if body.env:
raise ValueError(
"Environment variables are only supported for stdio MCP servers"
)
if auth == "header":
normalized = _strip_bearer_prefix(bearer_token) if bearer_token else ""
if not normalized or normalized.lower() == "bearer":
raise ValueError("Bearer token is required")
server_config["headers"] = _bearer_auth_headers(name)
elif body.bearer_token is not None:
raise ValueError("Bearer token requires header authentication")

server_config["url"] = url
if auth == "oauth":
server_config["auth"] = "oauth"
else:
if auth != "none" or body.bearer_token is not None:
raise ValueError(
"HTTP authentication is not supported for stdio MCP servers"
)
server_config["command"] = command
if body.args:
server_config["args"] = list(body.args)
if body.env:
server_config["env"] = dict(body.env)

issues = validate_mcp_server_entry(name, server_config)
if issues:
raise ValueError(f"Server '{name}' rejected: {'; '.join(issues)}")
return name, server_config, bearer_token


def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
"""Mask secret-shaped MCP env values for read responses."""
out: Dict[str, str] = {}
Expand Down Expand Up @@ -11010,71 +11081,19 @@ async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
_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",
)
try:
name, server_config, bearer_token = _normalize_mcp_server_create(body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

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")
server_config: Dict[str, Any] = {}
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)

try:
with _profile_scope(body.profile or profile):
if auth == "header" and bearer_token is not None:
if 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(
Expand Down Expand Up @@ -13015,36 +13034,26 @@ def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate
Returns the number of servers written.
"""
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
from hermes_cli.mcp_security import validate_mcp_server_entry
from hermes_cli.mcp_config import _save_bearer_auth_token

written = 0
token = set_hermes_home_override(str(profile_dir))
try:
cfg = load_config()
mcp = cfg.setdefault("mcp_servers", {})
for server in servers:
name = (server.name or "").strip()
if not name:
continue
entry: Dict[str, Any] = {}
if server.url:
entry["url"] = server.url
if server.command:
entry["command"] = server.command
if server.args:
entry["args"] = list(server.args)
if server.env:
entry["env"] = dict(server.env)
if server.auth:
entry["auth"] = server.auth
if not entry:
# Nothing usable to write (neither url nor command) — skip
# rather than persist an empty, unusable server stanza.
continue
issues = validate_mcp_server_entry(name, entry)
if issues:
_log.warning("Profile-create: skipping MCP server '%s': %s", name, "; ".join(issues))
try:
name, entry, bearer_token = _normalize_mcp_server_create(server)
except ValueError as exc:
display_name = (server.name or "").strip() or "<unnamed>"
_log.warning(
"Profile-create: skipping MCP server '%s': %s",
display_name,
exc,
)
continue
if bearer_token is not None:
entry["headers"] = _save_bearer_auth_token(name, bearer_token)
mcp[name] = entry
written += 1
if written:
Expand Down
81 changes: 81 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4414,6 +4414,87 @@ def fake_spawn(subcommand, name):
finally:
reset_hermes_home_override(token)

def test_profiles_create_builder_mcp_auth_is_profile_scoped(
self, monkeypatch
):
from hermes_constants import get_hermes_home
import hermes_cli.profiles as profiles_mod

monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)

secret = "profile-builder-secret"
resp = self.client.post(
"/api/profiles",
json={
"name": "builder-auth",
"mcp_servers": [
{
"name": "Bearer Server",
"url": "https://example.com/mcp",
"auth": "header",
"bearer_token": f"Bearer {secret}",
},
{
"name": "oauth-server",
"url": "https://example.com/oauth-mcp",
"auth": "oauth",
},
{
"name": "local-server",
"command": "uvx",
"args": ["mcp-server", "--debug"],
"env": {"API_KEY": "stdio-secret"},
},
{
"name": "missing-token",
"url": "https://example.com/bad",
"auth": "header",
},
{
"name": "http-with-env",
"url": "https://example.com/bad-env",
"env": {"NOT_SUPPORTED": "value"},
},
],
},
)

assert resp.status_code == 200
assert resp.json()["mcp_written"] == 3

root = get_hermes_home()
profile_dir = root / "profiles" / "builder-auth"
config_text = (profile_dir / "config.yaml").read_text(encoding="utf-8")
config = yaml.safe_load(config_text)
servers = config["mcp_servers"]

assert sorted(servers) == [
"Bearer Server",
"local-server",
"oauth-server",
]
assert servers["Bearer Server"] == {
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer ${MCP_BEARER_SERVER_API_KEY}",
},
}
assert servers["oauth-server"] == {
"url": "https://example.com/oauth-mcp",
"auth": "oauth",
}
assert servers["local-server"] == {
"command": "uvx",
"args": ["mcp-server", "--debug"],
"env": {"API_KEY": "stdio-secret"},
}

assert secret not in config_text
profile_env = (profile_dir / ".env").read_text(encoding="utf-8")
assert f"MCP_BEARER_SERVER_API_KEY={secret}" in profile_env
assert "Bearer Bearer" not in profile_env
assert not (root / ".env").exists()

def test_profile_open_terminal_uses_macos_terminal(self, monkeypatch):
from hermes_constants import get_hermes_home
import hermes_cli.web_server as web_server
Expand Down
99 changes: 99 additions & 0 deletions web/src/lib/mcp-server-create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";

import { buildMcpServerCreate, emptyMcpServerDraft } from "./mcp-server-create";

describe("buildMcpServerCreate", () => {
it("builds an HTTP Bearer request without stdio fields", () => {
const server = buildMcpServerCreate({
...emptyMcpServerDraft(),
name: " Linear ",
url: " https://mcp.linear.app/mcp ",
httpAuth: "header",
bearerToken: "Bearer secret-token",
command: "ignored",
args: "--ignored",
env: "IGNORED=value",
});

expect(server).toEqual({
name: "Linear",
url: "https://mcp.linear.app/mcp",
auth: "header",
bearer_token: "Bearer secret-token",
});
});

it("builds OAuth and unauthenticated HTTP requests without a token", () => {
expect(
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "oauth",
url: "https://example.com/mcp",
httpAuth: "oauth",
}),
).toEqual({
name: "oauth",
url: "https://example.com/mcp",
auth: "oauth",
});

expect(
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "public",
url: "https://example.com/mcp",
}),
).toEqual({
name: "public",
url: "https://example.com/mcp",
});
});

it("parses stdio arguments and environment assignments", () => {
const server = buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "local",
transport: "stdio",
command: " uvx ",
args: "mcp-server, --debug",
env: "API_KEY=secret\nURL=https://example.com?a=b\nINVALID",
});

expect(server).toEqual({
name: "local",
command: "uvx",
args: ["mcp-server", "--debug"],
env: {
API_KEY: "secret",
URL: "https://example.com?a=b",
},
});
});

it("rejects missing transport fields and Bearer tokens", () => {
expect(() => buildMcpServerCreate(emptyMcpServerDraft())).toThrow(
"Name required",
);
expect(() =>
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "remote",
}),
).toThrow("URL required");
expect(() =>
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "remote",
url: "https://example.com/mcp",
httpAuth: "header",
}),
).toThrow("Bearer token required");
expect(() =>
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "local",
transport: "stdio",
}),
).toThrow("Command required");
});
});
Loading
Loading