From 56ab9951b1708f65e8dbb2b4f3e79140ec5d7842 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:05:04 +0530 Subject: [PATCH] fix(dashboard): add MCP auth to profile builder (#65163) * fix(dashboard): add MCP auth to profile builder * fix(dashboard): preserve MCP rejection error contract * feat(dashboard): refine profile MCP picker --- hermes_cli/web_server.py | 167 +++++------ tests/hermes_cli/test_web_server.py | 81 ++++++ web/src/lib/mcp-server-create.test.ts | 99 +++++++ web/src/lib/mcp-server-create.ts | 78 ++++++ web/src/pages/McpPage.tsx | 130 +++------ web/src/pages/ProfileBuilderPage.tsx | 388 ++++++++++++++++++++------ 6 files changed, 695 insertions(+), 248 deletions(-) create mode 100644 web/src/lib/mcp-server-create.test.ts create mode 100644 web/src/lib/mcp-server-create.ts diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index cb818f54f..1fe07fefc 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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] = {} @@ -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( @@ -13015,7 +13034,7 @@ 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)) @@ -13023,28 +13042,18 @@ def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate 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 "" + _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: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1d27bf6c3..125b61a03 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4414,6 +4414,87 @@ class TestNewEndpoints: 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 diff --git a/web/src/lib/mcp-server-create.test.ts b/web/src/lib/mcp-server-create.test.ts new file mode 100644 index 000000000..f0fc41866 --- /dev/null +++ b/web/src/lib/mcp-server-create.test.ts @@ -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"); + }); +}); diff --git a/web/src/lib/mcp-server-create.ts b/web/src/lib/mcp-server-create.ts new file mode 100644 index 000000000..c39c83825 --- /dev/null +++ b/web/src/lib/mcp-server-create.ts @@ -0,0 +1,78 @@ +import type { McpHttpAuth, McpServerCreate } from "@/lib/api"; + +export type McpTransport = "http" | "stdio"; + +export interface McpServerDraft { + name: string; + transport: McpTransport; + url: string; + httpAuth: McpHttpAuth; + bearerToken: string; + command: string; + args: string; + env: string; +} + +export function emptyMcpServerDraft(): McpServerDraft { + return { + name: "", + transport: "http", + url: "", + httpAuth: "none", + bearerToken: "", + command: "", + args: "", + env: "", + }; +} + +function parseArgs(raw: string): string[] { + return raw + .split(/[\s,]+/) + .map((value) => value.trim()) + .filter(Boolean); +} + +function parseEnv(raw: string): Record { + const env: Record = {}; + for (const rawLine of raw.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const separator = line.indexOf("="); + if (separator === -1) continue; + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (key) env[key] = value; + } + return env; +} + +export function buildMcpServerCreate(draft: McpServerDraft): McpServerCreate { + const name = draft.name.trim(); + if (!name) throw new Error("Name required"); + + if (draft.transport === "http") { + const url = draft.url.trim(); + if (!url) throw new Error("URL required"); + if (draft.httpAuth === "header" && !draft.bearerToken.trim()) { + throw new Error("Bearer token required"); + } + + const server: McpServerCreate = { name, url }; + if (draft.httpAuth !== "none") server.auth = draft.httpAuth; + if (draft.httpAuth === "header") { + server.bearer_token = draft.bearerToken; + } + return server; + } + + const command = draft.command.trim(); + if (!command) throw new Error("Command required"); + + const server: McpServerCreate = { name, command }; + const args = parseArgs(draft.args); + if (args.length) server.args = args; + const env = parseEnv(draft.env); + if (Object.keys(env).length) server.env = env; + return server; +} diff --git a/web/src/pages/McpPage.tsx b/web/src/pages/McpPage.tsx index cc34e00a8..1939c1f57 100644 --- a/web/src/pages/McpPage.tsx +++ b/web/src/pages/McpPage.tsx @@ -11,7 +11,6 @@ import type { McpCatalogEntry, McpHttpAuth, McpServer, - McpServerCreate, McpTestResult, } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; @@ -24,8 +23,10 @@ import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { usePageHeader } from "@/contexts/usePageHeader"; import { cn, themedBody } from "@/lib/utils"; - -type Transport = "http" | "stdio"; +import { + buildMcpServerCreate, + type McpTransport, +} from "@/lib/mcp-server-create"; function isHttpUrl(value: string): boolean { return /^https?:\/\//i.test(value.trim()); @@ -35,29 +36,6 @@ function truncateText(value: string, maxLength: number): string { return value.length > maxLength ? value.slice(0, maxLength) + "..." : value; } -function parseArgs(raw: string): string[] { - return raw - .split(/[\s,]+/) - .map((s) => s.trim()) - .filter(Boolean); -} - -function parseEnv(raw: string): Record { - const env: Record = {}; - raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .forEach((line) => { - const idx = line.indexOf("="); - if (idx === -1) return; - const key = line.slice(0, idx).trim(); - const value = line.slice(idx + 1).trim(); - if (key) env[key] = value; - }); - return env; -} - const TRANSPORT_TONE: Record = { http: "success", stdio: "warning", @@ -75,7 +53,7 @@ export default function McpPage() { // Add server modal state const [createModalOpen, setCreateModalOpen] = useState(false); const [name, setName] = useState(""); - const [transport, setTransport] = useState("http"); + const [transport, setTransport] = useState("http"); const [url, setUrl] = useState(""); const [httpAuth, setHttpAuth] = useState("none"); const [bearerToken, setBearerToken] = useState(""); @@ -95,9 +73,9 @@ export default function McpPage() { // Test results keyed by server name const [testing, setTesting] = useState(null); const [authenticating, setAuthenticating] = useState(null); - const [testResults, setTestResults] = useState< - Record - >({}); + const [testResults, setTestResults] = useState>( + {}, + ); // Enable/disable state const [togglingName, setTogglingName] = useState(null); @@ -139,37 +117,28 @@ export default function McpPage() { }, [loadServers, loadCatalog]); const handleCreate = async () => { - if (!name.trim()) { - showToast("Name required", "error"); - return; - } - if (transport === "http" && !url.trim()) { - 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"); + let body; + try { + body = buildMcpServerCreate({ + name, + transport, + url, + httpAuth, + bearerToken, + command, + args, + env, + }); + } catch (error) { + showToast( + error instanceof Error ? error.message : "Invalid MCP server", + "error", + ); return; } + setCreating(true); try { - 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; - } - await api.addMcpServer(body); showToast( transport === "http" && httpAuth === "oauth" @@ -234,9 +203,7 @@ export default function McpPage() { try { await api.setMcpServerEnabled(server.name, next); setServers((prev) => - prev.map((s) => - s.name === server.name ? { ...s, enabled: next } : s, - ), + prev.map((s) => (s.name === server.name ? { ...s, enabled: next } : s)), ); setRestartNote( "Enable/disable takes effect on the next gateway restart.", @@ -420,7 +387,7 @@ export default function McpPage() { id="mcp-transport" value={transport} onValueChange={(value) => { - const nextTransport = value as Transport; + const nextTransport = value as McpTransport; setTransport(nextTransport); if (nextTransport === "stdio") setBearerToken(""); }} @@ -469,15 +436,16 @@ export default function McpPage() { onChange={(e) => setBearerToken(e.target.value)} />

- Stored in this profile's .env; config.yaml keeps only - an environment-variable reference. + Stored in this profile's .env; config.yaml keeps + only an environment-variable reference.

)} {httpAuth === "oauth" && (

Add the server, then use Authenticate. Hermes opens the - OAuth browser on the machine running the Dashboard backend. + OAuth browser on the machine running the Dashboard + backend.

)} @@ -537,9 +505,7 @@ export default function McpPage() {
- e.target === e.currentTarget && setInstallEntry(null) - } + onClick={(e) => e.target === e.currentTarget && setInstallEntry(null)} role="dialog" aria-modal="true" aria-labelledby="install-mcp-title" @@ -628,9 +594,7 @@ export default function McpPage() {
- {restartNote && ( -

{restartNote}

- )} + {restartNote &&

{restartNote}

} {servers.length === 0 && ( @@ -664,12 +628,11 @@ export default function McpPage() { {server.auth && ( - auth: {server.auth === "header" ? "bearer" : server.auth} + auth:{" "} + {server.auth === "header" ? "bearer" : server.auth} )} - {!server.enabled && ( - disabled - )} + {!server.enabled && disabled}
{server.transport === "http" ? ( @@ -736,11 +699,7 @@ export default function McpPage() { onClick={() => handleToggleEnabled(server)} disabled={togglingName === server.name} prefix={ - togglingName === server.name ? ( - - ) : ( - - ) + togglingName === server.name ? : } className={server.enabled ? "text-success" : undefined} > @@ -831,9 +790,7 @@ export default function McpPage() { {entry.source} ) )} - {entry.installed && ( - Installed - )} + {entry.installed && Installed} {entry.installed && !entry.enabled && ( disabled )} @@ -875,9 +832,7 @@ export default function McpPage() { ) : ( {entry.install_url} )} - {entry.install_ref && ( - @ {entry.install_ref} - )} + {entry.install_ref && @ {entry.install_ref}}

)} {entry.bootstrap.length > 0 && ( @@ -887,7 +842,10 @@ export default function McpPage() {
    {entry.bootstrap.map((cmd, i) => ( -
  • +
  • {cmd}
  • ))} diff --git a/web/src/pages/ProfileBuilderPage.tsx b/web/src/pages/ProfileBuilderPage.tsx index 6aedb8dc1..dc9a71381 100644 --- a/web/src/pages/ProfileBuilderPage.tsx +++ b/web/src/pages/ProfileBuilderPage.tsx @@ -10,7 +10,18 @@ import { Checkbox } from "@nous-research/ui/ui/components/checkbox"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { api } from "@/lib/api"; -import type { McpServerCreate, SkillInfo, SkillHubResult } from "@/lib/api"; +import type { + McpHttpAuth, + McpServerCreate, + SkillInfo, + SkillHubResult, +} from "@/lib/api"; +import { + buildMcpServerCreate, + emptyMcpServerDraft, + type McpServerDraft, + type McpTransport, +} from "@/lib/mcp-server-create"; import { cn } from "@/lib/utils"; // Profile name rule mirrors the backend (`^[a-z0-9][a-z0-9_-]{0,63}$`). @@ -77,12 +88,7 @@ export default function ProfileBuilderPage() { // ── Step 4: MCPs ────────────────────────────────────────────────── const [mcpServers, setMcpServers] = useState([]); - const [mcpDraft, setMcpDraft] = useState<{ - name: string; - url: string; - command: string; - args: string; - }>({ name: "", url: "", command: "", args: "" }); + const [mcpDraft, setMcpDraft] = useState(emptyMcpServerDraft); // ── Submit ──────────────────────────────────────────────────────── const [creating, setCreating] = useState(false); @@ -99,7 +105,11 @@ export default function ProfileBuilderPage() { const flat: ModelChoice[] = []; for (const prov of res.providers ?? []) { for (const m of prov.models ?? []) { - flat.push({ provider: prov.slug, model: m, label: `${prov.name} · ${m}` }); + flat.push({ + provider: prov.slug, + model: m, + label: `${prov.name} · ${m}`, + }); } } setModelChoices(flat); @@ -160,28 +170,47 @@ export default function ProfileBuilderPage() { setHubSkills((prev) => prev.filter((x) => x.identifier !== identifier)); const addMcpDraft = () => { - const n = mcpDraft.name.trim(); - if (!n) { - showToast("MCP server needs a name", "error"); + let entry: McpServerCreate; + try { + entry = buildMcpServerCreate(mcpDraft); + } catch (error) { + showToast( + error instanceof Error ? error.message : "Invalid MCP server", + "error", + ); return; } - if (!mcpDraft.url.trim() && !mcpDraft.command.trim()) { - showToast("Give the MCP server a URL or a command", "error"); - return; - } - const entry: McpServerCreate = { name: n }; - if (mcpDraft.url.trim()) entry.url = mcpDraft.url.trim(); - if (mcpDraft.command.trim()) { - entry.command = mcpDraft.command.trim(); - const args = mcpDraft.args.trim(); - if (args) entry.args = args.split(/\s+/); - } - setMcpServers((prev) => [...prev.filter((s) => s.name !== n), entry]); - setMcpDraft({ name: "", url: "", command: "", args: "" }); + setMcpServers((prev) => [ + ...prev.filter((server) => server.name !== entry.name), + entry, + ]); + setMcpDraft(emptyMcpServerDraft()); }; const removeMcp = (n: string) => setMcpServers((prev) => prev.filter((s) => s.name !== n)); + const setMcpTransport = (transport: McpTransport) => { + setMcpDraft((draft) => + transport === "http" + ? { ...draft, transport, command: "", args: "", env: "" } + : { + ...draft, + transport, + url: "", + httpAuth: "none", + bearerToken: "", + }, + ); + }; + + const setMcpHttpAuth = (httpAuth: McpHttpAuth) => { + setMcpDraft((draft) => ({ + ...draft, + httpAuth, + bearerToken: httpAuth === "header" ? draft.bearerToken : "", + })); + }; + const filteredModels = useMemo(() => { if (!modelChoices) return []; const f = modelFilter.trim().toLowerCase(); @@ -204,7 +233,9 @@ export default function ProfileBuilderPage() { const pickedModel = useMemo( () => modelChoice - ? modelChoices?.find((c) => `${c.provider}\u0000${c.model}` === modelChoice) + ? modelChoices?.find( + (c) => `${c.provider}\u0000${c.model}` === modelChoice, + ) : undefined, [modelChoice, modelChoices], ); @@ -226,7 +257,9 @@ export default function ProfileBuilderPage() { model: pickedModel?.model, mcp_servers: mcpServers.length ? mcpServers : undefined, keep_skills: keepAll ? undefined : Array.from(keptSkills), - hub_skills: hubSkills.length ? hubSkills.map((s) => s.identifier) : undefined, + hub_skills: hubSkills.length + ? hubSkills.map((s) => s.identifier) + : undefined, }); const pending = (res.hub_installs ?? []).filter((h) => h.pid).length; showToast( @@ -288,11 +321,14 @@ export default function ProfileBuilderPage() { id="pb-name" placeholder="coder" value={name} - onChange={(e: React.ChangeEvent) => setName(e.target.value)} + onChange={(e: React.ChangeEvent) => + setName(e.target.value) + } /> {name && !nameValid && (

    - Lowercase letters, digits, hyphens and underscores; must start with a letter or digit. + Lowercase letters, digits, hyphens and underscores; must + start with a letter or digit.

    )}
@@ -313,7 +349,8 @@ export default function ProfileBuilderPage() { {step === "model" && (

- Pick the model+provider for this profile. Skip to use the default. + Pick the model+provider for this profile. Skip to use the + default.

setModelChoice(key)} className={cn( "block w-full rounded px-3 py-2 text-left text-sm", - modelChoice === key ? "bg-primary/10" : "hover:bg-muted", + modelChoice === key + ? "bg-primary/10" + : "hover:bg-muted", )} > {c.label} @@ -367,7 +406,8 @@ export default function ProfileBuilderPage() { {!keepAll && (

- Choose which built-in / optional skills to keep active. Unchecked skills are disabled in the new profile. + Choose which built-in / optional skills to keep active. + Unchecked skills are disabled in the new profile.

{skills === null ? ( -

Loading skills…

+

+ Loading skills… +

) : (
{filteredSkills.map((s) => ( @@ -423,7 +465,11 @@ export default function ProfileBuilderPage() { if (e.key === "Enter") runHubSearch(); }} /> -
@@ -473,73 +519,243 @@ export default function ProfileBuilderPage() { )} {step === "mcp" && ( -
-

- Add MCP servers for this profile. HTTP servers take a URL; stdio servers take a command + args. -

-
- ) => - setMcpDraft({ ...mcpDraft, name: e.target.value }) - } - /> - ) => - setMcpDraft({ ...mcpDraft, url: e.target.value }) - } - /> - ) => - setMcpDraft({ ...mcpDraft, command: e.target.value }) - } - /> - ) => - setMcpDraft({ ...mcpDraft, args: e.target.value }) - } - /> -
- - {mcpServers.length > 0 && ( +
+
+

+ MCP servers +

+

+ Add MCP servers to give this profile access to external + tools and data. +

+
+ + {mcpServers.length} configured + +
+ +
+

Add server

+ +
+
+ + ) => + setMcpDraft({ ...mcpDraft, name: e.target.value }) + } + /> +
+
+ +
+ {( + [ + ["http", "HTTP/SSE"], + ["stdio", "stdio"], + ] as const + ).map(([value, label]) => ( + + ))} +
+
+
+ + {mcpDraft.transport === "http" ? ( + <> +
+ + ) => + setMcpDraft({ ...mcpDraft, url: e.target.value }) + } + /> +
+
+ +
+ {( + [ + ["none", "None"], + ["header", "Bearer token"], + ["oauth", "OAuth"], + ] as const + ).map(([value, label]) => ( + + ))} +
+
+ {mcpDraft.httpAuth === "header" && ( +
+ + ) => + setMcpDraft({ + ...mcpDraft, + bearerToken: e.target.value, + }) + } + /> +

+ Stored in the new profile's .env; config.yaml + keeps only an environment-variable reference. +

+
+ )} + {mcpDraft.httpAuth === "oauth" && ( +

+ After creating the profile, open its MCP page and use + Authenticate to complete OAuth. +

+ )} + + ) : ( + <> +
+
+ + ) => + setMcpDraft({ + ...mcpDraft, + command: e.target.value, + }) + } + /> +
+
+ + ) => + setMcpDraft({ ...mcpDraft, args: e.target.value }) + } + /> +
+
+
+ +