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:
parent
3bfa6001f7
commit
56ab9951b1
6 changed files with 695 additions and 248 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
web/src/lib/mcp-server-create.test.ts
Normal file
99
web/src/lib/mcp-server-create.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
78
web/src/lib/mcp-server-create.ts
Normal file
78
web/src/lib/mcp-server-create.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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's .env; config.yaml keeps only
|
||||
an environment-variable reference.
|
||||
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.
|
||||
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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue