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
This commit is contained in:
Siddharth Balyan 2026-07-16 02:05:04 +05:30 committed by GitHub
parent 3bfa6001f7
commit 56ab9951b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 695 additions and 248 deletions

View file

@ -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 "<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:

View file

@ -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

View file

@ -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");
});
});

View file

@ -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<string, string> {
const env: Record<string, string> = {};
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;
}

View file

@ -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<string, string> {
const env: Record<string, string> = {};
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<string, "success" | "warning" | "secondary"> = {
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<Transport>("http");
const [transport, setTransport] = useState<McpTransport>("http");
const [url, setUrl] = useState("");
const [httpAuth, setHttpAuth] = useState<McpHttpAuth>("none");
const [bearerToken, setBearerToken] = useState("");
@ -95,9 +73,9 @@ 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>
>({});
const [testResults, setTestResults] = useState<Record<string, McpTestResult>>(
{},
);
// Enable/disable state
const [togglingName, setTogglingName] = useState<string | null>(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)}
/>
<p className="text-xs text-muted-foreground">
Stored in this profile&apos;s .env; config.yaml keeps only
an environment-variable reference.
Stored in this profile&apos;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.
OAuth browser on the machine running the Dashboard
backend.
</p>
)}
</>
@ -537,9 +505,7 @@ export default function McpPage() {
<div
ref={installModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) =>
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() {
</H2>
</div>
{restartNote && (
<p className="text-xs text-warning">{restartNote}</p>
)}
{restartNote && <p className="text-xs text-warning">{restartNote}</p>}
{servers.length === 0 && (
<Card>
@ -664,12 +628,11 @@ export default function McpPage() {
</Badge>
{server.auth && (
<Badge tone="outline">
auth: {server.auth === "header" ? "bearer" : server.auth}
auth:{" "}
{server.auth === "header" ? "bearer" : server.auth}
</Badge>
)}
{!server.enabled && (
<Badge tone="outline">disabled</Badge>
)}
{!server.enabled && <Badge tone="outline">disabled</Badge>}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{server.transport === "http" ? (
@ -736,11 +699,7 @@ export default function McpPage() {
onClick={() => handleToggleEnabled(server)}
disabled={togglingName === server.name}
prefix={
togglingName === server.name ? (
<Spinner />
) : (
<Power />
)
togglingName === server.name ? <Spinner /> : <Power />
}
className={server.enabled ? "text-success" : undefined}
>
@ -831,9 +790,7 @@ export default function McpPage() {
<Badge tone="outline">{entry.source}</Badge>
)
)}
{entry.installed && (
<Badge tone="success">Installed</Badge>
)}
{entry.installed && <Badge tone="success">Installed</Badge>}
{entry.installed && !entry.enabled && (
<Badge tone="outline">disabled</Badge>
)}
@ -875,9 +832,7 @@ export default function McpPage() {
) : (
<code className="font-mono">{entry.install_url}</code>
)}
{entry.install_ref && (
<span> @ {entry.install_ref}</span>
)}
{entry.install_ref && <span> @ {entry.install_ref}</span>}
</p>
)}
{entry.bootstrap.length > 0 && (
@ -887,7 +842,10 @@ export default function McpPage() {
</summary>
<ul className="mt-1 ml-3 list-disc space-y-0.5">
{entry.bootstrap.map((cmd, i) => (
<li key={`${entry.name}-bs-${i}`} className="break-all">
<li
key={`${entry.name}-bs-${i}`}
className="break-all"
>
<code className="font-mono">{cmd}</code>
</li>
))}

View file

@ -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<McpServerCreate[]>([]);
const [mcpDraft, setMcpDraft] = useState<{
name: string;
url: string;
command: string;
args: string;
}>({ name: "", url: "", command: "", args: "" });
const [mcpDraft, setMcpDraft] = useState<McpServerDraft>(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<HTMLInputElement>) => setName(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setName(e.target.value)
}
/>
{name && !nameValid && (
<p className="text-xs text-destructive">
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.
</p>
)}
</div>
@ -313,7 +349,8 @@ export default function ProfileBuilderPage() {
{step === "model" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Pick the model+provider for this profile. Skip to use the default.
Pick the model+provider for this profile. Skip to use the
default.
</p>
<Input
placeholder="Filter models…"
@ -343,7 +380,9 @@ export default function ProfileBuilderPage() {
onClick={() => 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 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
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.
</p>
<Input
placeholder="Filter skills…"
@ -377,7 +417,9 @@ export default function ProfileBuilderPage() {
}
/>
{skills === null ? (
<p className="text-sm text-muted-foreground">Loading skills</p>
<p className="text-sm text-muted-foreground">
Loading skills
</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto">
{filteredSkills.map((s) => (
@ -423,7 +465,11 @@ export default function ProfileBuilderPage() {
if (e.key === "Enter") runHubSearch();
}}
/>
<Button outlined onClick={runHubSearch} disabled={hubSearching}>
<Button
outlined
onClick={runHubSearch}
disabled={hubSearching}
>
{hubSearching ? "Searching…" : "Search"}
</Button>
</div>
@ -473,73 +519,243 @@ export default function ProfileBuilderPage() {
)}
{step === "mcp" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Add MCP servers for this profile. HTTP servers take a URL; stdio servers take a command + args.
</p>
<div className="grid grid-cols-2 gap-2">
<Input
placeholder="Server name"
value={mcpDraft.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, name: e.target.value })
}
/>
<Input
placeholder="URL (https://…/mcp)"
value={mcpDraft.url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, url: e.target.value })
}
/>
<Input
placeholder="Command (e.g. npx)"
value={mcpDraft.command}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, command: e.target.value })
}
/>
<Input
placeholder="Args (space-separated)"
value={mcpDraft.args}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, args: e.target.value })
}
/>
</div>
<Button outlined onClick={addMcpDraft}>
Add server
</Button>
{mcpServers.length > 0 && (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="font-expanded text-base font-bold tracking-[0.04em]">
MCP servers
</h3>
<p className="text-sm text-muted-foreground">
Add MCP servers to give this profile access to external
tools and data.
</p>
</div>
<span
className="text-xs text-muted-foreground"
aria-live="polite"
>
{mcpServers.length} configured
</span>
</div>
<div className="space-y-4 border border-border bg-background/20 p-4 md:p-5">
<h4 className="font-medium">Add server</h4>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-name">Server name</Label>
<Input
id="pb-mcp-name"
placeholder="Enter server name"
value={mcpDraft.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, name: e.target.value })
}
/>
</div>
<div className="grid gap-1.5">
<Label>Transport</Label>
<div
className="grid grid-cols-2 border border-border bg-background/30 p-0.5"
role="group"
aria-label="MCP transport"
>
{(
[
["http", "HTTP/SSE"],
["stdio", "stdio"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={mcpDraft.transport === value}
className={cn(
"px-3 py-2 text-sm font-medium transition-colors",
mcpDraft.transport === value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
onClick={() => setMcpTransport(value)}
>
{label}
</button>
))}
</div>
</div>
</div>
{mcpDraft.transport === "http" ? (
<>
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-url">URL</Label>
<Input
id="pb-mcp-url"
placeholder="https://example.com/mcp"
value={mcpDraft.url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, url: e.target.value })
}
/>
</div>
<div className="grid gap-1.5">
<Label>Authentication</Label>
<div
className="grid grid-cols-3 border border-border bg-background/30 p-0.5 md:max-w-md"
role="group"
aria-label="HTTP authentication"
>
{(
[
["none", "None"],
["header", "Bearer token"],
["oauth", "OAuth"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={mcpDraft.httpAuth === value}
className={cn(
"px-2 py-2 text-sm font-medium transition-colors",
mcpDraft.httpAuth === value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
onClick={() => setMcpHttpAuth(value)}
>
{label}
</button>
))}
</div>
</div>
{mcpDraft.httpAuth === "header" && (
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-bearer-token">
Bearer token
</Label>
<Input
id="pb-mcp-bearer-token"
type="password"
autoComplete="new-password"
placeholder="Token or Bearer token"
value={mcpDraft.bearerToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({
...mcpDraft,
bearerToken: e.target.value,
})
}
/>
<p className="text-xs text-muted-foreground">
Stored in the new profile&apos;s .env; config.yaml
keeps only an environment-variable reference.
</p>
</div>
)}
{mcpDraft.httpAuth === "oauth" && (
<p className="text-xs text-muted-foreground">
After creating the profile, open its MCP page and use
Authenticate to complete OAuth.
</p>
)}
</>
) : (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-command">Command</Label>
<Input
id="pb-mcp-command"
placeholder="npx"
value={mcpDraft.command}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({
...mcpDraft,
command: e.target.value,
})
}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-args">Arguments</Label>
<Input
id="pb-mcp-args"
placeholder="-y @modelcontextprotocol/server"
value={mcpDraft.args}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, args: e.target.value })
}
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-env">
Environment (KEY=VALUE per line)
</Label>
<textarea
id="pb-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:border-foreground/25 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30"
placeholder={"API_KEY=secret\nDEBUG=1"}
value={mcpDraft.env}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setMcpDraft({ ...mcpDraft, env: e.target.value })
}
/>
</div>
</>
)}
<div className="flex justify-end">
<Button onClick={addMcpDraft}>Add server</Button>
</div>
</div>
{mcpServers.length > 0 && (
<div className="space-y-2">
{mcpServers.map((s) => (
<div
key={s.name}
className="flex items-center justify-between rounded bg-muted px-3 py-1.5 text-sm"
className="flex items-center justify-between gap-4 border border-border bg-muted/40 p-4 text-sm"
>
<span>
<span className="font-medium">{s.name}</span>{" "}
<span className="text-xs text-muted-foreground">
{s.url || `${s.command} ${(s.args || []).join(" ")}`}
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{s.name}</span>
<Badge tone="outline">
{s.url ? "HTTP" : "stdio"}
</Badge>
{s.auth && (
<Badge tone="outline">
auth: {s.auth === "header" ? "bearer" : s.auth}
</Badge>
)}
</span>
<span className="mt-1 block break-all text-xs text-muted-foreground">
{s.url || [s.command, ...(s.args || [])].join(" ")}
</span>
</span>
<button
className="text-xs text-destructive"
<Button
size="sm"
ghost
destructive
className="shrink-0"
onClick={() => removeMcp(s.name)}
>
Remove
</button>
</Button>
</div>
))}
</div>
)}
</div>
)}
{step === "review" && (
<div className="space-y-3 text-sm">
<ReviewRow label="Name" value={name.trim() || "—"} />
<ReviewRow label="Description" value={description.trim() || "—"} />
<ReviewRow
label="Description"
value={description.trim() || "—"}
/>
<ReviewRow
label="Model"
value={pickedModel ? pickedModel.label : "Default (set later)"}
@ -566,7 +782,11 @@ export default function ProfileBuilderPage() {
)}
<ReviewRow
label="MCP servers"
value={mcpServers.length ? mcpServers.map((s) => s.name).join(", ") : "None"}
value={
mcpServers.length
? mcpServers.map((s) => s.name).join(", ")
: "None"
}
/>
</div>
)}
@ -589,7 +809,9 @@ export default function ProfileBuilderPage() {
) : (
<Button
disabled={!canAdvance}
onClick={() => setStep(STEPS[Math.min(STEPS.length - 1, stepIndex + 1)].id)}
onClick={() =>
setStep(STEPS[Math.min(STEPS.length - 1, stepIndex + 1)].id)
}
>
Next
</Button>