-
- {value.name || `Subagent ${index + 1}`}
-
+
+
+ {value.name}
+
+
+ ref: {value.agent_ref}
+
+
-
-
-
- Name
-
- update({ name: e.target.value })}
- placeholder="researcher"
- />
-
-
-
- Model
-
- update({ model: e.target.value || undefined })}
- placeholder="openai:gpt-4o (optional)"
- />
-
+
+
+ Name
+
+
-
+
Description
update({ description: e.target.value })}
placeholder="Describe what this subagent does"
/>
-
-
-
- Instructions
-
-
-
-
-
-
update({ tools })}
- placeholder="Add tool…"
- />
-
- update({ skills })}
- placeholder="Add skill…"
- />
);
}
diff --git a/src/application/components/chat/JsonBlock.tsx b/src/application/components/chat/JsonBlock.tsx
new file mode 100644
index 0000000..347cbb4
--- /dev/null
+++ b/src/application/components/chat/JsonBlock.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useRef, useState } from "react";
+import { Check, Copy } from "lucide-react";
+import { Button } from "@/application/components/ui/button";
+import { cn } from "@/application/lib/utils";
+
+interface JsonBlockProps {
+ content: string | null;
+ maxHeightClassName?: string;
+ id?: string;
+}
+
+export default function JsonBlock({
+ content,
+ maxHeightClassName = "max-h-96",
+ id,
+}: Readonly
) {
+ const [copied, setCopied] = useState(false);
+ const timerRef = useRef | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ };
+ }, []);
+
+ if (content == null || content === "") return null;
+
+ let displayText = content;
+ try {
+ const parsed = JSON.parse(content);
+ displayText = JSON.stringify(parsed, null, 2);
+ } catch {
+ displayText = content;
+ }
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(displayText);
+ setCopied(true);
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => setCopied(false), 1500);
+ } catch {
+ /* ignore */
+ }
+ };
+
+ return (
+
+
+ Result content
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? "Copied" : "Copy"}
+
+
+
+ {displayText}
+
+
+ );
+}
diff --git a/src/application/components/chat/ToolCallBadge.tsx b/src/application/components/chat/ToolCallBadge.tsx
index d5a96cb..3427c67 100644
--- a/src/application/components/chat/ToolCallBadge.tsx
+++ b/src/application/components/chat/ToolCallBadge.tsx
@@ -46,7 +46,7 @@ export default function ToolCallBadge({ name, args, source }: Readonly
{args}
diff --git a/src/application/components/chat/ToolResultBlock.tsx b/src/application/components/chat/ToolResultBlock.tsx
index e9d624c..e2164c0 100644
--- a/src/application/components/chat/ToolResultBlock.tsx
+++ b/src/application/components/chat/ToolResultBlock.tsx
@@ -42,7 +42,7 @@ export default function ToolResultBlock({ name, content, source }: Readonly
{content}
diff --git a/src/application/components/layout/Sidebar.tsx b/src/application/components/layout/Sidebar.tsx
index 1c01a0f..c44cdd7 100644
--- a/src/application/components/layout/Sidebar.tsx
+++ b/src/application/components/layout/Sidebar.tsx
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { NavLink } from "react-router-dom";
-import { BookOpen, Bot, Database, MessagesSquare, Settings, Sparkles, X } from "lucide-react";
+import { BookOpen, Bot, Database, MessagesSquare, Server, Settings, Sparkles, X } from "lucide-react";
import { cn } from "@/application/lib/utils";
import { useSidebarStore } from "@/application/stores/useSidebarStore";
@@ -17,6 +17,7 @@ const NAV_ITEMS: readonly NavItem[] = [
{ to: "/skills", label: "Skills", icon: Sparkles, odId: "nav-skills" },
{ to: "/memories", label: "Memories", icon: BookOpen, odId: "nav-memories" },
{ to: "/rag", label: "RAG Storage", icon: Database, odId: "nav-rag" },
+ { to: "/mcp-registry", label: "MCP Registry", icon: Server, odId: "nav-mcp-registry" },
] as const;
const SYSTEM_ITEMS: readonly NavItem[] = [
@@ -27,13 +28,7 @@ const NAV_LINK_BASE =
"flex w-full items-center gap-3 border border-transparent border-l-[3px] border-l-transparent px-4 py-3 text-left font-body text-base font-medium text-fg-2 transition-[background-color,border-color,color] duration-fast ease-standard hover:bg-surface-warm hover:border-l-border hover:text-fg focus-visible:outline-none focus-visible:shadow-focus";
const NAV_LINK_ACTIVE = "border-l-accent bg-surface-warm text-accent";
-function SidebarLink({
- to,
- label,
- icon: Icon,
- odId,
- onClick,
-}: NavItem & { onClick: () => void }) {
+function SidebarLink({ to, label, icon: Icon, odId, onClick }: NavItem & { onClick: () => void }) {
return (
void;
+ readonly editServer?: RegisteredMcpServer;
+}
+
+const FORM_ID = "mcp-server-form";
+
+/**
+ * Dialog to create or edit a registered MCP server. The form collects the
+ * registry fields and submits via {@link useCreateMcpServer} (create mode) or
+ * {@link useUpdateMcpServer} (edit mode). A "Test connection" button calls
+ * {@link useValidateMcpServer} and surfaces the tool count on success.
+ *
+ * The top-level "Type" select switches between an `external` remote MCP
+ * server (url + headers + env + auth_token) and an `openapi` spec-mounted
+ * server (openapi_url + headers — no env, no auth_token).
+ */
+export function CreateMcpServerDialog({
+ open,
+ onOpenChange,
+ editServer,
+}: Readonly) {
+ const isEdit = Boolean(editServer);
+ const [sourceType, setSourceType] = useState(
+ editServer?.source_type ?? "external",
+ );
+ const [name, setName] = useState(editServer?.name ?? "");
+ const [url, setUrl] = useState(editServer?.url ?? "");
+ const [openapiUrl, setOpenapiUrl] = useState(editServer?.openapi_url ?? "");
+ const [headers, setHeaders] = useState>(editServer?.headers ?? {});
+ const [env, setEnv] = useState>(editServer?.env ?? {});
+ const [authToken, setAuthToken] = useState(editServer?.auth_token ?? "");
+ const [validationMessage, setValidationMessage] = useState(null);
+ const [validationError, setValidationError] = useState(null);
+
+ const createServer = useCreateMcpServer();
+ const updateServer = useUpdateMcpServer();
+ const validateServer = useValidateMcpServer();
+
+ const isOpenapi = sourceType === "openapi";
+ const submitLabel = isEdit ? "Save" : "Create";
+
+ function resetForm() {
+ setSourceType("external");
+ setName("");
+ setUrl("");
+ setOpenapiUrl("");
+ setHeaders({});
+ setEnv({});
+ setAuthToken("");
+ setValidationMessage(null);
+ setValidationError(null);
+ }
+
+ function handleClose() {
+ resetForm();
+ onOpenChange(false);
+ }
+
+ function buildInput(): McpServerInput {
+ return {
+ name: name.trim(),
+ url: isOpenapi ? null : url.trim(),
+ headers,
+ env: isOpenapi ? {} : env,
+ auth_token: isOpenapi ? null : authToken,
+ source_type: sourceType,
+ openapi_url: isOpenapi ? openapiUrl.trim() : null,
+ };
+ }
+
+ async function handleTestConnection() {
+ setValidationMessage(null);
+ setValidationError(null);
+ try {
+ const result = await validateServer.mutateAsync(buildInput());
+ setValidationMessage(`✓ ${result.tool_count} tools`);
+ } catch (error) {
+ const message = extractApiMessage(error);
+ setValidationError(message);
+ }
+ }
+
+ function handleSubmit(e: SyntheticEvent) {
+ e.preventDefault();
+ if (!name.trim()) {
+ toast.error("Server name is required");
+ return;
+ }
+ if (isOpenapi) {
+ if (!openapiUrl.trim()) {
+ toast.error("OpenAPI URL is required");
+ return;
+ }
+ } else if (!url.trim()) {
+ toast.error("Server URL is required");
+ return;
+ }
+
+ const input = buildInput();
+ const onSuccess = () => {
+ toast.success(isEdit ? "Server updated successfully" : "Server created successfully");
+ handleClose();
+ };
+ const onError = (error: unknown) => {
+ toast.error(extractApiMessage(error));
+ };
+
+ if (isEdit && editServer) {
+ updateServer.mutate({ name: editServer.name, input }, { onSuccess, onError });
+ } else {
+ createServer.mutate(input, { onSuccess, onError });
+ }
+ }
+
+ const isPending = createServer.isPending || updateServer.isPending;
+
+ return (
+ {
+ if (!v) handleClose();
+ }}
+ >
+
+
+
+
+ {isEdit ? "Edit MCP Server" : "Create MCP Server"}
+
+
+ Register a remote MCP server. Secrets are stored encrypted and revealed only on
+ demand.
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ {isPending ? "Saving…" : submitLabel}
+
+
+
+
+ );
+}
+
+export default CreateMcpServerDialog;
diff --git a/src/application/components/mcpServer/McpServerCard.tsx b/src/application/components/mcpServer/McpServerCard.tsx
new file mode 100644
index 0000000..c3c2a86
--- /dev/null
+++ b/src/application/components/mcpServer/McpServerCard.tsx
@@ -0,0 +1,75 @@
+import { Pencil, Trash2 } from "lucide-react";
+import type { RegisteredMcpServer } from "@/domain/entities/mcpServer/registeredMcpServer";
+import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+import { Badge } from "@/application/components/ui/badge";
+import { Button } from "@/application/components/ui/button";
+
+interface McpServerCardProps {
+ readonly server: RegisteredMcpServer;
+ readonly onEdit: (server: RegisteredMcpServer) => void;
+ readonly onDelete: (name: string) => void;
+}
+
+/**
+ * Presentational card for a single registered MCP server. Mirrors the
+ * SkillCard visual treatment (border + block-shadow-raised + hover lift).
+ * Renders a source-type badge (External vs OpenAPI) and, for OpenAPI
+ * servers, the spec URL.
+ */
+export function McpServerCard({ server, onEdit, onDelete }: Readonly) {
+ const isHttp = server.transport === McpTransportType.HTTP;
+ const isOpenapi = server.source_type === "openapi";
+ const sourceTypeLabel = isOpenapi ? "OpenAPI" : "External";
+ const displayedUrl = isOpenapi && server.openapi_url ? server.openapi_url : (server.url ?? "—");
+
+ return (
+
+
+
+ {server.name}
+
+
+
+ {sourceTypeLabel}
+
+ {server.transport}
+
+
+
+
+ {displayedUrl}
+
+
+
{server.tool_count} tools
+
Created {server.created_at.slice(0, 10)}
+
+
+
onEdit(server)}
+ aria-label="Edit"
+ >
+
+ Edit
+
+
onDelete(server.name)}
+ aria-label="Delete"
+ >
+
+ Delete
+
+
+
+ );
+}
+
+export default McpServerCard;
diff --git a/src/application/components/mcpServer/McpServerGrid.tsx b/src/application/components/mcpServer/McpServerGrid.tsx
new file mode 100644
index 0000000..86c911b
--- /dev/null
+++ b/src/application/components/mcpServer/McpServerGrid.tsx
@@ -0,0 +1,89 @@
+import { Plus } from "lucide-react";
+import { useMcpRegistry } from "@/application/hooks/mcpServer/useMcpRegistry";
+import McpServerCard from "@/application/components/mcpServer/McpServerCard";
+import type { RegisteredMcpServer } from "@/domain/entities/mcpServer/registeredMcpServer";
+
+interface McpServerGridProps {
+ readonly onCreateNew: () => void;
+ readonly onEdit: (server: RegisteredMcpServer) => void;
+ readonly onDelete: (name: string) => void;
+}
+
+/**
+ * Grid of registered MCP servers. Mirrors the SkillGrid / MemoryGrid layout:
+ * a responsive grid of server cards followed by a dashed "create" card, which
+ * is also rendered when the registry is empty (so users always have an entry
+ * point to create a new server from the grid itself).
+ */
+export default function McpServerGrid({
+ onCreateNew,
+ onEdit,
+ onDelete,
+}: Readonly) {
+ const { data: servers, isLoading, error } = useMcpRegistry();
+
+ if (isLoading) {
+ return (
+
+
+
Loading MCP servers...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Failed to load MCP servers: {error.message}
+
+
+ );
+ }
+
+ return (
+
+ {(servers ?? []).map((server) => (
+
+ ))}
+
+
+ );
+}
+
+function McpServerCreateButton({ onClick }: Readonly<{ onClick: () => void }>) {
+ return (
+
+
+
+
+ New MCP Server
+
+ );
+}
diff --git a/src/application/components/memory/MemoryCard.tsx b/src/application/components/memory/MemoryCard.tsx
index acdab17..7b023ee 100644
--- a/src/application/components/memory/MemoryCard.tsx
+++ b/src/application/components/memory/MemoryCard.tsx
@@ -47,4 +47,4 @@ export function MemoryCard({ name, preview, onConfigure }: Readonly,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
-
+
{children}
));
diff --git a/src/application/components/ui/input.tsx b/src/application/components/ui/input.tsx
index 243a655..108dd42 100644
--- a/src/application/components/ui/input.tsx
+++ b/src/application/components/ui/input.tsx
@@ -8,7 +8,7 @@ const Input = React.forwardRef>(
mcpRegistryApi.create(input),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["mcp-registry"] });
+ },
+ });
+}
diff --git a/src/application/hooks/mcpServer/useDeleteMcpServer.ts b/src/application/hooks/mcpServer/useDeleteMcpServer.ts
new file mode 100644
index 0000000..192eafa
--- /dev/null
+++ b/src/application/hooks/mcpServer/useDeleteMcpServer.ts
@@ -0,0 +1,17 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { mcpRegistryApi } from "@/infrastructure/api/mcpServer/mcpRegistryApi";
+
+/**
+ * Delete a registered MCP server. Invalidates the `["mcp-registry"]` query
+ * on success so list views refetch.
+ */
+export function useDeleteMcpServer() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (name: string) => mcpRegistryApi.delete(name),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["mcp-registry"] });
+ },
+ });
+}
diff --git a/src/application/hooks/mcpServer/useMcpRegistry.ts b/src/application/hooks/mcpServer/useMcpRegistry.ts
new file mode 100644
index 0000000..2652de1
--- /dev/null
+++ b/src/application/hooks/mcpServer/useMcpRegistry.ts
@@ -0,0 +1,13 @@
+import { useQuery } from "@tanstack/react-query";
+import { mcpRegistryApi } from "@/infrastructure/api/mcpServer/mcpRegistryApi";
+
+/**
+ * List all registered MCP servers. Cached under the `["mcp-registry"]` key;
+ * mutations (create/update/delete) invalidate it.
+ */
+export function useMcpRegistry() {
+ return useQuery({
+ queryKey: ["mcp-registry"],
+ queryFn: () => mcpRegistryApi.list(),
+ });
+}
diff --git a/src/application/hooks/mcpServer/useUpdateMcpServer.ts b/src/application/hooks/mcpServer/useUpdateMcpServer.ts
new file mode 100644
index 0000000..79f6a10
--- /dev/null
+++ b/src/application/hooks/mcpServer/useUpdateMcpServer.ts
@@ -0,0 +1,19 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { mcpRegistryApi } from "@/infrastructure/api/mcpServer/mcpRegistryApi";
+import type { McpServerInput } from "@/domain/entities/mcpServer/registeredMcpServer";
+
+/**
+ * Update an existing MCP server. Invalidates the `["mcp-registry"]` query on
+ * success so list views refetch.
+ */
+export function useUpdateMcpServer() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ name, input }: { name: string; input: McpServerInput }) =>
+ mcpRegistryApi.update(name, input),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["mcp-registry"] });
+ },
+ });
+}
diff --git a/src/application/hooks/mcpServer/useValidateMcpServer.ts b/src/application/hooks/mcpServer/useValidateMcpServer.ts
new file mode 100644
index 0000000..e220b15
--- /dev/null
+++ b/src/application/hooks/mcpServer/useValidateMcpServer.ts
@@ -0,0 +1,13 @@
+import { useMutation } from "@tanstack/react-query";
+import { mcpRegistryApi } from "@/infrastructure/api/mcpServer/mcpRegistryApi";
+import type { McpServerInput } from "@/domain/entities/mcpServer/registeredMcpServer";
+
+/**
+ * Validate (test connection) an MCP server config. Returns the tool count on
+ * success. Does NOT invalidate the registry — validation is read-only.
+ */
+export function useValidateMcpServer() {
+ return useMutation({
+ mutationFn: (input: McpServerInput) => mcpRegistryApi.validate(input),
+ });
+}
diff --git a/src/application/hooks/memory/useCreateMemory.ts b/src/application/hooks/memory/useCreateMemory.ts
index b7e700c..b2f9a67 100644
--- a/src/application/hooks/memory/useCreateMemory.ts
+++ b/src/application/hooks/memory/useCreateMemory.ts
@@ -35,6 +35,7 @@ export function useCreateMemory() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["store-files"] });
+ queryClient.invalidateQueries({ queryKey: ["store-file-previews"] });
},
});
}
diff --git a/src/application/hooks/skill/useCreateSkill.ts b/src/application/hooks/skill/useCreateSkill.ts
index 8018a57..c506a09 100644
--- a/src/application/hooks/skill/useCreateSkill.ts
+++ b/src/application/hooks/skill/useCreateSkill.ts
@@ -39,6 +39,7 @@ export function useCreateSkill() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["store-files"] });
+ queryClient.invalidateQueries({ queryKey: ["store-file-previews"] });
},
});
}
diff --git a/src/application/hooks/store/useDeleteStoreFile.ts b/src/application/hooks/store/useDeleteStoreFile.ts
index 90d12fe..c760d41 100644
--- a/src/application/hooks/store/useDeleteStoreFile.ts
+++ b/src/application/hooks/store/useDeleteStoreFile.ts
@@ -12,6 +12,7 @@ export function useDeleteStoreFile() {
mutationFn: (path: string) => storeApi.deleteFile(path),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["store-files"] });
+ queryClient.invalidateQueries({ queryKey: ["store-file-previews"] });
},
});
}
diff --git a/src/application/hooks/store/usePutStoreFile.ts b/src/application/hooks/store/usePutStoreFile.ts
index 23de2bd..c73b16b 100644
--- a/src/application/hooks/store/usePutStoreFile.ts
+++ b/src/application/hooks/store/usePutStoreFile.ts
@@ -13,6 +13,7 @@ export function usePutStoreFile() {
storeApi.putFile(path, content),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["store-files"] });
+ queryClient.invalidateQueries({ queryKey: ["store-file-previews"] });
queryClient.invalidateQueries({ queryKey: ["store-file", variables.path] });
},
});
diff --git a/src/application/index.css b/src/application/index.css
index a047937..5f12214 100644
--- a/src/application/index.css
+++ b/src/application/index.css
@@ -25,17 +25,20 @@
--warn: #b45309;
--danger: #b91c1c;
- --font-display: "Press Start 2P", "Arial Black", system-ui, sans-serif;
- --font-body: "Press Start 2P", "Arial Black", system-ui, sans-serif;
- --font-mono: "Press Start 2P", ui-monospace, monospace;
+ /* Default font stack — the source of truth for the app's base family.
+ All --font-* tokens derive from --app-font-family (set by the Settings
+ page slider) so every font-display/font-body/font-mono utility follows
+ the user-selected family, including the sidebar and code blocks. */
+ --font-app-default: "Press Start 2P", "Arial Black", system-ui, sans-serif;
- /* App-wide typography scale (driven by Settings page font-size slider).
- Default scale = 1 (12px baseline). --app-font-family overrides body font.
- --chat-font-size is consumed by chat bubbles. */
--app-font-scale: 1;
- --app-font-family: var(--font-body);
+ --app-font-family: var(--font-app-default);
--chat-font-size: 12px;
+ --font-display: var(--app-font-family);
+ --font-body: var(--app-font-family);
+ --font-mono: var(--app-font-family);
+
/* Text scale tokens are scaled by --app-font-scale so every Tailwind
text-* utility (text-xs, text-sm, text-base, …) follows the slider. */
--text-xs: calc(10px * var(--app-font-scale));
diff --git a/src/application/lib/color.ts b/src/application/lib/color.ts
index cb26994..b3ca36b 100644
--- a/src/application/lib/color.ts
+++ b/src/application/lib/color.ts
@@ -58,4 +58,4 @@ export function accentOnFor(hex: string): string {
export function isValidHex(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value);
-}
\ No newline at end of file
+}
diff --git a/src/application/lib/yaml.ts b/src/application/lib/yaml.ts
index c1149cc..5127928 100644
--- a/src/application/lib/yaml.ts
+++ b/src/application/lib/yaml.ts
@@ -13,6 +13,7 @@ function cleanSubagent(sub: AgentConfig["subagents"][number]): PlainRecord {
mcp_servers: sub.mcp_servers,
};
+ if (sub.agent_ref) result.agent_ref = sub.agent_ref;
if (sub.instructions) result.instructions = sub.instructions;
if (sub.model) result.model = sub.model;
if (sub.response_format && Object.keys(sub.response_format).length > 0) {
@@ -55,6 +56,9 @@ export function serializeAgentConfig(config: AgentConfig): string {
if (config.system_prompt) {
clean.system_prompt = config.system_prompt;
}
+ if (config.description) {
+ clean.description = config.description;
+ }
if (config.system_prompt_file) {
clean.system_prompt_file = config.system_prompt_file;
}
diff --git a/src/application/pages/AgentsPage.tsx b/src/application/pages/AgentsPage.tsx
index 131c2a2..3cf57d0 100644
--- a/src/application/pages/AgentsPage.tsx
+++ b/src/application/pages/AgentsPage.tsx
@@ -14,7 +14,6 @@ export default function AgentsPage() {
const { data: agents } = useAgents();
const total = agents?.length ?? 0;
- const active = agents?.filter((a) => a.is_builtin).length ?? 0;
return (
@@ -29,7 +28,7 @@ export default function AgentsPage() {
- {total} agents · {active} active
+ {total} agents
(undefined);
+ const { data: servers } = useMcpRegistry();
+ const deleteServer = useDeleteMcpServer();
+
+ const total = (servers ?? []).length;
+
+ function openCreate() {
+ setEditServer(undefined);
+ setCreateDialogOpen(true);
+ }
+
+ async function openEdit(server: RegisteredMcpServer) {
+ try {
+ const revealed = await mcpRegistryApi.reveal(server.name);
+ setEditServer(revealed);
+ } catch (err) {
+ toast.error(`Could not load server details: ${extractApiMessage(err)}`);
+ setEditServer(server);
+ }
+ setCreateDialogOpen(true);
+ }
+
+ function handleDelete(name: string) {
+ deleteServer.mutate(name, {
+ onSuccess: () => {
+ toast.success(`Server "${name}" deleted`);
+ },
+ onError: (err) => {
+ toast.error(extractApiMessage(err));
+ },
+ });
+ }
+
+ return (
+
+
+
+
+ Register and manage remote MCP servers. Each entry stores the endpoint URL and secrets
+ (encrypted) so agents can reference them by name.
+
+
+
+ {total} server{total === 1 ? "" : "s"}
+
+
+
+ Add MCP Server
+
+
+
+
+
+
+
+ {createDialogOpen && (
+ {
+ if (!v) setCreateDialogOpen(false);
+ }}
+ editServer={editServer}
+ />
+ )}
+
+ );
+}
diff --git a/src/application/pages/SettingsPage.tsx b/src/application/pages/SettingsPage.tsx
index 2a35aca..51c3fb6 100644
--- a/src/application/pages/SettingsPage.tsx
+++ b/src/application/pages/SettingsPage.tsx
@@ -192,9 +192,11 @@ export default function SettingsPage() {
- Press Start 2P (default)
+
+ Press Start 2P (default)
+
Monospace
- Mono
+ Mono
Georgia
@@ -232,7 +234,7 @@ export default function SettingsPage() {
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
-
+
Stored locally in this browser session only.
@@ -258,4 +260,4 @@ export default function SettingsPage() {
);
-}
\ No newline at end of file
+}
diff --git a/src/application/stores/useSettingsStore.ts b/src/application/stores/useSettingsStore.ts
index d3dd516..d09fa7c 100644
--- a/src/application/stores/useSettingsStore.ts
+++ b/src/application/stores/useSettingsStore.ts
@@ -6,16 +6,32 @@ export type Theme = "dark" | "light";
const STORAGE_KEY = "composable-ui-settings";
const THEME_SYNC_KEY = "composable-ui-theme";
+// Concrete font stacks — the value stored in the store/UI. We no longer use
+// CSS var() references here because --font-body/--font-mono now derive from
+// --app-font-family, so storing `var(--font-body)` would create a circular
+// reference. Old persisted values are migrated on load (see migrateFontFamily).
+export const FONT_DEFAULT = '"Press Start 2P", "Arial Black", system-ui, sans-serif';
+export const FONT_MONO = '"Press Start 2P", ui-monospace, monospace';
+
const DEFAULTS = {
accent: "#ff00ff",
surface: "#10162a",
chatFontSize: 10,
- chatFontFamily: "var(--font-body)",
+ chatFontFamily: FONT_DEFAULT,
llmProvider: "anthropic",
apiKey: "",
theme: "dark" as Theme,
};
+// Map legacy persisted values (pre-concrete-stacks) to the new concrete stacks.
+// Without this, returning users would get `var(--font-body)` applied as the
+// literal family string, which no longer resolves correctly.
+function migrateFontFamily(value: string | undefined): string | undefined {
+ if (value === "var(--font-body)") return FONT_DEFAULT;
+ if (value === "var(--font-mono)") return FONT_MONO;
+ return value;
+}
+
interface SettingsState {
accent: string;
surface: string;
@@ -40,7 +56,12 @@ function readStoredSettings(): Partial
{
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as { state?: Partial };
- if (parsed.state) return parsed.state;
+ if (parsed.state) {
+ const migrated = { ...parsed.state };
+ const migratedFont = migrateFontFamily(migrated.chatFontFamily);
+ if (migratedFont !== undefined) migrated.chatFontFamily = migratedFont;
+ return migrated;
+ }
}
} catch {
// localStorage may be unavailable (private mode, SSR, jsdom edge cases)
diff --git a/src/domain/entities/agent/agentConfig.ts b/src/domain/entities/agent/agentConfig.ts
index e87d0bd..9740552 100644
--- a/src/domain/entities/agent/agentConfig.ts
+++ b/src/domain/entities/agent/agentConfig.ts
@@ -21,6 +21,7 @@ export interface HITLConfig {
export interface SubAgentConfig {
name: string;
description: string;
+ agent_ref?: string;
instructions?: string;
model?: string;
tools: string[];
@@ -31,6 +32,7 @@ export interface SubAgentConfig {
export interface AgentConfig {
name: string;
+ description?: string;
model: string;
system_prompt?: string;
system_prompt_file?: string;
diff --git a/src/domain/entities/agent/agentConfigMetadata.ts b/src/domain/entities/agent/agentConfigMetadata.ts
index 64f15b9..339b739 100644
--- a/src/domain/entities/agent/agentConfigMetadata.ts
+++ b/src/domain/entities/agent/agentConfigMetadata.ts
@@ -1,8 +1,8 @@
export interface AgentConfigMetadata {
name: string;
model: string;
+ description?: string | null;
minio_path: string;
- is_builtin: boolean;
created_at: string;
updated_at: string;
}
diff --git a/src/domain/entities/agent/agentConfigSchema.ts b/src/domain/entities/agent/agentConfigSchema.ts
index 5aa3981..3b73bda 100644
--- a/src/domain/entities/agent/agentConfigSchema.ts
+++ b/src/domain/entities/agent/agentConfigSchema.ts
@@ -32,6 +32,7 @@ export const mcpServerConfigSchema = z.object({
export const subAgentConfigSchema = z.object({
name: z.string().min(1, "Subagent name is required"),
description: z.string().min(1, "Subagent description is required"),
+ agent_ref: z.string().nullable().optional(),
instructions: z.string().nullable().optional(),
model: z.string().nullable().optional(),
tools: z.array(z.string()),
@@ -51,6 +52,7 @@ export const agentConfigSchema = z
"Agent name must contain only alphanumeric characters, dots, hyphens, and underscores",
),
model: z.string().min(1, "Model is required"),
+ description: z.string().nullable().optional(),
system_prompt: z.string().nullable().optional(),
system_prompt_file: z.string().nullable().optional(),
tools: z.array(z.string()),
diff --git a/src/domain/entities/config/appConfig.ts b/src/domain/entities/config/appConfig.ts
index 4f131fa..c69dded 100644
--- a/src/domain/entities/config/appConfig.ts
+++ b/src/domain/entities/config/appConfig.ts
@@ -4,6 +4,7 @@ export const AppConfigSchema = z.object({
apiBaseUrl: z.string().url(),
wsBaseUrl: z.string().url(),
ragApiBaseUrl: z.string().url().or(z.literal("")).optional().default(""),
+ mcpApiBaseUrl: z.string().url().or(z.literal("")).optional().default(""),
});
export type AppConfig = z.infer;
diff --git a/src/domain/entities/mcpServer/registeredMcpServer.ts b/src/domain/entities/mcpServer/registeredMcpServer.ts
new file mode 100644
index 0000000..c0ad19a
--- /dev/null
+++ b/src/domain/entities/mcpServer/registeredMcpServer.ts
@@ -0,0 +1,68 @@
+import type { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+
+/**
+ * How a registered MCP server is provisioned.
+ *
+ * - `"external"` — a remote MCP server reachable over HTTP/STDIO.
+ * - `"openapi"` — an MCP server mounted from an OpenAPI spec (no MCP auth_token).
+ */
+export type McpSourceType = "external" | "openapi";
+
+/**
+ * A registered MCP server as stored in the central registry.
+ *
+ * The backend returns a *masked* representation on list/get (secrets stripped
+ * to `null` / empty objects); the *revealed* representation (via the `/reveal`
+ * endpoint) populates `auth_token`, `headers`, and `env` in plaintext. Both
+ * shapes share the same interface — the only difference is whether the secret
+ * fields are populated.
+ */
+export interface RegisteredMcpServer {
+ /** Unique server name (also the registry key). */
+ readonly name: string;
+ /** Transport used to reach the server (STDIO or HTTP). */
+ readonly transport: McpTransportType;
+ /** Endpoint URL for HTTP transports; empty for STDIO. */
+ readonly url: string;
+ /** HTTP headers to send. `{}` when masked. */
+ readonly headers: Record;
+ /** Environment variables. `{}` when masked. */
+ readonly env: Record;
+ /** Auth token. `null` when masked; plaintext when revealed. */
+ readonly auth_token: string | null;
+ /** Number of tools exposed by the server. */
+ readonly tool_count: number;
+ /** ISO timestamp — registry creation time. */
+ readonly created_at: string;
+ /** ISO timestamp — last registry update. */
+ readonly updated_at: string;
+ /** How the server is provisioned (`"external"` remote MCP, `"openapi"` spec-mounted). */
+ readonly source_type: McpSourceType;
+ /** URL of the OpenAPI spec. `null` for `"external"` servers. */
+ readonly openapi_url: string | null;
+}
+
+/**
+ * Payload for create/update operations against the registry. Secrets are
+ * sent in plaintext; the backend masks them on the response.
+ *
+ * For `source_type="openapi"`, `url` and `auth_token` MUST be `null` (the
+ * mounted URL is generated by the backend and openapi servers have no MCP
+ * auth token). For `source_type="external"`, both are strings.
+ */
+export interface McpServerInput {
+ readonly name: string;
+ readonly url: string | null;
+ readonly headers: Record;
+ readonly env: Record;
+ readonly auth_token: string | null;
+ /** How the server is provisioned (`"external"` remote MCP, `"openapi"` spec-mounted). */
+ readonly source_type: McpSourceType;
+ /** URL of the OpenAPI spec. `null` for `"external"` servers. */
+ readonly openapi_url: string | null;
+}
+
+/** Result of a validation (test connection) call. */
+export interface McpServerValidationResult {
+ readonly tool_count: number;
+}
diff --git a/src/domain/ports/mcpServer/mcpRegistryPort.ts b/src/domain/ports/mcpServer/mcpRegistryPort.ts
new file mode 100644
index 0000000..bc04db1
--- /dev/null
+++ b/src/domain/ports/mcpServer/mcpRegistryPort.ts
@@ -0,0 +1,26 @@
+import type {
+ RegisteredMcpServer,
+ McpServerInput,
+ McpServerValidationResult,
+} from "@/domain/entities/mcpServer/registeredMcpServer";
+
+/**
+ * Port contract for the MCP server registry — the application layer depends
+ * on this interface, never on the axios adapter.
+ */
+export interface McpRegistryPort {
+ /** List all registered servers (masked). */
+ list(): Promise;
+ /** Get a single registered server (masked). */
+ get(name: string): Promise;
+ /** Get a single registered server with secrets revealed (plaintext). */
+ reveal(name: string): Promise;
+ /** Register a new server. Returns the masked entry. */
+ create(input: McpServerInput): Promise;
+ /** Update an existing server. Returns the masked entry. */
+ update(name: string, input: McpServerInput): Promise;
+ /** Delete a registered server. */
+ delete(name: string): Promise;
+ /** Validate (test connection) a server config; returns the tool count. */
+ validate(input: McpServerInput): Promise;
+}
diff --git a/src/infrastructure/api/axiosInstance.ts b/src/infrastructure/api/axiosInstance.ts
index fb122c7..71730f3 100644
--- a/src/infrastructure/api/axiosInstance.ts
+++ b/src/infrastructure/api/axiosInstance.ts
@@ -24,7 +24,9 @@ apiClient.interceptors.response.use(
(error) => {
if (error.response) {
const detail = error.response.data?.detail || error.message;
- return Promise.reject(new Error(detail));
+ const wrapped = new Error(detail) as Error & { status?: number };
+ wrapped.status = error.response.status;
+ return Promise.reject(wrapped);
}
return Promise.reject(error);
},
diff --git a/src/infrastructure/api/mcpAxiosInstance.ts b/src/infrastructure/api/mcpAxiosInstance.ts
new file mode 100644
index 0000000..876a5e2
--- /dev/null
+++ b/src/infrastructure/api/mcpAxiosInstance.ts
@@ -0,0 +1,28 @@
+import axios from "axios";
+import { configRepository } from "@/infrastructure/config/configRepositoryInstance";
+
+let cachedMcpBaseURL: string | null = null;
+
+export const mcpApiClient = axios.create({
+ timeout: 30000,
+});
+
+mcpApiClient.interceptors.request.use(async (config) => {
+ if (!cachedMcpBaseURL) {
+ const appConfig = await configRepository.getConfig();
+ cachedMcpBaseURL = appConfig.mcpApiBaseUrl || appConfig.ragApiBaseUrl || appConfig.apiBaseUrl;
+ }
+ config.baseURL = cachedMcpBaseURL;
+ return config;
+});
+
+mcpApiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response) {
+ const detail = error.response.data?.detail || error.message;
+ return Promise.reject(new Error(detail));
+ }
+ return Promise.reject(error);
+ },
+);
diff --git a/src/infrastructure/api/mcpServer/mcpRegistryApi.ts b/src/infrastructure/api/mcpServer/mcpRegistryApi.ts
new file mode 100644
index 0000000..c38b5b9
--- /dev/null
+++ b/src/infrastructure/api/mcpServer/mcpRegistryApi.ts
@@ -0,0 +1,57 @@
+import type {
+ RegisteredMcpServer,
+ McpServerInput,
+ McpServerValidationResult,
+} from "@/domain/entities/mcpServer/registeredMcpServer";
+import type { McpRegistryPort } from "@/domain/ports/mcpServer/mcpRegistryPort";
+import { mcpApiClient } from "@/infrastructure/api/mcpAxiosInstance";
+
+const BASE = "/api/v1/mcp/servers";
+
+/**
+ * Axios implementation of {@link McpRegistryPort}. Errors are propagated as
+ * `Error` instances by the axios response interceptor in {@link mcpApiClient};
+ * this adapter lets them bubble unchanged.
+ */
+export const mcpRegistryApi: McpRegistryPort = {
+ async list(): Promise {
+ const response = await mcpApiClient.get(BASE);
+ return response.data;
+ },
+
+ async get(name: string): Promise {
+ const response = await mcpApiClient.get(
+ `${BASE}/${encodeURIComponent(name)}`,
+ );
+ return response.data;
+ },
+
+ async reveal(name: string): Promise {
+ const response = await mcpApiClient.get(
+ `${BASE}/${encodeURIComponent(name)}/reveal`,
+ );
+ return response.data;
+ },
+
+ async create(input: McpServerInput): Promise {
+ const response = await mcpApiClient.post(BASE, input);
+ return response.data;
+ },
+
+ async update(name: string, input: McpServerInput): Promise {
+ const response = await mcpApiClient.put(
+ `${BASE}/${encodeURIComponent(name)}`,
+ input,
+ );
+ return response.data;
+ },
+
+ async delete(name: string): Promise {
+ await mcpApiClient.delete(`${BASE}/${encodeURIComponent(name)}`);
+ },
+
+ async validate(input: McpServerInput): Promise {
+ const response = await mcpApiClient.post(`${BASE}/validate`, input);
+ return response.data;
+ },
+};
diff --git a/src/infrastructure/api/store/storeApi.ts b/src/infrastructure/api/store/storeApi.ts
index ed6934f..03208f0 100644
--- a/src/infrastructure/api/store/storeApi.ts
+++ b/src/infrastructure/api/store/storeApi.ts
@@ -50,9 +50,12 @@ export const storeApi: IStorePort = {
};
function isNotFound(error: unknown): boolean {
- if (error && typeof error === "object" && "response" in error) {
- const status = (error as { response?: { status?: number } }).response?.status;
- return status === 404;
+ if (error && typeof error === "object") {
+ if ("status" in error && (error as { status?: number }).status === 404) return true;
+ if ("response" in error) {
+ const status = (error as { response?: { status?: number } }).response?.status;
+ return status === 404;
+ }
}
return false;
}
diff --git a/tests/fixtures/external.ts b/tests/fixtures/external.ts
index b15e88b..f765d6b 100644
--- a/tests/fixtures/external.ts
+++ b/tests/fixtures/external.ts
@@ -15,8 +15,8 @@ export function createAgentConfigMetadata(
return {
name: "test-agent",
model: "openai:anthropic/claude-haiku-4.5:nitro",
+ description: null,
minio_path: "composable-agents/test-agent.yaml",
- is_builtin: false,
created_at: "2026-04-06T10:00:00Z",
updated_at: "2026-04-06T10:00:00Z",
...overrides,
diff --git a/tests/unit/application/components/rag/ConfirmDeleteDialog.test.tsx b/tests/unit/application/components/rag/ConfirmDeleteDialog.test.tsx
index bdc6bc5..7a67142 100644
--- a/tests/unit/application/components/rag/ConfirmDeleteDialog.test.tsx
+++ b/tests/unit/application/components/rag/ConfirmDeleteDialog.test.tsx
@@ -49,4 +49,4 @@ describe("ConfirmDeleteDialog", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/application/components/rag/CreateFolderDialog.test.tsx b/tests/unit/application/components/rag/CreateFolderDialog.test.tsx
index d89612b..8ccc2f6 100644
--- a/tests/unit/application/components/rag/CreateFolderDialog.test.tsx
+++ b/tests/unit/application/components/rag/CreateFolderDialog.test.tsx
@@ -4,9 +4,7 @@ import CreateFolderDialog from "@/application/components/rag/CreateFolderDialog"
describe("CreateFolderDialog", () => {
it("renders_input_and_create_button", () => {
- render(
- {}} onCreate={() => {}} />,
- );
+ render( {}} onCreate={() => {}} />);
expect(screen.getByRole("textbox", { name: /folder/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument();
@@ -16,9 +14,7 @@ describe("CreateFolderDialog", () => {
const onCreate = vi.fn();
const onClose = vi.fn();
- render(
- ,
- );
+ render( );
fireEvent.change(screen.getByRole("textbox", { name: /folder/i }), {
target: { value: "myfolder" },
@@ -31,9 +27,7 @@ describe("CreateFolderDialog", () => {
it("cancel_calls_onClose", () => {
const onClose = vi.fn();
- render(
- {}} />,
- );
+ render( {}} />);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
@@ -43,12 +37,10 @@ describe("CreateFolderDialog", () => {
it("does_not_call_onCreate_when_folder_name_is_empty", () => {
const onCreate = vi.fn();
- render(
- {}} onCreate={onCreate} />,
- );
+ render( {}} onCreate={onCreate} />);
fireEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).not.toHaveBeenCalled();
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/application/components/rag/FileContentPanel.test.tsx b/tests/unit/application/components/rag/FileContentPanel.test.tsx
index d368f2e..e955ec7 100644
--- a/tests/unit/application/components/rag/FileContentPanel.test.tsx
+++ b/tests/unit/application/components/rag/FileContentPanel.test.tsx
@@ -76,4 +76,4 @@ describe("FileContentPanel", () => {
expect(screen.getByText("pdf")).toBeInTheDocument();
expect(screen.getByText("application/pdf")).toBeInTheDocument();
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/application/components/rag/FileRow.test.tsx b/tests/unit/application/components/rag/FileRow.test.tsx
index 8556717..4a00ee9 100644
--- a/tests/unit/application/components/rag/FileRow.test.tsx
+++ b/tests/unit/application/components/rag/FileRow.test.tsx
@@ -10,12 +10,7 @@ describe("FileRow", () => {
};
it("renders_delete_button_when_onDelete_provided", () => {
- render(
- {}}
- />,
- );
+ render( {}} />);
expect(screen.getByRole("button", { name: /delete report\.pdf/i })).toBeInTheDocument();
});
@@ -43,10 +38,8 @@ describe("FileRow", () => {
});
it("delete_button_is_disabled_when_indexing", () => {
- render(
- {}} isIndexing={true} />,
- );
+ render( {}} isIndexing={true} />);
expect(screen.getByRole("button", { name: /delete report\.pdf/i })).toBeDisabled();
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/application/components/rag/FolderRow.test.tsx b/tests/unit/application/components/rag/FolderRow.test.tsx
index f100161..b888877 100644
--- a/tests/unit/application/components/rag/FolderRow.test.tsx
+++ b/tests/unit/application/components/rag/FolderRow.test.tsx
@@ -9,12 +9,7 @@ describe("FolderRow", () => {
};
it("renders_delete_button_when_onDelete_provided", () => {
- render(
- {}}
- />,
- );
+ render( {}} />);
expect(screen.getByRole("button", { name: /delete docs/i })).toBeInTheDocument();
});
@@ -39,4 +34,4 @@ describe("FolderRow", () => {
expect(onDelete).toHaveBeenCalledTimes(1);
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/application/hooks/rag/useCreateFolder.test.tsx b/tests/unit/application/hooks/rag/useCreateFolder.test.tsx
index 94bb756..dbe26f9 100644
--- a/tests/unit/application/hooks/rag/useCreateFolder.test.tsx
+++ b/tests/unit/application/hooks/rag/useCreateFolder.test.tsx
@@ -61,4 +61,4 @@ describe("useCreateFolder", () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe("Folder exists");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/application/hooks/rag/useDeleteFile.test.tsx b/tests/unit/application/hooks/rag/useDeleteFile.test.tsx
index 6ca5740..75f22d3 100644
--- a/tests/unit/application/hooks/rag/useDeleteFile.test.tsx
+++ b/tests/unit/application/hooks/rag/useDeleteFile.test.tsx
@@ -61,4 +61,4 @@ describe("useDeleteFile", () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe("Not found");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/application/hooks/rag/useDeleteFolder.test.tsx b/tests/unit/application/hooks/rag/useDeleteFolder.test.tsx
index 2dccf44..b022e8e 100644
--- a/tests/unit/application/hooks/rag/useDeleteFolder.test.tsx
+++ b/tests/unit/application/hooks/rag/useDeleteFolder.test.tsx
@@ -61,4 +61,4 @@ describe("useDeleteFolder", () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe("Not empty");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/application/lib/yaml.test.ts b/tests/unit/application/lib/yaml.test.ts
index 94c7b51..8daf0d5 100644
--- a/tests/unit/application/lib/yaml.test.ts
+++ b/tests/unit/application/lib/yaml.test.ts
@@ -120,6 +120,74 @@ describe("serializeAgentConfig", () => {
// Assert
expect(yaml).toContain("checkpoint_backend: postgres");
});
+
+ it("serializes description when present", () => {
+ // Arrange — a config that carries a top-level description.
+ const config: AgentConfig = {
+ ...fullConfig,
+ description: "An agent that writes tests.",
+ } as AgentConfig;
+
+ // Act
+ const yaml = serializeAgentConfig(config);
+
+ // Assert — the description key must appear in the serialized YAML.
+ expect(yaml).toMatch(/^description:/m);
+ expect(yaml).toContain("An agent that writes tests.");
+ });
+
+ it("does NOT serialize description when undefined/empty", () => {
+ // Arrange — fullConfig has no top-level description.
+ // Act
+ const yaml = serializeAgentConfig(fullConfig);
+
+ // Assert — no top-level description key should be emitted.
+ expect(yaml).not.toMatch(/^description:/m);
+ });
+
+ it("serializes subagent agent_ref when present", () => {
+ // Arrange — a config with a subagent that references another agent.
+ const config: AgentConfig = {
+ ...fullConfig,
+ subagents: [
+ {
+ name: "researcher",
+ description: "Research sub-agent",
+ agent_ref: "researcher",
+ tools: [],
+ skills: [],
+ mcp_servers: [],
+ } as AgentConfig["subagents"][number],
+ ],
+ } as AgentConfig;
+
+ // Act
+ const yaml = serializeAgentConfig(config);
+
+ // Assert — agent_ref must appear in the serialized YAML.
+ expect(yaml).toContain("agent_ref:");
+ expect(yaml).toContain("agent_ref: researcher");
+ });
+
+ it("does NOT serialize agent_ref when undefined", () => {
+ // Arrange — a subagent without agent_ref (the default in fullConfig).
+ // Act
+ const yaml = serializeAgentConfig(fullConfig);
+
+ // Assert — agent_ref must not appear in the serialized YAML.
+ expect(yaml).not.toContain("agent_ref");
+ });
+
+ it("still serializes tools (preservation)", () => {
+ // Arrange — fullConfig has tools: ["search", "calculator"].
+ // Act
+ const yaml = serializeAgentConfig(fullConfig);
+
+ // Assert — the top-level tools key must still be serialized.
+ expect(yaml).toMatch(/^tools:/m);
+ expect(yaml).toContain("search");
+ expect(yaml).toContain("calculator");
+ });
});
describe("parseAgentConfig", () => {
@@ -309,4 +377,4 @@ describe("agentConfigToYamlFile", () => {
expect(parsed.name).toBe(fullConfig.name);
expect(parsed.model).toBe(fullConfig.model);
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/components/agent/AgentCard.test.tsx b/tests/unit/components/agent/AgentCard.test.tsx
index da8a1f7..513f7b0 100644
--- a/tests/unit/components/agent/AgentCard.test.tsx
+++ b/tests/unit/components/agent/AgentCard.test.tsx
@@ -35,11 +35,13 @@ describe("AgentCard", () => {
expect(onConfigure).toHaveBeenCalledWith("my-agent");
});
- it("shows status badge", () => {
- const agent = createAgentConfigMetadata({ is_builtin: true });
+ it("does not render a status badge (Active/Standby)", () => {
+ const agent = createAgentConfigMetadata({ name: "my-agent" });
renderWithProviders( );
- expect(screen.getByText("Active")).toBeInTheDocument();
+ // After the fix, the StatusBadge component is removed entirely; no
+ // "Active" or "Standby" text should be rendered anywhere in the card.
+ expect(screen.queryByText(/^(active|standby)$/i)).toBeNull();
});
});
diff --git a/tests/unit/components/agent/AgentConfigForm.test.tsx b/tests/unit/components/agent/AgentConfigForm.test.tsx
index ed56506..6f17614 100644
--- a/tests/unit/components/agent/AgentConfigForm.test.tsx
+++ b/tests/unit/components/agent/AgentConfigForm.test.tsx
@@ -1,14 +1,43 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "../../../utils/render";
import AgentConfigForm from "@/application/components/agent/AgentConfigForm";
import type { AgentConfig } from "@/domain/entities/agent/agentConfig";
import { BackendType } from "@/domain/entities/agent/agentConfig";
+import type { AgentConfigMetadata } from "@/domain/entities/agent/agentConfigMetadata";
+import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+import type { RegisteredMcpServer } from "@/domain/entities/mcpServer/registeredMcpServer";
const mockSubmit = vi.fn();
const mockCancel = vi.fn();
+const { mockAgentsData, registryState, revealMock } = vi.hoisted(() => ({
+ mockAgentsData: {
+ data: [] as AgentConfigMetadata[] | undefined,
+ isLoading: false,
+ },
+ registryState: {
+ data: undefined as RegisteredMcpServer[] | undefined,
+ isLoading: false,
+ },
+ revealMock: vi.fn(),
+}));
+
+vi.mock("@/application/hooks/agent/useAgents", () => ({
+ useAgents: () => mockAgentsData,
+}));
+
+vi.mock("@/application/hooks/mcpServer/useMcpRegistry", () => ({
+ useMcpRegistry: () => registryState,
+}));
+
+vi.mock("@/infrastructure/api/mcpServer/mcpRegistryApi", () => ({
+ mcpRegistryApi: {
+ reveal: (name: string) => revealMock(name),
+ },
+}));
+
describe("AgentConfigForm", () => {
describe("create mode", () => {
it("renders form with sections", () => {
@@ -17,8 +46,6 @@ describe("AgentConfigForm", () => {
);
expect(screen.getByText("General")).toBeInTheDocument();
- // After the change the section is renamed to just "Tools".
- expect(screen.getByText("Tools")).toBeInTheDocument();
expect(screen.getByText("Backend")).toBeInTheDocument();
});
@@ -40,15 +67,9 @@ describe("AgentConfigForm", () => {
expect(screen.queryByText("Middleware")).not.toBeInTheDocument();
// The middleware toggle buttons (todo_list, filesystem, sub_agent) must
// not appear as toggle buttons.
- expect(
- screen.queryByRole("button", { name: /^todo_list$/i }),
- ).not.toBeInTheDocument();
- expect(
- screen.queryByRole("button", { name: /^filesystem$/i }),
- ).not.toBeInTheDocument();
- expect(
- screen.queryByRole("button", { name: /^sub_agent$/i }),
- ).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^todo_list$/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^filesystem$/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^sub_agent$/i })).not.toBeInTheDocument();
});
it("does NOT render filesystem in the backend type dropdown", async () => {
@@ -65,9 +86,7 @@ describe("AgentConfigForm", () => {
// Assert — filesystem must not be a selectable option.
await waitFor(() => {
- expect(
- screen.queryByRole("option", { name: "filesystem" }),
- ).not.toBeInTheDocument();
+ expect(screen.queryByRole("option", { name: "filesystem" })).not.toBeInTheDocument();
});
});
@@ -81,9 +100,7 @@ describe("AgentConfigForm", () => {
await user.click(screen.getByRole("combobox"));
await waitFor(() => {
- expect(
- screen.queryByRole("option", { name: "composite" }),
- ).not.toBeInTheDocument();
+ expect(screen.queryByRole("option", { name: "composite" })).not.toBeInTheDocument();
});
});
@@ -125,7 +142,7 @@ describe("AgentConfigForm", () => {
expect(nameInput).not.toBeDisabled();
});
- it("renders model and debug fields", () => {
+ it("renders model field", () => {
renderWithProviders(
,
);
@@ -133,6 +150,14 @@ describe("AgentConfigForm", () => {
expect(screen.getByLabelText(/model/i)).toBeInTheDocument();
});
+ it("does NOT render a Debug switch", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByLabelText(/debug/i)).not.toBeInTheDocument();
+ });
+
it("renders cancel button", () => {
renderWithProviders(
,
@@ -224,4 +249,305 @@ describe("AgentConfigForm", () => {
expect(screen.queryByRole("button", { name: /create/i })).not.toBeInTheDocument();
});
});
-});
\ No newline at end of file
+
+ describe("description & subagent reference feature", () => {
+ beforeEach(() => {
+ mockAgentsData.data = [];
+ mockAgentsData.isLoading = false;
+ });
+
+ it("renders a Description input in the General section", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // The General section is open by default; a Description field must be present.
+ expect(screen.getByLabelText(/^description$/i)).toBeInTheDocument();
+ });
+
+ it("does NOT render a Tools accordion section", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // After the change, there must be no accordion trigger labelled "Tools".
+ expect(screen.queryByRole("button", { name: /^tools$/i })).not.toBeInTheDocument();
+ });
+
+ it("shows an 'Add from existing agents' control in the Subagents section when useAgents returns agents", async () => {
+ // Arrange — useAgents returns two existing agents.
+ mockAgentsData.data = [
+ { name: "researcher", description: "Research agent" } as AgentConfigMetadata,
+ { name: "writer", description: "Writer agent" } as AgentConfigMetadata,
+ ];
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ // Act — expand the Subagents accordion.
+ await user.click(screen.getByRole("button", { name: "Subagents" }));
+
+ // Assert — the "Add from existing agents" control must be rendered.
+ await waitFor(() => {
+ expect(screen.getByText(/add from existing agents/i)).toBeInTheDocument();
+ });
+ });
+
+ it("clicking an existing agent in the picker adds a subagent with agent_ref", async () => {
+ // Arrange — useAgents returns a "researcher" agent.
+ mockAgentsData.data = [
+ { name: "researcher", description: "Research agent" } as AgentConfigMetadata,
+ ];
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ // Act — expand Subagents, open the picker, and select the researcher.
+ await user.click(screen.getByRole("button", { name: "Subagents" }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/add from existing agents/i)).toBeInTheDocument();
+ });
+
+ // The picker is the combobox inside the Subagents section.
+ await user.click(screen.getByRole("combobox"));
+ await user.click(await screen.findByRole("option", { name: /researcher/i }));
+
+ // Assert — a SubAgentEditor in reference mode must appear, showing a
+ // "ref: researcher" badge.
+ await waitFor(() => {
+ expect(screen.getByText(/ref:\s*researcher/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("MCP servers pills", () => {
+ const registryServers: RegisteredMcpServer[] = [
+ {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: { Authorization: "Bearer secret" },
+ env: {},
+ auth_token: "tok",
+ tool_count: 5,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ },
+ {
+ name: "github",
+ transport: McpTransportType.HTTP,
+ url: "https://api.github.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 12,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ },
+ ];
+
+ beforeEach(() => {
+ mockAgentsData.data = [];
+ mockAgentsData.isLoading = false;
+ registryState.data = registryServers;
+ registryState.isLoading = false;
+ revealMock.mockReset();
+ });
+
+ async function expandMcpServers(user: ReturnType) {
+ await user.click(screen.getByRole("button", { name: "MCP Servers" }));
+ }
+
+ it("renders registry servers as toggle pills", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await expandMcpServers(user);
+
+ await waitFor(() => {
+ expect(screen.getByText("weather")).toBeInTheDocument();
+ expect(screen.getByText("github")).toBeInTheDocument();
+ });
+ });
+
+ it("does NOT render the old 'Add from registry' dropdown, editor cards or 'Add MCP Server' button", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await expandMcpServers(user);
+
+ // Dropdown + custom-server button are gone.
+ expect(screen.queryByText(/add from registry/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/add mcp server/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/select a registered server/i)).not.toBeInTheDocument();
+ // No combobox in the MCP section (the backend/subagent comboboxes live
+ // in other sections that are closed, so none should be present at all).
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ });
+
+ it("clicking a registry pill reveals and embeds the server (pill becomes active)", async () => {
+ revealMock.mockResolvedValue(registryServers[0]);
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await expandMcpServers(user);
+
+ const pill = await screen.findByRole("button", { name: "weather" });
+ expect(pill).toHaveAttribute("aria-pressed", "false");
+
+ await user.click(pill);
+
+ await waitFor(() => {
+ expect(pill).toHaveAttribute("aria-pressed", "true");
+ });
+ expect(revealMock).toHaveBeenCalledWith("weather");
+ });
+
+ it("clicking an active pill removes the embedded entry", async () => {
+ revealMock.mockResolvedValue(registryServers[0]);
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await expandMcpServers(user);
+
+ const pill = await screen.findByRole("button", { name: "weather" });
+ await user.click(pill);
+ await waitFor(() => {
+ expect(pill).toHaveAttribute("aria-pressed", "true");
+ });
+
+ await user.click(pill);
+
+ await waitFor(() => {
+ expect(pill).toHaveAttribute("aria-pressed", "false");
+ });
+ });
+
+ it("shows the empty message when the registry is empty", async () => {
+ registryState.data = [];
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await expandMcpServers(user);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/add servers in the mcp registry page first/i),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("edit mode: an existing registry server shows as an active pill with no editor card", async () => {
+ const config: AgentConfig = {
+ name: "existing-agent",
+ model: "openai:gpt-4o",
+ system_prompt: "You are helpful",
+ tools: ["search"],
+ backend: { type: BackendType.STORE, checkpoint_backend: "memory" },
+ hitl: { rules: {} },
+ memory: [],
+ skills: [],
+ subagents: [],
+ mcp_servers: [
+ {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ command: undefined,
+ args: [],
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: undefined,
+ },
+ ],
+ debug: false,
+ };
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await expandMcpServers(user);
+
+ const pill = await screen.findByRole("button", { name: "weather" });
+ expect(pill).toHaveAttribute("aria-pressed", "true");
+ // No editor card rendered.
+ expect(screen.queryByText(/add mcp server/i)).not.toBeInTheDocument();
+ });
+
+ it("edit mode: a legacy custom (non-registry) server shows as a removable pill", async () => {
+ const config: AgentConfig = {
+ name: "existing-agent",
+ model: "openai:gpt-4o",
+ system_prompt: "You are helpful",
+ tools: ["search"],
+ backend: { type: BackendType.STORE, checkpoint_backend: "memory" },
+ hitl: { rules: {} },
+ memory: [],
+ skills: [],
+ subagents: [],
+ mcp_servers: [
+ {
+ name: "legacy-local",
+ transport: McpTransportType.STDIO,
+ command: "npx",
+ args: [],
+ headers: {},
+ env: {},
+ auth_token: undefined,
+ },
+ ],
+ debug: false,
+ };
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await expandMcpServers(user);
+
+ const pill = await screen.findByRole("button", { name: /legacy-local/i });
+ expect(pill).toHaveAttribute("aria-pressed", "true");
+
+ // Removing a legacy entry should not call reveal (it's not in registry).
+ await user.click(pill);
+ // Legacy entries are not in the registry, so once removed the pill is
+ // gone entirely (it cannot be re-added).
+ await waitFor(() => {
+ expect(screen.queryByRole("button", { name: /legacy-local/i })).not.toBeInTheDocument();
+ });
+ expect(revealMock).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/tests/unit/components/agent/AgentConfigViewer.test.tsx b/tests/unit/components/agent/AgentConfigViewer.test.tsx
index 59e6953..d4b57d4 100644
--- a/tests/unit/components/agent/AgentConfigViewer.test.tsx
+++ b/tests/unit/components/agent/AgentConfigViewer.test.tsx
@@ -1,4 +1,4 @@
-import { screen, waitFor } from "@testing-library/react";
+import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "../../../utils/render";
@@ -147,9 +147,8 @@ describe("AgentConfigViewer", () => {
,
);
- expect(screen.getByText("search")).toBeInTheDocument();
- expect(screen.getByText("calculator")).toBeInTheDocument();
- expect(screen.getByText("Tools (2)")).toBeInTheDocument();
+ // After the change, the Tools section is no longer rendered.
+ expect(screen.queryByText("Tools (2)")).not.toBeInTheDocument();
});
it("renders backend checkpoint_backend", () => {
@@ -178,14 +177,14 @@ describe("AgentConfigViewer", () => {
expect(screen.getByRole("button", { name: /confirm delete/i })).toBeInTheDocument();
});
- it("renders debug status badge", () => {
+ it("does NOT render a Debug section", () => {
mockAgentConfigData.data = fullConfig;
renderWithProviders(
,
);
- expect(screen.getByText("Off")).toBeInTheDocument();
+ expect(screen.queryByText(/debug/i)).not.toBeInTheDocument();
});
it("does NOT render a middleware section", () => {
@@ -331,14 +330,14 @@ describe("AgentConfigViewer", () => {
);
});
- it("shows Active badge when debug is true", () => {
+ it("does NOT render an Active debug badge when debug is true", () => {
mockAgentConfigData.data = { ...fullConfig, debug: true };
renderWithProviders(
,
);
- expect(screen.getByText("Active")).toBeInTheDocument();
+ expect(screen.queryByText("Active")).not.toBeInTheDocument();
});
it("has an Edit button", () => {
@@ -418,4 +417,57 @@ describe("AgentConfigViewer", () => {
// Assert — no Memories section label is rendered.
expect(screen.queryByText(/Memories \(/)).not.toBeInTheDocument();
});
-});
\ No newline at end of file
+
+ it("does NOT render a Tools section (even when config.tools has items)", () => {
+ // Arrange — fullConfig has tools: ["search", "calculator"].
+ mockAgentConfigData.data = fullConfig;
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — the Tools section label must no longer be rendered.
+ expect(screen.queryByText(/^Tools \(\d+\)$/)).not.toBeInTheDocument();
+ expect(screen.queryByText("Tools (2)")).not.toBeInTheDocument();
+ });
+
+ it("renders a Description section when config.description is present", () => {
+ // Arrange — a config carrying a top-level description.
+ mockAgentConfigData.data = {
+ ...fullConfig,
+ description: "An agent that writes tests.",
+ } as AgentConfig;
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — a Description section label and the description text must appear.
+ expect(screen.getByText(/^description$/i)).toBeInTheDocument();
+ expect(screen.getByText("An agent that writes tests.")).toBeInTheDocument();
+ });
+
+ it("renders a ref badge on referenced subagents", () => {
+ // Arrange — a config with a subagent that references another agent.
+ mockAgentConfigData.data = {
+ ...fullConfig,
+ subagents: [
+ {
+ name: "researcher",
+ description: "Research sub-agent",
+ agent_ref: "researcher",
+ tools: [],
+ skills: [],
+ mcp_servers: [],
+ } as AgentConfig["subagents"][number],
+ ],
+ } as AgentConfig;
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — a ref badge "ref: researcher" must be rendered on the subagent.
+ expect(screen.getByText(/ref:\s*researcher/i)).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/components/agent/CreateAgentDialog.test.tsx b/tests/unit/components/agent/CreateAgentDialog.test.tsx
index d6a99b6..9618914 100644
--- a/tests/unit/components/agent/CreateAgentDialog.test.tsx
+++ b/tests/unit/components/agent/CreateAgentDialog.test.tsx
@@ -1,4 +1,4 @@
-import { screen, waitFor } from "@testing-library/react";
+import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "../../../utils/render";
diff --git a/tests/unit/components/agent/McpServerEditor.test.tsx b/tests/unit/components/agent/McpServerEditor.test.tsx
deleted file mode 100644
index a6e0855..0000000
--- a/tests/unit/components/agent/McpServerEditor.test.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, it, expect, vi } from "vitest";
-import { renderWithProviders } from "../../../utils/render";
-import McpServerEditor from "@/application/components/agent/McpServerEditor";
-import type { McpServerConfig } from "@/domain/entities/agent/mcpServerConfig";
-import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
-
-const stdioServer: McpServerConfig = {
- name: "fs-server",
- transport: McpTransportType.STDIO,
- command: "npx",
- args: ["-y", "@mcp/filesystem"],
- headers: {},
- env: {},
-};
-
-const httpServer: McpServerConfig = {
- name: "http-server",
- transport: McpTransportType.HTTP,
- url: "http://localhost:3000/mcp",
- args: [],
- headers: { Authorization: "Bearer token" },
- env: {},
-};
-
-describe("McpServerEditor", () => {
- it("renders server name field", () => {
- renderWithProviders(
- ,
- );
-
- expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
- expect(screen.getByDisplayValue("fs-server")).toBeInTheDocument();
- });
-
- it("shows command field for stdio transport", () => {
- renderWithProviders(
- ,
- );
-
- expect(screen.getByLabelText(/command/i)).toBeInTheDocument();
- expect(screen.getByText("Args")).toBeInTheDocument();
- });
-
- it("shows url field for http transport", () => {
- renderWithProviders(
- ,
- );
-
- expect(screen.getByLabelText(/url/i)).toBeInTheDocument();
- expect(screen.getByText("Headers")).toBeInTheDocument();
- });
-
- it("calls onRemove when remove button is clicked", async () => {
- const user = userEvent.setup();
- const onRemove = vi.fn();
-
- renderWithProviders(
- ,
- );
-
- await user.click(screen.getByRole("button", { name: /remove server/i }));
- expect(onRemove).toHaveBeenCalled();
- });
-});
diff --git a/tests/unit/components/agent/SubAgentEditor.test.tsx b/tests/unit/components/agent/SubAgentEditor.test.tsx
index 425f87f..dc8d05f 100644
--- a/tests/unit/components/agent/SubAgentEditor.test.tsx
+++ b/tests/unit/components/agent/SubAgentEditor.test.tsx
@@ -8,8 +8,9 @@ import type { SubAgentConfig } from "@/domain/entities/agent/agentConfig";
const minimalSubAgent: SubAgentConfig = {
name: "researcher",
description: "Research sub-agent",
- tools: ["search"],
- skills: ["web-search"],
+ agent_ref: "researcher",
+ tools: [],
+ skills: [],
mcp_servers: [],
};
@@ -35,25 +36,56 @@ describe("SubAgentEditor", () => {
expect(onRemove).toHaveBeenCalled();
});
- it("renders tools and skills sections", () => {
+ it("does NOT render tools, skills, or instructions sections", () => {
renderWithProviders(
,
);
- expect(screen.getByText("Tools")).toBeInTheDocument();
- expect(screen.getByText("Skills")).toBeInTheDocument();
+ expect(screen.queryByText(/^tools$/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/^skills$/i)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/^instructions$/i)).not.toBeInTheDocument();
});
- it("renders instructions field when present", () => {
- const withInstructions: SubAgentConfig = {
- ...minimalSubAgent,
- instructions: "Do deep research",
- };
+ describe("reference mode (always on)", () => {
+ it("renders a reference badge when agent_ref is set", () => {
+ renderWithProviders(
+ ,
+ );
- renderWithProviders(
- ,
- );
+ expect(screen.getByText(/ref:\s*researcher/i)).toBeInTheDocument();
+ });
+
+ it("name is read-only", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const nameInput = screen.getByLabelText(/^name$/i) as HTMLInputElement;
+ expect(nameInput).toBeDisabled();
+ });
+
+ it("hides instructions/model/skills editors", () => {
+ const refSub: SubAgentConfig = {
+ ...minimalSubAgent,
+ instructions: "should be hidden",
+ model: "openai:gpt-4o",
+ } as SubAgentConfig;
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByLabelText(/^instructions$/i)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/^model$/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/^skills$/i)).not.toBeInTheDocument();
+ });
+
+ it("does NOT render a Tools editor", () => {
+ renderWithProviders(
+ ,
+ );
- expect(screen.getByLabelText(/instructions/i)).toBeInTheDocument();
+ expect(screen.queryByText(/^tools$/i)).not.toBeInTheDocument();
+ });
});
});
diff --git a/tests/unit/components/chat/JsonBlock.test.tsx b/tests/unit/components/chat/JsonBlock.test.tsx
new file mode 100644
index 0000000..a98e8e7
--- /dev/null
+++ b/tests/unit/components/chat/JsonBlock.test.tsx
@@ -0,0 +1,57 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import JsonBlock from "@/application/components/chat/JsonBlock";
+
+describe("JsonBlock", () => {
+ it("pretty-prints a valid JSON string with 2-space indentation", () => {
+ const content = '{"a":1,"b":[2,3]}';
+ const expected = JSON.stringify(JSON.parse(content), null, 2);
+
+ renderWithProviders( );
+
+ const pre = document.querySelector("pre");
+ expect(pre).not.toBeNull();
+ expect(pre?.textContent).toBe(expected);
+ });
+
+ it("renders a non-JSON string as-is (fallback)", () => {
+ renderWithProviders( );
+
+ const pre = document.querySelector("pre");
+ expect(pre).not.toBeNull();
+ expect(pre?.textContent).toContain("plain text result");
+ });
+
+ it("renders nothing when content is null", () => {
+ renderWithProviders( );
+
+ expect(document.querySelector('[data-od-id="json-block"]')).toBeNull();
+ expect(document.querySelector("pre")).toBeNull();
+ });
+
+ it("Copy button copies the formatted JSON and shows a Copied state", async () => {
+ const content = '{"a":1,"b":[2,3]}';
+ const expectedFormatted = JSON.stringify(JSON.parse(content), null, 2);
+ // userEvent.setup() installs a clipboard stub on navigator; spy on its
+ // writeText AFTER setup so we assert the formatted payload is copied.
+ const user = userEvent.setup();
+ const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
+
+ renderWithProviders( );
+
+ const copyBtn = screen.getByRole("button", { name: /copy/i });
+ await user.click(copyBtn);
+
+ expect(writeTextSpy).toHaveBeenCalledWith(expectedFormatted);
+ expect(await screen.findByText(/copied/i)).toBeInTheDocument();
+ writeTextSpy.mockRestore();
+ });
+
+ it("has data-od-id=json-block on the wrapper", () => {
+ renderWithProviders( );
+
+ expect(document.querySelector('[data-od-id="json-block"]')).not.toBeNull();
+ });
+});
diff --git a/tests/unit/components/layout/MainHeader.test.tsx b/tests/unit/components/layout/MainHeader.test.tsx
index d7febe8..92313a2 100644
--- a/tests/unit/components/layout/MainHeader.test.tsx
+++ b/tests/unit/components/layout/MainHeader.test.tsx
@@ -57,4 +57,4 @@ describe("MainHeader", () => {
expect(screen.getByRole("heading")).toHaveAttribute("data-od-id", "page-title");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/components/mcpServer/CreateMcpServerDialog.test.tsx b/tests/unit/components/mcpServer/CreateMcpServerDialog.test.tsx
new file mode 100644
index 0000000..7ea7861
--- /dev/null
+++ b/tests/unit/components/mcpServer/CreateMcpServerDialog.test.tsx
@@ -0,0 +1,322 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+
+// Hoisted mocks so the dialog can call them via the mocked hooks.
+const { mockCreateMcpServerMutate, mockValidateMcpServerMutateAsync } = vi.hoisted(() => {
+ return {
+ mockCreateMcpServerMutate: vi.fn(),
+ mockValidateMcpServerMutateAsync: vi.fn(),
+ };
+});
+
+vi.mock("@/application/hooks/mcpServer/useCreateMcpServer", () => ({
+ useCreateMcpServer: () => ({
+ mutate: mockCreateMcpServerMutate,
+ isPending: false,
+ }),
+}));
+
+vi.mock("@/application/hooks/mcpServer/useUpdateMcpServer", () => ({
+ useUpdateMcpServer: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+}));
+
+vi.mock("@/application/hooks/mcpServer/useValidateMcpServer", () => ({
+ useValidateMcpServer: () => ({
+ mutateAsync: mockValidateMcpServerMutateAsync,
+ isPending: false,
+ }),
+}));
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import { CreateMcpServerDialog } from "@/application/components/mcpServer/CreateMcpServerDialog";
+
+describe("CreateMcpServerDialog", () => {
+ beforeEach(() => {
+ mockCreateMcpServerMutate.mockClear();
+ mockValidateMcpServerMutateAsync.mockReset();
+ });
+
+ it("renders the dialog container with data-od-id when open", () => {
+ renderWithProviders( );
+
+ const dialog = document.querySelector('[data-od-id="create-mcp-server-dialog"]');
+ expect(dialog).not.toBeNull();
+ expect(dialog).toBeInTheDocument();
+ });
+
+ it("renders the name input", () => {
+ renderWithProviders( );
+
+ expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
+ });
+
+ it("renders the url input", () => {
+ renderWithProviders( );
+
+ expect(screen.getByLabelText(/url/i)).toBeInTheDocument();
+ });
+
+ it("renders a headers key/value editor", () => {
+ renderWithProviders( );
+
+ expect(screen.getByText(/^headers$/i)).toBeInTheDocument();
+ });
+
+ it("renders an env key/value editor", () => {
+ renderWithProviders( );
+
+ expect(screen.getByText(/^env$/i)).toBeInTheDocument();
+ });
+
+ it("renders the auth_token password input", () => {
+ renderWithProviders( );
+
+ const token = screen.getByLabelText(/auth token|auth_token/i);
+ expect(token).toBeInTheDocument();
+ expect((token as HTMLInputElement).type).toBe("password");
+ });
+
+ it("renders the Test connection button", () => {
+ renderWithProviders( );
+
+ expect(screen.getByRole("button", { name: /test connection/i })).toBeInTheDocument();
+ });
+
+ it("renders the submit button", () => {
+ renderWithProviders( );
+
+ expect(screen.getByRole("button", { name: /create|save|submit/i })).toBeInTheDocument();
+ });
+
+ it("calls onOpenChange(false) when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it("calls useCreateMcpServer mutate with the form values on submit", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders( );
+
+ await user.type(screen.getByLabelText(/name/i), "weather");
+ await user.type(screen.getByLabelText(/url/i), "https://example.com/mcp");
+
+ await user.click(screen.getByRole("button", { name: /create|save|submit/i }));
+
+ await waitFor(() => {
+ expect(mockCreateMcpServerMutate).toHaveBeenCalledTimes(1);
+ });
+
+ const payload = mockCreateMcpServerMutate.mock.calls[0][0];
+ expect(payload.name).toBe("weather");
+ expect(payload.url).toBe("https://example.com/mcp");
+ });
+
+ it("shows a success message with the tool count after a successful Test connection", async () => {
+ mockValidateMcpServerMutateAsync.mockResolvedValue({ tool_count: 7 });
+ const user = userEvent.setup();
+
+ renderWithProviders( );
+
+ await user.type(screen.getByLabelText(/name/i), "weather");
+ await user.type(screen.getByLabelText(/url/i), "https://example.com/mcp");
+
+ await user.click(screen.getByRole("button", { name: /test connection/i }));
+
+ await waitFor(() => {
+ expect(mockValidateMcpServerMutateAsync).toHaveBeenCalledOnce();
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/7 tools/i)).toBeInTheDocument();
+ });
+ });
+
+ it("shows an error message after a failed Test connection", async () => {
+ mockValidateMcpServerMutateAsync.mockRejectedValue(new Error("Server unreachable"));
+ const user = userEvent.setup();
+
+ renderWithProviders( );
+
+ await user.type(screen.getByLabelText(/name/i), "weather");
+ await user.type(screen.getByLabelText(/url/i), "https://example.com/mcp");
+
+ await user.click(screen.getByRole("button", { name: /test connection/i }));
+
+ await waitFor(() => {
+ expect(mockValidateMcpServerMutateAsync).toHaveBeenCalledOnce();
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/unreachable|error/i)).toBeInTheDocument();
+ });
+ });
+
+ describe("source type selection", () => {
+ it("renders a Type select with data-od-id=mcp-source-type", () => {
+ renderWithProviders( );
+
+ const typeSelect = document.querySelector('[data-od-id="mcp-source-type"]');
+ expect(typeSelect).not.toBeNull();
+ expect(typeSelect).toBeInTheDocument();
+ });
+
+ it("offers External and OpenAPI options in the Type select", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("combobox", { name: /type/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("option", { name: /external/i })).toBeInTheDocument();
+ });
+ expect(screen.getByRole("option", { name: /openapi/i })).toBeInTheDocument();
+ });
+
+ it("defaults to External and shows url, env and auth_token fields", () => {
+ renderWithProviders( );
+
+ // External is the default — url + env + auth_token visible.
+ expect(screen.getByLabelText(/url/i)).toBeInTheDocument();
+ expect(screen.getByText(/^env$/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/auth token|auth_token/i)).toBeInTheDocument();
+ // openapi_url is NOT rendered for external.
+ expect(screen.queryByLabelText(/openapi url|openapi_url/i)).not.toBeInTheDocument();
+ });
+
+ it("shows openapi_url and hides env + auth_token when OpenAPI is selected", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("combobox", { name: /type/i }));
+ await user.click(screen.getByRole("option", { name: /openapi/i }));
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/openapi url|openapi_url/i)).toBeInTheDocument();
+ });
+ // env + auth_token hidden for openapi.
+ expect(screen.queryByText(/^env$/i)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/auth token|auth_token/i)).not.toBeInTheDocument();
+ });
+
+ it("shows url + env + auth_token again when switching back to External", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ // Switch to OpenAPI first.
+ await user.click(screen.getByRole("combobox", { name: /type/i }));
+ await user.click(screen.getByRole("option", { name: /openapi/i }));
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/openapi url|openapi_url/i)).toBeInTheDocument();
+ });
+
+ // Switch back to External.
+ await user.click(screen.getByRole("combobox", { name: /type/i }));
+ await user.click(screen.getByRole("option", { name: /external/i }));
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/url/i)).toBeInTheDocument();
+ });
+ expect(screen.getByText(/^env$/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/auth token|auth_token/i)).toBeInTheDocument();
+ expect(screen.queryByLabelText(/openapi url|openapi_url/i)).not.toBeInTheDocument();
+ });
+
+ it("calls validate with source_type openapi and openapi_url on Test connection (OpenAPI mode)", async () => {
+ mockValidateMcpServerMutateAsync.mockResolvedValue({ tool_count: 4 });
+ const user = userEvent.setup();
+
+ renderWithProviders( );
+
+ await user.type(screen.getByLabelText(/name/i), "petstore");
+ // Switch to OpenAPI.
+ await user.click(screen.getByRole("combobox", { name: /type/i }));
+ await user.click(screen.getByRole("option", { name: /openapi/i }));
+
+ await user.type(
+ screen.getByLabelText(/openapi url|openapi_url/i),
+ "https://example.com/openapi.json",
+ );
+
+ await user.click(screen.getByRole("button", { name: /test connection/i }));
+
+ await waitFor(() => {
+ expect(mockValidateMcpServerMutateAsync).toHaveBeenCalledOnce();
+ });
+
+ const payload = mockValidateMcpServerMutateAsync.mock.calls[0][0];
+ expect(payload.source_type).toBe("openapi");
+ expect(payload.openapi_url).toBe("https://example.com/openapi.json");
+ expect(payload.name).toBe("petstore");
+ // openapi sends null for url and auth_token (not empty strings).
+ expect(payload.url).toBeNull();
+ expect(payload.auth_token).toBeNull();
+ });
+
+ it("calls create with source_type openapi and openapi_url on submit (OpenAPI mode)", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders( );
+
+ await user.type(screen.getByLabelText(/name/i), "petstore");
+ // Switch to OpenAPI.
+ await user.click(screen.getByRole("combobox", { name: /type/i }));
+ await user.click(screen.getByRole("option", { name: /openapi/i }));
+
+ await user.type(
+ screen.getByLabelText(/openapi url|openapi_url/i),
+ "https://example.com/openapi.json",
+ );
+
+ await user.click(screen.getByRole("button", { name: /create|save|submit/i }));
+
+ await waitFor(() => {
+ expect(mockCreateMcpServerMutate).toHaveBeenCalledTimes(1);
+ });
+
+ const payload = mockCreateMcpServerMutate.mock.calls[0][0];
+ expect(payload.source_type).toBe("openapi");
+ expect(payload.openapi_url).toBe("https://example.com/openapi.json");
+ expect(payload.name).toBe("petstore");
+ // openapi sends null for url and auth_token (not empty strings).
+ expect(payload.url).toBeNull();
+ expect(payload.auth_token).toBeNull();
+ });
+
+ it("calls create with source_type external on submit (External mode default)", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders( );
+
+ await user.type(screen.getByLabelText(/name/i), "weather");
+ await user.type(screen.getByLabelText(/url/i), "https://example.com/mcp");
+
+ await user.click(screen.getByRole("button", { name: /create|save|submit/i }));
+
+ await waitFor(() => {
+ expect(mockCreateMcpServerMutate).toHaveBeenCalledTimes(1);
+ });
+
+ const payload = mockCreateMcpServerMutate.mock.calls[0][0];
+ expect(payload.source_type).toBe("external");
+ expect(payload.openapi_url).toBeNull();
+ expect(payload.url).toBe("https://example.com/mcp");
+ });
+ });
+});
diff --git a/tests/unit/components/mcpServer/McpServerGrid.test.tsx b/tests/unit/components/mcpServer/McpServerGrid.test.tsx
new file mode 100644
index 0000000..ca16fa7
--- /dev/null
+++ b/tests/unit/components/mcpServer/McpServerGrid.test.tsx
@@ -0,0 +1,165 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import McpServerGrid from "@/application/components/mcpServer/McpServerGrid";
+import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+import type { RegisteredMcpServer } from "@/domain/entities/mcpServer/registeredMcpServer";
+
+vi.mock("@/application/hooks/mcpServer/useMcpRegistry");
+
+import { useMcpRegistry } from "@/application/hooks/mcpServer/useMcpRegistry";
+
+const mockedUseMcpRegistry = vi.mocked(useMcpRegistry);
+
+function makeServer(overrides: Partial = {}): RegisteredMcpServer {
+ return {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 5,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ ...overrides,
+ };
+}
+
+describe("McpServerGrid", () => {
+ it("shows loading text when loading", () => {
+ mockedUseMcpRegistry.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ error: null,
+ } as ReturnType);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("Loading MCP servers...")).toBeInTheDocument();
+ });
+
+ it("shows an error message when the registry fails to load", () => {
+ mockedUseMcpRegistry.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ error: new Error("boom"),
+ } as ReturnType);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText(/failed to load mcp servers: boom/i)).toBeInTheDocument();
+ });
+
+ it("renders one card per registered server", () => {
+ mockedUseMcpRegistry.mockReturnValue({
+ data: [
+ makeServer({ name: "weather", url: "https://example.com/mcp" }),
+ makeServer({ name: "github", url: "https://api.github.com/mcp" }),
+ ],
+ isLoading: false,
+ error: null,
+ } as ReturnType);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("weather")).toBeInTheDocument();
+ expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
+ expect(screen.getByText("github")).toBeInTheDocument();
+ expect(screen.getByText("https://api.github.com/mcp")).toBeInTheDocument();
+ });
+
+ it("shows the New MCP Server card when the registry is empty", () => {
+ mockedUseMcpRegistry.mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: null,
+ } as ReturnType);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("New MCP Server")).toBeInTheDocument();
+ });
+
+ it("also shows the New MCP Server card when the registry is populated", () => {
+ mockedUseMcpRegistry.mockReturnValue({
+ data: [makeServer({ name: "weather" })],
+ isLoading: false,
+ error: null,
+ } as ReturnType);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("weather")).toBeInTheDocument();
+ expect(screen.getByText("New MCP Server")).toBeInTheDocument();
+ });
+
+ it("calls onCreateNew when the New MCP Server card is clicked", async () => {
+ const user = userEvent.setup();
+ mockedUseMcpRegistry.mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: null,
+ } as ReturnType);
+ const onCreateNew = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByText("New MCP Server"));
+
+ expect(onCreateNew).toHaveBeenCalledOnce();
+ });
+
+ it("forwards onEdit when a card's Edit button is clicked", async () => {
+ const user = userEvent.setup();
+ const server = makeServer({ name: "weather" });
+ mockedUseMcpRegistry.mockReturnValue({
+ data: [server],
+ isLoading: false,
+ error: null,
+ } as ReturnType);
+ const onEdit = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /edit/i }));
+
+ expect(onEdit).toHaveBeenCalledWith(server);
+ });
+
+ it("forwards onDelete when a card's Delete button is clicked", async () => {
+ const user = userEvent.setup();
+ const server = makeServer({ name: "weather" });
+ mockedUseMcpRegistry.mockReturnValue({
+ data: [server],
+ isLoading: false,
+ error: null,
+ } as ReturnType);
+ const onDelete = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /delete/i }));
+
+ expect(onDelete).toHaveBeenCalledWith("weather");
+ });
+});
diff --git a/tests/unit/components/memory/CreateMemoryDialog.test.tsx b/tests/unit/components/memory/CreateMemoryDialog.test.tsx
index 9f7f150..c427463 100644
--- a/tests/unit/components/memory/CreateMemoryDialog.test.tsx
+++ b/tests/unit/components/memory/CreateMemoryDialog.test.tsx
@@ -31,25 +31,19 @@ describe("CreateMemoryDialog", () => {
});
it("renders name input", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
});
it("renders content textarea", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByLabelText(/content/i)).toBeInTheDocument();
});
it("renders Create button", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument();
});
@@ -58,12 +52,10 @@ describe("CreateMemoryDialog", () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/components/memory/MemoryCard.test.tsx b/tests/unit/components/memory/MemoryCard.test.tsx
index 8ce3024..852cb09 100644
--- a/tests/unit/components/memory/MemoryCard.test.tsx
+++ b/tests/unit/components/memory/MemoryCard.test.tsx
@@ -7,11 +7,7 @@ import { MemoryCard } from "@/application/components/memory/MemoryCard";
describe("MemoryCard", () => {
it("renders memory name", () => {
renderWithProviders(
- ,
+ ,
);
expect(screen.getByText("AGENTS.md")).toBeInTheDocument();
@@ -19,11 +15,7 @@ describe("MemoryCard", () => {
it("renders content preview", () => {
renderWithProviders(
- ,
+ ,
);
expect(screen.getByText("# Project rules...")).toBeInTheDocument();
@@ -34,11 +26,7 @@ describe("MemoryCard", () => {
const onConfigure = vi.fn();
renderWithProviders(
- ,
+ ,
);
await user.click(screen.getByRole("button", { name: /configure/i }));
@@ -46,4 +34,4 @@ describe("MemoryCard", () => {
expect(onConfigure).toHaveBeenCalledOnce();
expect(onConfigure).toHaveBeenCalledWith("AGENTS.md");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/components/shared/PillMultiSelect.test.tsx b/tests/unit/components/shared/PillMultiSelect.test.tsx
index 034a8c7..72272f9 100644
--- a/tests/unit/components/shared/PillMultiSelect.test.tsx
+++ b/tests/unit/components/shared/PillMultiSelect.test.tsx
@@ -87,4 +87,4 @@ describe("PillMultiSelect", () => {
expect(screen.getByText("Aucune compétence disponible")).toBeInTheDocument();
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/components/skill/CreateSkillDialog.test.tsx b/tests/unit/components/skill/CreateSkillDialog.test.tsx
index c24fe9f..2c5e0de 100644
--- a/tests/unit/components/skill/CreateSkillDialog.test.tsx
+++ b/tests/unit/components/skill/CreateSkillDialog.test.tsx
@@ -31,33 +31,25 @@ describe("CreateSkillDialog", () => {
});
it("renders name input", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
});
it("renders description input", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
});
it("renders content textarea", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByLabelText(/content/i)).toBeInTheDocument();
});
it("renders Create button", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument();
});
@@ -66,12 +58,10 @@ describe("CreateSkillDialog", () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/components/skill/SkillCard.test.tsx b/tests/unit/components/skill/SkillCard.test.tsx
index 2d7e09b..3623d99 100644
--- a/tests/unit/components/skill/SkillCard.test.tsx
+++ b/tests/unit/components/skill/SkillCard.test.tsx
@@ -6,17 +6,13 @@ import { SkillCard } from "@/application/components/skill/SkillCard";
describe("SkillCard", () => {
it("renders skill name", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByText("rag")).toBeInTheDocument();
});
it("renders skill description", () => {
- renderWithProviders(
- ,
- );
+ renderWithProviders( );
expect(screen.getByText("RAG queries")).toBeInTheDocument();
});
@@ -34,4 +30,4 @@ describe("SkillCard", () => {
expect(onConfigure).toHaveBeenCalledOnce();
expect(onConfigure).toHaveBeenCalledWith("rag");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/components/ui/input.test.tsx b/tests/unit/components/ui/input.test.tsx
new file mode 100644
index 0000000..180791c
--- /dev/null
+++ b/tests/unit/components/ui/input.test.tsx
@@ -0,0 +1,13 @@
+import { describe, it, expect } from "vitest";
+import { render } from "@testing-library/react";
+import { Input } from "@/application/components/ui/input";
+
+describe("Input — modal overflow fix", () => {
+ it("includes min-w-0 in its className", () => {
+ const { container } = render( );
+
+ const input = container.querySelector("input");
+ expect(input).not.toBeNull();
+ expect(input?.className).toMatch(/min-w-0/);
+ });
+});
diff --git a/tests/unit/domain/entities/agentConfig.test.ts b/tests/unit/domain/entities/agentConfig.test.ts
index b10abeb..5b75c14 100644
--- a/tests/unit/domain/entities/agentConfig.test.ts
+++ b/tests/unit/domain/entities/agentConfig.test.ts
@@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest";
import { BackendType } from "@/domain/entities/agent/agentConfig";
-import { backendConfigSchema, agentConfigSchema } from "@/domain/entities/agent/agentConfigSchema";
+import {
+ backendConfigSchema,
+ agentConfigSchema,
+ subAgentConfigSchema,
+} from "@/domain/entities/agent/agentConfigSchema";
import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
describe("BackendType", () => {
@@ -120,4 +124,81 @@ describe("McpTransportType", () => {
expect(McpTransportType.STDIO).toBe("stdio");
expect(McpTransportType.HTTP).toBe("http");
});
-});
\ No newline at end of file
+});
+
+describe("AgentConfig.description (optional)", () => {
+ it("accepts optional description on AgentConfig via schema", () => {
+ // Arrange — a valid base config that additionally provides a description.
+ const config = {
+ name: "described-agent",
+ model: "openai:gpt-4o",
+ description: "An agent that writes tests.",
+ tools: [],
+ backend: { type: BackendType.STORE, checkpoint_backend: "memory" },
+ hitl: { rules: {} },
+ memory: [],
+ skills: [],
+ subagents: [],
+ mcp_servers: [],
+ debug: false,
+ };
+
+ // Act
+ const parsed = agentConfigSchema.parse(config);
+
+ // Assert — the description must round-trip through the schema.
+ expect(parsed).toHaveProperty("description", "An agent that writes tests.");
+ });
+
+ it("accepts a null description on AgentConfig via schema", () => {
+ const config = {
+ name: "null-desc-agent",
+ model: "openai:gpt-4o",
+ description: null,
+ tools: [],
+ backend: { type: BackendType.STORE, checkpoint_backend: "memory" },
+ hitl: { rules: {} },
+ memory: [],
+ skills: [],
+ subagents: [],
+ mcp_servers: [],
+ debug: false,
+ };
+
+ const parsed = agentConfigSchema.parse(config);
+ expect(parsed).toHaveProperty("description", null);
+ });
+});
+
+describe("SubAgentConfig.agent_ref (optional)", () => {
+ it("accepts an agent_ref on a subagent via schema", () => {
+ // Arrange — a subagent that references another agent by name.
+ const sub = {
+ name: "researcher",
+ description: "Research sub-agent",
+ agent_ref: "researcher",
+ tools: [],
+ skills: [],
+ mcp_servers: [],
+ };
+
+ // Act
+ const parsed = subAgentConfigSchema.parse(sub);
+
+ // Assert — agent_ref must round-trip.
+ expect(parsed).toHaveProperty("agent_ref", "researcher");
+ });
+
+ it("defaults agent_ref to undefined when omitted", () => {
+ const sub = {
+ name: "researcher",
+ description: "Research sub-agent",
+ tools: [],
+ skills: [],
+ mcp_servers: [],
+ };
+
+ const parsed = subAgentConfigSchema.parse(sub);
+ expect(parsed).not.toHaveProperty("agent_ref");
+ });
+});
diff --git a/tests/unit/domain/entities/agentConfigMetadata.test.ts b/tests/unit/domain/entities/agentConfigMetadata.test.ts
new file mode 100644
index 0000000..b415190
--- /dev/null
+++ b/tests/unit/domain/entities/agentConfigMetadata.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import { createAgentConfigMetadata } from "../../../fixtures/external";
+
+describe("AgentConfigMetadata", () => {
+ it("does NOT include is_builtin (field removed by migration 003)", () => {
+ // TS types are erased at runtime, so we assert via the fixture: the
+ // property must not be present on the produced object.
+ const metadata = createAgentConfigMetadata();
+
+ expect("is_builtin" in metadata).toBe(false);
+ expect(metadata).not.toHaveProperty("is_builtin");
+ });
+
+ it("includes the expected fields", () => {
+ const metadata = createAgentConfigMetadata();
+
+ expect(metadata).toHaveProperty("name");
+ expect(metadata).toHaveProperty("model");
+ expect(metadata).toHaveProperty("minio_path");
+ expect(metadata).toHaveProperty("created_at");
+ expect(metadata).toHaveProperty("updated_at");
+ });
+
+ it("accepts optional description (string | null) — defaults to null", () => {
+ // Arrange — the default fixture must surface a description field
+ // (null when unspecified) so the metadata reflects the new schema.
+ const metadata = createAgentConfigMetadata();
+
+ // Assert — description must be present on the metadata object.
+ expect(metadata).toHaveProperty("description");
+ expect(metadata.description).toBeNull();
+ });
+
+ it("accepts a string description via overrides", () => {
+ const metadata = createAgentConfigMetadata({
+ description: "An agent that writes tests.",
+ } as Partial>);
+
+ expect(metadata).toHaveProperty("description", "An agent that writes tests.");
+ });
+});
diff --git a/tests/unit/domain/entities/agentConfigSchema.test.ts b/tests/unit/domain/entities/agentConfigSchema.test.ts
index 9b99129..37ec1a2 100644
--- a/tests/unit/domain/entities/agentConfigSchema.test.ts
+++ b/tests/unit/domain/entities/agentConfigSchema.test.ts
@@ -79,17 +79,13 @@ describe("backendConfigSchema", () => {
it("rejects filesystem backend type", () => {
// Arrange — filesystem must no longer be a valid backend type
// Act & Assert — parsing should throw
- expect(() =>
- backendConfigSchema.parse({ type: "filesystem" }),
- ).toThrow();
+ expect(() => backendConfigSchema.parse({ type: "filesystem" })).toThrow();
});
it("rejects composite backend type", () => {
// Arrange — composite must no longer be a valid backend type
// Act & Assert — parsing should throw
- expect(() =>
- backendConfigSchema.parse({ type: "composite" }),
- ).toThrow();
+ expect(() => backendConfigSchema.parse({ type: "composite" })).toThrow();
});
it("rejects root_dir field", () => {
@@ -200,6 +196,50 @@ describe("subAgentConfigSchema", () => {
}),
).toThrow();
});
+
+ it("accepts agent_ref", () => {
+ // Arrange — a subagent referencing another agent.
+ const sub = {
+ name: "researcher",
+ description: "Research agent",
+ agent_ref: "researcher",
+ tools: [],
+ skills: [],
+ mcp_servers: [],
+ };
+
+ // Act
+ const result = subAgentConfigSchema.parse(sub);
+
+ // Assert — agent_ref must round-trip through the schema.
+ expect(result).toHaveProperty("agent_ref", "researcher");
+ });
+
+ it("accepts null agent_ref", () => {
+ const result = subAgentConfigSchema.parse({
+ name: "researcher",
+ description: "Research agent",
+ agent_ref: null,
+ tools: [],
+ skills: [],
+ mcp_servers: [],
+ });
+ expect(result.agent_ref).toBeNull();
+ });
+
+ it("still rejects missing description when agent_ref present", () => {
+ // Arrange — a subagent with agent_ref but no description.
+ // Assert — description is still required even when agent_ref is set.
+ expect(() =>
+ subAgentConfigSchema.parse({
+ name: "researcher",
+ agent_ref: "researcher",
+ tools: [],
+ skills: [],
+ mcp_servers: [],
+ }),
+ ).toThrow();
+ });
});
describe("agentConfigSchema", () => {
@@ -362,4 +402,31 @@ describe("agentConfigSchema", () => {
});
expect(result.system_prompt_file).toBe("/prompts/system.txt");
});
-});
\ No newline at end of file
+
+ it("accepts optional description", () => {
+ // Arrange — a config with a description field.
+ const config = {
+ ...baseConfig,
+ description: "An agent that helps with testing.",
+ };
+
+ // Act
+ const result = agentConfigSchema.parse(config);
+
+ // Assert — description must be present on the parsed result.
+ expect(result.description).toBe("An agent that helps with testing.");
+ });
+
+ it("accepts null description", () => {
+ const result = agentConfigSchema.parse({
+ ...baseConfig,
+ description: null,
+ });
+ expect(result.description).toBeNull();
+ });
+
+ it("does not require description (omitted is valid)", () => {
+ const result = agentConfigSchema.parse(baseConfig);
+ expect(result).not.toHaveProperty("description");
+ });
+});
diff --git a/tests/unit/domain/entities/registeredMcpServer.test.ts b/tests/unit/domain/entities/registeredMcpServer.test.ts
new file mode 100644
index 0000000..07a731d
--- /dev/null
+++ b/tests/unit/domain/entities/registeredMcpServer.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect } from "vitest";
+import type {
+ RegisteredMcpServer,
+ McpServerInput,
+} from "@/domain/entities/mcpServer/registeredMcpServer";
+import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+
+describe("RegisteredMcpServer entity", () => {
+ it("exposes the full set of registry fields on a masked entry", () => {
+ // A masked entry (as returned by GET /api/v1/mcp/servers) — secrets
+ // stripped to null / empty.
+ const masked: RegisteredMcpServer = {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 5,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ };
+
+ expect(masked.name).toBe("weather");
+ expect(masked.transport).toBe(McpTransportType.HTTP);
+ expect(masked.url).toBe("https://example.com/mcp");
+ expect(masked.headers).toEqual({});
+ expect(masked.env).toEqual({});
+ expect(masked.auth_token).toBeNull();
+ expect(masked.tool_count).toBe(5);
+ expect(typeof masked.created_at).toBe("string");
+ expect(typeof masked.updated_at).toBe("string");
+ });
+
+ it("supports a revealed entry where secrets are present in plaintext", () => {
+ // A revealed entry (as returned by GET /api/v1/mcp/servers/{name}/reveal)
+ // has the auth_token, headers and env populated in plaintext.
+ const revealed: RegisteredMcpServer = {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: { Authorization: "Bearer secret-token" },
+ env: { LOG_LEVEL: "debug" },
+ auth_token: "secret-token",
+ tool_count: 5,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ };
+
+ expect(revealed.auth_token).toBe("secret-token");
+ expect(revealed.headers.Authorization).toBe("Bearer secret-token");
+ expect(revealed.env.LOG_LEVEL).toBe("debug");
+ });
+
+ it("always carries transport as a McpTransportType", () => {
+ const stdioEntry: RegisteredMcpServer = {
+ name: "fs",
+ transport: McpTransportType.STDIO,
+ url: "",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 0,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ };
+
+ expect(stdioEntry.transport).toBe(McpTransportType.STDIO);
+ });
+
+ it("supports source_type 'openapi' with an openapi_url pointing at the spec", () => {
+ const openapiEntry: RegisteredMcpServer = {
+ name: "petstore",
+ transport: McpTransportType.HTTP,
+ url: "",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 3,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "openapi",
+ openapi_url: "https://example.com/openapi.json",
+ };
+
+ expect(openapiEntry.source_type).toBe("openapi");
+ expect(openapiEntry.openapi_url).toBe("https://example.com/openapi.json");
+ });
+
+ it("defaults source_type to 'external' for an external MCP server entry", () => {
+ const externalEntry: RegisteredMcpServer = {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 5,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ };
+
+ expect(externalEntry.source_type).toBe("external");
+ expect(externalEntry.openapi_url).toBeNull();
+ });
+
+ it("accepts McpServerInput with source_type and openapi_url for openapi servers", () => {
+ const openapiInput: McpServerInput = {
+ name: "petstore",
+ url: null,
+ headers: {},
+ env: {},
+ auth_token: null,
+ source_type: "openapi",
+ openapi_url: "https://example.com/openapi.json",
+ };
+
+ expect(openapiInput.source_type).toBe("openapi");
+ expect(openapiInput.openapi_url).toBe("https://example.com/openapi.json");
+ expect(openapiInput.url).toBeNull();
+ expect(openapiInput.auth_token).toBeNull();
+ });
+
+ it("accepts McpServerInput with source_type 'external' and null openapi_url", () => {
+ const externalInput: McpServerInput = {
+ name: "weather",
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: "tok",
+ source_type: "external",
+ openapi_url: null,
+ };
+
+ expect(externalInput.source_type).toBe("external");
+ expect(externalInput.openapi_url).toBeNull();
+ });
+});
diff --git a/tests/unit/domain/entities/storeFile.test.ts b/tests/unit/domain/entities/storeFile.test.ts
index 521cae5..76081f9 100644
--- a/tests/unit/domain/entities/storeFile.test.ts
+++ b/tests/unit/domain/entities/storeFile.test.ts
@@ -23,4 +23,4 @@ describe("StoreFile", () => {
expect(file.path).toBe("skills/rag/SKILL.md");
expect(file.content).toBe("---\nname: rag\n---\n# RAG skill");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/hooks/mcpServer/useCreateMcpServer.test.tsx b/tests/unit/hooks/mcpServer/useCreateMcpServer.test.tsx
new file mode 100644
index 0000000..301f502
--- /dev/null
+++ b/tests/unit/hooks/mcpServer/useCreateMcpServer.test.tsx
@@ -0,0 +1,126 @@
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useCreateMcpServer } from "@/application/hooks/mcpServer/useCreateMcpServer";
+import { mcpRegistryApi } from "@/infrastructure/api/mcpServer/mcpRegistryApi";
+import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/mcpServer/mcpRegistryApi", () => ({
+ mcpRegistryApi: {
+ list: vi.fn(),
+ get: vi.fn(),
+ reveal: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ validate: vi.fn(),
+ },
+}));
+
+const createInput = {
+ name: "weather",
+ url: "https://example.com/mcp",
+ headers: { Authorization: "Bearer tok" },
+ env: { LOG_LEVEL: "debug" },
+ auth_token: "tok",
+ source_type: "external" as const,
+ openapi_url: null,
+};
+
+const maskedEntry = {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 5,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+};
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ return {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ queryClient,
+ };
+}
+
+describe("useCreateMcpServer", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("calls mcpRegistryApi.create with the input and resolves to the masked entry", async () => {
+ vi.mocked(mcpRegistryApi.create).mockResolvedValue(maskedEntry);
+ const { wrapper } = createWrapper();
+
+ const { result } = renderHook(() => useCreateMcpServer(), { wrapper });
+
+ act(() => {
+ result.current.mutate(createInput);
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(mcpRegistryApi.create).toHaveBeenCalledWith(createInput);
+ expect(result.current.data).toEqual(maskedEntry);
+ });
+
+ it("propagates the error when create fails (e.g. 409 conflict)", async () => {
+ vi.mocked(mcpRegistryApi.create).mockRejectedValue(new Error("Server already exists"));
+ const { wrapper } = createWrapper();
+
+ const { result } = renderHook(() => useCreateMcpServer(), { wrapper });
+
+ act(() => {
+ result.current.mutate(createInput);
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.error?.message).toBe("Server already exists");
+ });
+
+ it("invalidates ['mcp-registry'] on success", async () => {
+ vi.mocked(mcpRegistryApi.create).mockResolvedValue(maskedEntry);
+ const { wrapper, queryClient } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ const { result } = renderHook(() => useCreateMcpServer(), { wrapper });
+
+ act(() => {
+ result.current.mutate(createInput);
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["mcp-registry"] });
+ });
+
+ it("does NOT invalidate ['mcp-registry'] on failure", async () => {
+ vi.mocked(mcpRegistryApi.create).mockRejectedValue(new Error("Server already exists"));
+ const { wrapper, queryClient } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ const { result } = renderHook(() => useCreateMcpServer(), { wrapper });
+
+ act(() => {
+ result.current.mutate(createInput);
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["mcp-registry"] });
+ });
+});
diff --git a/tests/unit/hooks/mcpServer/useMcpRegistry.test.tsx b/tests/unit/hooks/mcpServer/useMcpRegistry.test.tsx
new file mode 100644
index 0000000..e14fad0
--- /dev/null
+++ b/tests/unit/hooks/mcpServer/useMcpRegistry.test.tsx
@@ -0,0 +1,104 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useMcpRegistry } from "@/application/hooks/mcpServer/useMcpRegistry";
+import { mcpRegistryApi } from "@/infrastructure/api/mcpServer/mcpRegistryApi";
+import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+import type { RegisteredMcpServer } from "@/domain/entities/mcpServer/registeredMcpServer";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/mcpServer/mcpRegistryApi", () => ({
+ mcpRegistryApi: {
+ list: vi.fn(),
+ get: vi.fn(),
+ reveal: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ validate: vi.fn(),
+ },
+}));
+
+const servers: RegisteredMcpServer[] = [
+ {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 5,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ },
+ {
+ name: "fs",
+ transport: McpTransportType.STDIO,
+ url: "",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 0,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ },
+];
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ return {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ queryClient,
+ };
+}
+
+describe("useMcpRegistry", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns the list of registered servers from mcpRegistryApi.list", async () => {
+ vi.mocked(mcpRegistryApi.list).mockResolvedValue(servers);
+ const { wrapper } = createWrapper();
+
+ const { result } = renderHook(() => useMcpRegistry(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(mcpRegistryApi.list).toHaveBeenCalledOnce();
+ expect(result.current.data).toEqual(servers);
+ });
+
+ it("uses query key ['mcp-registry']", async () => {
+ vi.mocked(mcpRegistryApi.list).mockResolvedValue(servers);
+ const { wrapper, queryClient } = createWrapper();
+
+ const { result } = renderHook(() => useMcpRegistry(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const cached = queryClient.getQueryData(["mcp-registry"]);
+ expect(cached).toEqual(servers);
+ });
+
+ it("returns empty array when registry is empty", async () => {
+ vi.mocked(mcpRegistryApi.list).mockResolvedValue([]);
+ const { wrapper } = createWrapper();
+
+ const { result } = renderHook(() => useMcpRegistry(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual([]);
+ });
+});
diff --git a/tests/unit/hooks/store/useDeleteStoreFile.test.tsx b/tests/unit/hooks/store/useDeleteStoreFile.test.tsx
index 8b7ff45..08caa74 100644
--- a/tests/unit/hooks/store/useDeleteStoreFile.test.tsx
+++ b/tests/unit/hooks/store/useDeleteStoreFile.test.tsx
@@ -70,4 +70,4 @@ describe("useDeleteStoreFile", () => {
// Assert
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["store-files"] });
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/hooks/store/usePutStoreFile.test.tsx b/tests/unit/hooks/store/usePutStoreFile.test.tsx
index 1e05acd..b0a3ce1 100644
--- a/tests/unit/hooks/store/usePutStoreFile.test.tsx
+++ b/tests/unit/hooks/store/usePutStoreFile.test.tsx
@@ -70,4 +70,4 @@ describe("usePutStoreFile", () => {
// Assert — any query whose key starts with "store-files" must be invalidated
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["store-files"] });
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/hooks/store/useStoreFile.test.tsx b/tests/unit/hooks/store/useStoreFile.test.tsx
index 700fa14..2a1d5af 100644
--- a/tests/unit/hooks/store/useStoreFile.test.tsx
+++ b/tests/unit/hooks/store/useStoreFile.test.tsx
@@ -38,10 +38,9 @@ describe("useStoreFile", () => {
vi.mocked(storeApi.getFile).mockResolvedValue(file);
// Act
- const { result } = renderHook(
- () => useStoreFile("skills/rag/SKILL.md"),
- { wrapper: createWrapper() },
- );
+ const { result } = renderHook(() => useStoreFile("skills/rag/SKILL.md"), {
+ wrapper: createWrapper(),
+ });
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -54,10 +53,9 @@ describe("useStoreFile", () => {
vi.mocked(storeApi.getFile).mockResolvedValue(null);
// Act
- const { result } = renderHook(
- () => useStoreFile("skills/missing/SKILL.md"),
- { wrapper: createWrapper() },
- );
+ const { result } = renderHook(() => useStoreFile("skills/missing/SKILL.md"), {
+ wrapper: createWrapper(),
+ });
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -79,18 +77,12 @@ describe("useStoreFile", () => {
);
// Act
- const { result } = renderHook(
- () => useStoreFile("skills/rag/SKILL.md"),
- { wrapper },
- );
+ const { result } = renderHook(() => useStoreFile("skills/rag/SKILL.md"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Assert — the cached data must be retrievable under the exact query key
- const cached = queryClient.getQueryData([
- "store-file",
- "skills/rag/SKILL.md",
- ]);
+ const cached = queryClient.getQueryData(["store-file", "skills/rag/SKILL.md"]);
expect(cached).toEqual(file);
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/hooks/store/useStoreFiles.test.tsx b/tests/unit/hooks/store/useStoreFiles.test.tsx
index 2e76051..db1328a 100644
--- a/tests/unit/hooks/store/useStoreFiles.test.tsx
+++ b/tests/unit/hooks/store/useStoreFiles.test.tsx
@@ -81,10 +81,7 @@ describe("useStoreFiles", () => {
// Assert — the query key registered for this prefix must include "store-files" and the prefix
getQueryDataSpy.mockRestore();
// The presence of data under the exact key is verified by fetching it back
- const cached = queryClient.getQueryData([
- "store-files",
- "memories/",
- ]);
+ const cached = queryClient.getQueryData(["store-files", "memories/"]);
expect(cached).toEqual([]);
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/infrastructure/api/mcpAxiosInstance.test.ts b/tests/unit/infrastructure/api/mcpAxiosInstance.test.ts
new file mode 100644
index 0000000..e0078f9
--- /dev/null
+++ b/tests/unit/infrastructure/api/mcpAxiosInstance.test.ts
@@ -0,0 +1,173 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+
+// ---------------------------------------------------------------------------
+// Mock configRepository so we can control what getConfig() returns per test.
+// vi.resetModules() in beforeEach forces a fresh mcpAxiosInstance module
+// (and thus a fresh cachedMcpBaseURL) for every test.
+// ---------------------------------------------------------------------------
+
+let mockConfig: Record = {
+ apiBaseUrl: "http://localhost:8000",
+ wsBaseUrl: "ws://localhost:8000",
+ ragApiBaseUrl: "",
+ mcpApiBaseUrl: "",
+};
+
+vi.mock("@/infrastructure/config/configRepositoryInstance", () => ({
+ configRepository: {
+ getConfig: vi.fn(async () => mockConfig),
+ },
+}));
+
+/**
+ * Dynamically imports a fresh mcpAxiosInstance module + the (re-mocked)
+ * configRepository so each test starts with an empty URL cache.
+ */
+async function loadMcpApiClient() {
+ vi.resetModules();
+ const { mcpApiClient } = await import("@/infrastructure/api/mcpAxiosInstance");
+ const { configRepository } = await import("@/infrastructure/config/configRepositoryInstance");
+ return { mcpApiClient, configRepository };
+}
+
+/** A minimal axios adapter that resolves with a 200 empty-body response. */
+function okAdapter() {
+ return vi.fn().mockResolvedValue({
+ data: {},
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ config: {},
+ request: {},
+ });
+}
+
+describe("mcpAxiosInstance", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockConfig = {
+ apiBaseUrl: "http://localhost:8000",
+ wsBaseUrl: "ws://localhost:8000",
+ ragApiBaseUrl: "",
+ mcpApiBaseUrl: "",
+ };
+ });
+
+ describe("mcpApiClient export", () => {
+ it("is an axios instance with get/post/put/delete and interceptors", async () => {
+ const { mcpApiClient } = await loadMcpApiClient();
+
+ expect(typeof mcpApiClient.get).toBe("function");
+ expect(typeof mcpApiClient.post).toBe("function");
+ expect(typeof mcpApiClient.put).toBe("function");
+ expect(typeof mcpApiClient.delete).toBe("function");
+ expect(mcpApiClient.interceptors).toBeDefined();
+ expect(mcpApiClient.interceptors.request).toBeDefined();
+ expect(mcpApiClient.interceptors.response).toBeDefined();
+ });
+ });
+
+ describe("request interceptor — baseURL resolution", () => {
+ it("sets baseURL from appConfig.mcpApiBaseUrl when it is non-empty", async () => {
+ mockConfig.mcpApiBaseUrl = "http://localhost:8020";
+ const { mcpApiClient } = await loadMcpApiClient();
+ const adapter = okAdapter();
+ mcpApiClient.defaults.adapter = adapter;
+
+ await mcpApiClient.get("/api/v1/mcp/servers");
+
+ const calledConfig = adapter.mock.calls[0][0];
+ expect(calledConfig.baseURL).toBe("http://localhost:8020");
+ });
+
+ it("falls back to ragApiBaseUrl when mcpApiBaseUrl is empty", async () => {
+ mockConfig.mcpApiBaseUrl = "";
+ mockConfig.ragApiBaseUrl = "http://localhost:8010";
+ const { mcpApiClient } = await loadMcpApiClient();
+ const adapter = okAdapter();
+ mcpApiClient.defaults.adapter = adapter;
+
+ await mcpApiClient.get("/api/v1/mcp/servers");
+
+ const calledConfig = adapter.mock.calls[0][0];
+ expect(calledConfig.baseURL).toBe("http://localhost:8010");
+ });
+
+ it("falls back to apiBaseUrl when both mcpApiBaseUrl and ragApiBaseUrl are empty", async () => {
+ mockConfig.mcpApiBaseUrl = "";
+ mockConfig.ragApiBaseUrl = "";
+ mockConfig.apiBaseUrl = "http://localhost:8000";
+ const { mcpApiClient } = await loadMcpApiClient();
+ const adapter = okAdapter();
+ mcpApiClient.defaults.adapter = adapter;
+
+ await mcpApiClient.get("/api/v1/mcp/servers");
+
+ const calledConfig = adapter.mock.calls[0][0];
+ expect(calledConfig.baseURL).toBe("http://localhost:8000");
+ });
+ });
+
+ describe("request interceptor — caching", () => {
+ it("only calls configRepository.getConfig() once across multiple requests", async () => {
+ mockConfig.mcpApiBaseUrl = "http://localhost:8020";
+ const { mcpApiClient, configRepository } = await loadMcpApiClient();
+ const adapter = okAdapter();
+ mcpApiClient.defaults.adapter = adapter;
+
+ await mcpApiClient.get("/a");
+ await mcpApiClient.get("/b");
+ await mcpApiClient.get("/c");
+
+ expect(configRepository.getConfig).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("response interceptor — error extraction", () => {
+ it("extracts error.response.data.detail into an Error", async () => {
+ const { mcpApiClient } = await loadMcpApiClient();
+ mcpApiClient.defaults.adapter = vi.fn().mockRejectedValue({
+ response: {
+ data: { detail: "Something went wrong on the server" },
+ status: 500,
+ statusText: "Internal Server Error",
+ headers: {},
+ config: {},
+ },
+ config: {},
+ message: "Request failed with status code 500",
+ });
+
+ await expect(mcpApiClient.get("/api/v1/mcp/servers")).rejects.toThrow(
+ "Something went wrong on the server",
+ );
+ });
+
+ it("falls back to error.message when response.data.detail is absent", async () => {
+ const { mcpApiClient } = await loadMcpApiClient();
+ mcpApiClient.defaults.adapter = vi.fn().mockRejectedValue({
+ response: {
+ data: {},
+ status: 502,
+ statusText: "Bad Gateway",
+ headers: {},
+ config: {},
+ },
+ config: {},
+ message: "Request failed with status code 502",
+ });
+
+ await expect(mcpApiClient.get("/api/v1/mcp/servers")).rejects.toThrow(
+ "Request failed with status code 502",
+ );
+ });
+
+ it("rejects with the original error when there is no response", async () => {
+ const { mcpApiClient } = await loadMcpApiClient();
+ const networkError = new Error("Network Error");
+ mcpApiClient.defaults.adapter = vi.fn().mockRejectedValue(networkError);
+
+ await expect(mcpApiClient.get("/api/v1/mcp/servers")).rejects.toBe(networkError);
+ });
+ });
+});
diff --git a/tests/unit/infrastructure/api/mcpServer/mcpRegistryApi.test.ts b/tests/unit/infrastructure/api/mcpServer/mcpRegistryApi.test.ts
new file mode 100644
index 0000000..6453d4b
--- /dev/null
+++ b/tests/unit/infrastructure/api/mcpServer/mcpRegistryApi.test.ts
@@ -0,0 +1,207 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { mcpRegistryApi } from "@/infrastructure/api/mcpServer/mcpRegistryApi";
+import { mcpApiClient } from "@/infrastructure/api/mcpAxiosInstance";
+import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+
+vi.mock("@/infrastructure/api/mcpAxiosInstance", () => ({
+ mcpApiClient: {
+ get: vi.fn(),
+ post: vi.fn(),
+ put: vi.fn(),
+ delete: vi.fn(),
+ },
+}));
+
+const maskedEntry = {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 5,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+};
+
+const createInput = {
+ name: "weather",
+ url: "https://example.com/mcp",
+ headers: { Authorization: "Bearer tok" },
+ env: { LOG_LEVEL: "debug" },
+ auth_token: "tok",
+ source_type: "external" as const,
+ openapi_url: null,
+};
+
+const openapiInput = {
+ name: "petstore",
+ url: null,
+ headers: { Authorization: "Bearer spec" },
+ env: {},
+ auth_token: null,
+ source_type: "openapi" as const,
+ openapi_url: "https://example.com/openapi.json",
+};
+
+const openapiEntry = {
+ name: "petstore",
+ transport: McpTransportType.HTTP,
+ url: "",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 3,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "openapi",
+ openapi_url: "https://example.com/openapi.json",
+};
+
+describe("mcpRegistryApi", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("list", () => {
+ it("fetches servers from GET /api/v1/mcp/servers", async () => {
+ vi.mocked(mcpApiClient.get).mockResolvedValue({ data: [maskedEntry] });
+
+ const result = await mcpRegistryApi.list();
+
+ expect(mcpApiClient.get).toHaveBeenCalledWith("/api/v1/mcp/servers");
+ expect(result).toEqual([maskedEntry]);
+ });
+ });
+
+ describe("get", () => {
+ it("fetches a masked server from GET /api/v1/mcp/servers/{name}", async () => {
+ vi.mocked(mcpApiClient.get).mockResolvedValue({ data: maskedEntry });
+
+ const result = await mcpRegistryApi.get("weather");
+
+ expect(mcpApiClient.get).toHaveBeenCalledWith("/api/v1/mcp/servers/weather");
+ expect(result).toEqual(maskedEntry);
+ });
+
+ it("encodes server name with special characters", async () => {
+ vi.mocked(mcpApiClient.get).mockResolvedValue({ data: {} });
+
+ await mcpRegistryApi.get("my server");
+
+ expect(mcpApiClient.get).toHaveBeenCalledWith("/api/v1/mcp/servers/my%20server");
+ });
+ });
+
+ describe("reveal", () => {
+ it("fetches the full (plaintext) server from GET /api/v1/mcp/servers/{name}/reveal", async () => {
+ const revealed = {
+ ...maskedEntry,
+ auth_token: "secret",
+ headers: { Authorization: "Bearer secret" },
+ };
+ vi.mocked(mcpApiClient.get).mockResolvedValue({ data: revealed });
+
+ const result = await mcpRegistryApi.reveal("weather");
+
+ expect(mcpApiClient.get).toHaveBeenCalledWith("/api/v1/mcp/servers/weather/reveal");
+ expect(result).toEqual(revealed);
+ });
+ });
+
+ describe("create", () => {
+ it("posts the input to POST /api/v1/mcp/servers and returns the masked entry", async () => {
+ vi.mocked(mcpApiClient.post).mockResolvedValue({ data: maskedEntry });
+
+ const result = await mcpRegistryApi.create(createInput);
+
+ expect(mcpApiClient.post).toHaveBeenCalledWith("/api/v1/mcp/servers", createInput);
+ expect(result).toEqual(maskedEntry);
+ });
+
+ it("sends source_type and openapi_url in the body when creating an openapi server", async () => {
+ vi.mocked(mcpApiClient.post).mockResolvedValue({ data: openapiEntry });
+
+ const result = await mcpRegistryApi.create(openapiInput);
+
+ expect(mcpApiClient.post).toHaveBeenCalledWith("/api/v1/mcp/servers", openapiInput);
+ expect(result).toEqual(openapiEntry);
+ expect(result.source_type).toBe("openapi");
+ expect(result.openapi_url).toBe("https://example.com/openapi.json");
+ });
+ });
+
+ describe("update", () => {
+ it("puts the input to PUT /api/v1/mcp/servers/{name} and returns the masked entry", async () => {
+ vi.mocked(mcpApiClient.put).mockResolvedValue({ data: maskedEntry });
+
+ const result = await mcpRegistryApi.update("weather", createInput);
+
+ expect(mcpApiClient.put).toHaveBeenCalledWith("/api/v1/mcp/servers/weather", createInput);
+ expect(result).toEqual(maskedEntry);
+ });
+
+ it("encodes server name with special characters", async () => {
+ vi.mocked(mcpApiClient.put).mockResolvedValue({ data: {} });
+
+ await mcpRegistryApi.update("my server", createInput);
+
+ expect(mcpApiClient.put).toHaveBeenCalledWith("/api/v1/mcp/servers/my%20server", createInput);
+ });
+ });
+
+ describe("delete", () => {
+ it("sends DELETE to /api/v1/mcp/servers/{name}", async () => {
+ vi.mocked(mcpApiClient.delete).mockResolvedValue({});
+
+ await mcpRegistryApi.delete("weather");
+
+ expect(mcpApiClient.delete).toHaveBeenCalledWith("/api/v1/mcp/servers/weather");
+ });
+ });
+
+ describe("validate", () => {
+ it("posts the input to POST /api/v1/mcp/servers/validate and returns the tool count", async () => {
+ vi.mocked(mcpApiClient.post).mockResolvedValue({ data: { tool_count: 12 } });
+
+ const result = await mcpRegistryApi.validate(createInput);
+
+ expect(mcpApiClient.post).toHaveBeenCalledWith("/api/v1/mcp/servers/validate", createInput);
+ expect(result).toEqual({ tool_count: 12 });
+ });
+
+ it("sends source_type and openapi_url when validating an openapi server", async () => {
+ vi.mocked(mcpApiClient.post).mockResolvedValue({ data: { tool_count: 8 } });
+
+ const result = await mcpRegistryApi.validate(openapiInput);
+
+ expect(mcpApiClient.post).toHaveBeenCalledWith("/api/v1/mcp/servers/validate", openapiInput);
+ expect(result).toEqual({ tool_count: 8 });
+ });
+ });
+
+ describe("error propagation", () => {
+ it("rejects with the axios interceptor Error when the backend returns 422", async () => {
+ // The mcpAxiosInstance response interceptor converts the backend `detail`
+ // into an Error instance — the API layer must let that propagate.
+ vi.mocked(mcpApiClient.post).mockRejectedValue(new Error("Server unreachable"));
+
+ await expect(mcpRegistryApi.create(createInput)).rejects.toThrow("Server unreachable");
+ await expect(mcpRegistryApi.validate(createInput)).rejects.toThrow("Server unreachable");
+ });
+
+ it("rejects with the backend detail on a 409 conflict", async () => {
+ vi.mocked(mcpApiClient.post).mockRejectedValue(new Error("Server already exists"));
+
+ await expect(mcpRegistryApi.create(createInput)).rejects.toThrow("Server already exists");
+ });
+
+ it("rejects with the backend detail on a 404 (delete)", async () => {
+ vi.mocked(mcpApiClient.delete).mockRejectedValue(new Error("Not found"));
+
+ await expect(mcpRegistryApi.delete("missing")).rejects.toThrow("Not found");
+ });
+ });
+});
diff --git a/tests/unit/infrastructure/api/rag/ragApi.test.ts b/tests/unit/infrastructure/api/rag/ragApi.test.ts
index 634e73e..e22d39b 100644
--- a/tests/unit/infrastructure/api/rag/ragApi.test.ts
+++ b/tests/unit/infrastructure/api/rag/ragApi.test.ts
@@ -169,9 +169,7 @@ describe("ragApi", () => {
const result = await ragApi.deleteFolder("docs");
- expect(ragApiClient.delete).toHaveBeenCalledWith(
- "/api/v1/files/folders?prefix=docs",
- );
+ expect(ragApiClient.delete).toHaveBeenCalledWith("/api/v1/files/folders?prefix=docs");
expect(result).toEqual({ message: "Folder deleted" });
});
});
diff --git a/tests/unit/infrastructure/api/rag/ragFileContentApi.test.ts b/tests/unit/infrastructure/api/rag/ragFileContentApi.test.ts
index 3f4e3ff..7012c98 100644
--- a/tests/unit/infrastructure/api/rag/ragFileContentApi.test.ts
+++ b/tests/unit/infrastructure/api/rag/ragFileContentApi.test.ts
@@ -174,4 +174,4 @@ describe("ragFileContentApi", () => {
await expect(ragFileContentApi.readFile("any/file.txt")).rejects.toThrow("Network error");
});
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/infrastructure/api/store/storeApi.test.ts b/tests/unit/infrastructure/api/store/storeApi.test.ts
new file mode 100644
index 0000000..6a4e0c5
--- /dev/null
+++ b/tests/unit/infrastructure/api/store/storeApi.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/infrastructure/api/axiosInstance", () => ({
+ apiClient: {
+ get: vi.fn(),
+ put: vi.fn(),
+ delete: vi.fn(),
+ },
+}));
+
+import { apiClient } from "@/infrastructure/api/axiosInstance";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+
+describe("storeApi.getFile", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns null when the error has status 404 (interceptor-unwrapped)", async () => {
+ vi.mocked(apiClient.get).mockRejectedValue(
+ Object.assign(new Error("File not found: /skills/ghost/SKILL.md"), {
+ status: 404,
+ }),
+ );
+
+ const result = await storeApi.getFile("/skills/ghost/SKILL.md");
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when the raw axios error has response.status 404", async () => {
+ vi.mocked(apiClient.get).mockRejectedValue({
+ response: { status: 404, data: { detail: "File not found" } },
+ message: "Request failed with status code 404",
+ });
+
+ const result = await storeApi.getFile("/skills/ghost/SKILL.md");
+
+ expect(result).toBeNull();
+ });
+
+ it("rethrows on non-404 status (500)", async () => {
+ vi.mocked(apiClient.get).mockRejectedValue(
+ Object.assign(new Error("Internal Server Error"), { status: 500 }),
+ );
+
+ await expect(storeApi.getFile("/skills/x/SKILL.md")).rejects.toThrow("Internal Server Error");
+ });
+
+ it("returns data when the file exists", async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({
+ data: { path: "/skills/rag/SKILL.md", content: "# RAG" },
+ });
+
+ const result = await storeApi.getFile("/skills/rag/SKILL.md");
+
+ expect(result).toEqual({ path: "/skills/rag/SKILL.md", content: "# RAG" });
+ });
+});
diff --git a/tests/unit/infrastructure/axiosInstance.test.ts b/tests/unit/infrastructure/axiosInstance.test.ts
index 377c4bc..e1f4c07 100644
--- a/tests/unit/infrastructure/axiosInstance.test.ts
+++ b/tests/unit/infrastructure/axiosInstance.test.ts
@@ -90,6 +90,30 @@ describe("axiosInstance", () => {
}
});
+ it("error interceptor preserves HTTP status on the rejected Error", async () => {
+ const { apiClient } = await import("@/infrastructure/api/axiosInstance");
+
+ const axiosError = {
+ response: {
+ status: 404,
+ data: { detail: "File not found: /skills/ghost/SKILL.md" },
+ },
+ message: "Request failed with status code 404",
+ };
+
+ const interceptors = (apiClient.interceptors.response as any).handlers;
+ const errorHandler = interceptors[0]?.rejected;
+
+ try {
+ await errorHandler(axiosError);
+ expect.fail("should have thrown");
+ } catch (error: any) {
+ expect(error).toBeInstanceOf(Error);
+ expect(error.message).toBe("File not found: /skills/ghost/SKILL.md");
+ expect(error.status).toBe(404);
+ }
+ });
+
it("error interceptor passes through errors without response", async () => {
const { apiClient } = await import("@/infrastructure/api/axiosInstance");
diff --git a/tests/unit/infrastructure/config/fileConfigRepository.test.ts b/tests/unit/infrastructure/config/fileConfigRepository.test.ts
index d7d007b..49bc68f 100644
--- a/tests/unit/infrastructure/config/fileConfigRepository.test.ts
+++ b/tests/unit/infrastructure/config/fileConfigRepository.test.ts
@@ -24,6 +24,7 @@ describe("FileConfigRepository", () => {
apiBaseUrl: "http://api.test.com",
wsBaseUrl: "ws://api.test.com",
ragApiBaseUrl: "",
+ mcpApiBaseUrl: "",
};
vi.mocked(fetch).mockResolvedValue({
@@ -107,6 +108,7 @@ describe("FileConfigRepository", () => {
apiBaseUrl: "http://api.test.com",
wsBaseUrl: "ws://api.test.com",
ragApiBaseUrl: "",
+ mcpApiBaseUrl: "",
};
vi.mocked(fetch).mockResolvedValue({
ok: true,
diff --git a/tests/unit/lib/frontmatter.test.ts b/tests/unit/lib/frontmatter.test.ts
index 74a10c1..b2d969f 100644
--- a/tests/unit/lib/frontmatter.test.ts
+++ b/tests/unit/lib/frontmatter.test.ts
@@ -45,8 +45,7 @@ describe("parseFrontmatter", () => {
it("handles special characters in description", () => {
// Arrange — description with punctuation and accents
- const input =
- "---\nname: rag\ndescription: RAG: \"queries & accents\" — éàç\n---\n# Body";
+ const input = '---\nname: rag\ndescription: RAG: "queries & accents" — éàç\n---\n# Body';
// Act
const result = parseFrontmatter(input);
@@ -97,4 +96,4 @@ describe("buildFrontmatter", () => {
// Assert
expect(output).toBe("---\nname: rag\ndescription: RAG\n---\n# Body");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/pages/McpRegistryPage.test.tsx b/tests/unit/pages/McpRegistryPage.test.tsx
new file mode 100644
index 0000000..721e889
--- /dev/null
+++ b/tests/unit/pages/McpRegistryPage.test.tsx
@@ -0,0 +1,177 @@
+import { screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../utils/render";
+import McpRegistryPage from "@/application/pages/McpRegistryPage";
+import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+import type { RegisteredMcpServer } from "@/domain/entities/mcpServer/registeredMcpServer";
+
+const servers: RegisteredMcpServer[] = [
+ {
+ name: "weather",
+ transport: McpTransportType.HTTP,
+ url: "https://example.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 5,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ },
+ {
+ name: "github",
+ transport: McpTransportType.HTTP,
+ url: "https://api.github.com/mcp",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 12,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "external",
+ openapi_url: null,
+ },
+ {
+ name: "petstore",
+ transport: McpTransportType.HTTP,
+ url: "",
+ headers: {},
+ env: {},
+ auth_token: null,
+ tool_count: 3,
+ created_at: "2026-04-06T10:00:00Z",
+ updated_at: "2026-04-06T10:00:00Z",
+ source_type: "openapi",
+ openapi_url: "https://example.com/openapi.json",
+ },
+];
+
+// Hoisted mutable so we can flip the registry contents per test without
+// redefining the (hoisted) vi.mock.
+const { registryState } = vi.hoisted(() => ({
+ registryState: {
+ data: undefined as RegisteredMcpServer[] | undefined,
+ isLoading: false,
+ error: null as unknown,
+ },
+}));
+
+vi.mock("@/application/hooks/mcpServer/useMcpRegistry", () => ({
+ useMcpRegistry: () => registryState,
+}));
+
+vi.mock("@/application/hooks/mcpServer/useCreateMcpServer", () => ({
+ useCreateMcpServer: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+}));
+
+vi.mock("@/application/hooks/mcpServer/useDeleteMcpServer", () => ({
+ useDeleteMcpServer: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+}));
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe("McpRegistryPage", () => {
+ it("renders a page container with data-od-id=mcp-registry-view", () => {
+ registryState.data = servers;
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ const container = document.querySelector('[data-od-id="mcp-registry-view"]');
+ expect(container).not.toBeNull();
+ expect(container).toBeInTheDocument();
+ });
+
+ it("renders the Add MCP Server button", () => {
+ registryState.data = servers;
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ expect(screen.getByRole("button", { name: /add mcp server/i })).toBeInTheDocument();
+ });
+
+ it("shows a count badge with the total number of servers", () => {
+ registryState.data = servers;
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ const badge = document.querySelector('[data-od-id="mcp-server-count"]');
+ expect(badge).not.toBeNull();
+ expect(badge?.textContent).toMatch(/3 servers/i);
+ });
+
+ it("renders one card per registered server with its name and url", () => {
+ registryState.data = servers;
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ expect(screen.getByText("weather")).toBeInTheDocument();
+ expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
+
+ expect(screen.getByText("github")).toBeInTheDocument();
+ expect(screen.getByText("https://api.github.com/mcp")).toBeInTheDocument();
+ });
+
+ it("renders the tool count for each server card", () => {
+ registryState.data = servers;
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ expect(screen.getByText(/5 tools/i)).toBeInTheDocument();
+ expect(screen.getByText(/12 tools/i)).toBeInTheDocument();
+ });
+
+ it("renders edit and delete actions for each card", () => {
+ registryState.data = servers;
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ // One edit button per card
+ expect(screen.getAllByRole("button", { name: /edit/i })).toHaveLength(3);
+ // One delete button per card
+ expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(3);
+ });
+
+ it("renders an OpenAPI badge and openapi_url for an openapi server", () => {
+ registryState.data = servers;
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ // The petstore card shows an OpenAPI badge.
+ const openapiBadge = document.querySelector(
+ '[data-od-id="mcp-server-card-petstore"] [data-od-id="mcp-source-type-badge"]',
+ );
+ expect(openapiBadge).not.toBeNull();
+ expect(openapiBadge?.textContent).toMatch(/openapi/i);
+
+ // The openapi_url is displayed on the card.
+ expect(screen.getByText("https://example.com/openapi.json")).toBeInTheDocument();
+ });
+
+ it("renders an External badge for an external server", () => {
+ registryState.data = servers;
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ const externalBadge = document.querySelector(
+ '[data-od-id="mcp-server-card-weather"] [data-od-id="mcp-source-type-badge"]',
+ );
+ expect(externalBadge).not.toBeNull();
+ expect(externalBadge?.textContent).toMatch(/external/i);
+ });
+
+ it("renders the New MCP Server create card when the registry is empty", () => {
+ registryState.data = [];
+ renderWithProviders( , { initialEntries: ["/mcp-registry"] });
+
+ // Empty state now mirrors the other grids: a dashed create card is rendered
+ // instead of a centered message + middle button.
+ const createCard = document.querySelector('[data-od-id="mcp-server-card-new"]');
+ expect(createCard).not.toBeNull();
+ expect(createCard).toBeInTheDocument();
+ expect(screen.getByText("New MCP Server")).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/pages/SettingsPage.test.tsx b/tests/unit/pages/SettingsPage.test.tsx
index c99e35e..d952dcd 100644
--- a/tests/unit/pages/SettingsPage.test.tsx
+++ b/tests/unit/pages/SettingsPage.test.tsx
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, it, expect, beforeEach } from "vitest";
import { renderWithProviders } from "../../utils/render";
import SettingsPage from "@/application/pages/SettingsPage";
-import { useSettingsStore } from "@/application/stores/useSettingsStore";
+import { useSettingsStore, FONT_DEFAULT } from "@/application/stores/useSettingsStore";
describe("SettingsPage", () => {
beforeEach(() => {
@@ -191,7 +191,7 @@ describe("SettingsPage", () => {
expect(state.accent).toBe("#ff00ff");
expect(state.surface).toBe("#10162a");
expect(state.chatFontSize).toBe(10);
- expect(state.chatFontFamily).toBe("var(--font-body)");
+ expect(state.chatFontFamily).toBe(FONT_DEFAULT);
expect(state.llmProvider).toBe("anthropic");
expect(state.apiKey).toBe("");
expect(state.theme).toBe("dark");
@@ -242,4 +242,4 @@ describe("SettingsPage", () => {
expect(useSettingsStore.getState().theme).toBe("dark");
});
-});
\ No newline at end of file
+});
diff --git a/tests/unit/stores/useSettingsStore.test.ts b/tests/unit/stores/useSettingsStore.test.ts
index 2b468e2..429d4ca 100644
--- a/tests/unit/stores/useSettingsStore.test.ts
+++ b/tests/unit/stores/useSettingsStore.test.ts
@@ -1,5 +1,9 @@
-import { describe, it, expect, beforeEach } from "vitest";
-import { useSettingsStore } from "@/application/stores/useSettingsStore";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import {
+ useSettingsStore,
+ FONT_DEFAULT,
+ FONT_MONO,
+} from "@/application/stores/useSettingsStore";
const STORAGE_KEY = "composable-ui-settings";
@@ -17,7 +21,7 @@ describe("useSettingsStore", () => {
expect(state.accent).toBe("#ff00ff");
expect(state.surface).toBe("#10162a");
expect(state.chatFontSize).toBe(10);
- expect(state.chatFontFamily).toBe("var(--font-body)");
+ expect(state.chatFontFamily).toBe(FONT_DEFAULT);
expect(state.llmProvider).toBe("anthropic");
expect(state.apiKey).toBe("");
expect(state.theme).toBe("dark");
@@ -124,7 +128,7 @@ describe("useSettingsStore", () => {
expect(state.accent).toBe("#ff00ff");
expect(state.surface).toBe("#10162a");
expect(state.chatFontSize).toBe(10);
- expect(state.chatFontFamily).toBe("var(--font-body)");
+ expect(state.chatFontFamily).toBe(FONT_DEFAULT);
expect(state.llmProvider).toBe("anthropic");
expect(state.apiKey).toBe("");
expect(state.theme).toBe("dark");
@@ -142,4 +146,59 @@ describe("useSettingsStore", () => {
const parsed = JSON.parse(stored as string);
expect(parsed.state.accent).toBe("#00ff00");
});
-});
\ No newline at end of file
+
+ it("setChatFontSize applies --app-font-scale and --chat-font-size on documentElement", () => {
+ useSettingsStore.getState().setChatFontSize(16);
+
+ expect(document.documentElement.style.getPropertyValue("--app-font-scale")).toBe(
+ (16 / 12).toFixed(3),
+ );
+ expect(document.documentElement.style.getPropertyValue("--chat-font-size")).toBe("16px");
+ });
+
+ it("setChatFontFamily applies --app-font-family on documentElement", () => {
+ useSettingsStore.getState().setChatFontFamily("Georgia, serif");
+
+ expect(document.documentElement.style.getPropertyValue("--app-font-family")).toBe(
+ "Georgia, serif",
+ );
+ });
+
+ it("migrates a legacy persisted chatFontFamily='var(--font-body)' to the concrete default", () => {
+ // Simulate a returning user with the old var() value in localStorage.
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ state: { ...useSettingsStore.getState(), chatFontFamily: "var(--font-body)" },
+ }),
+ );
+
+ // Re-import side effects run at module load; to exercise readStoredSettings
+ // we reset and re-create the store by reading persisted then applying.
+ // Easiest: call resetToDefaults then re-load by re-reading via a fresh
+ // module import is not possible here. Instead, verify the migration
+ // mapping directly by simulating a store re-init through getInitialState.
+ const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) as string);
+ expect(persisted.state.chatFontFamily).toBe("var(--font-body)");
+
+ // Reload the module to trigger readStoredSettings() migration.
+ vi.resetModules();
+ return import("@/application/stores/useSettingsStore").then(({ useSettingsStore: reloaded }) => {
+ expect(reloaded.getState().chatFontFamily).toBe(FONT_DEFAULT);
+ });
+ });
+
+ it("migrates a legacy persisted chatFontFamily='var(--font-mono)' to the concrete mono stack", () => {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ state: { ...useSettingsStore.getState(), chatFontFamily: "var(--font-mono)" },
+ }),
+ );
+
+ vi.resetModules();
+ return import("@/application/stores/useSettingsStore").then(({ useSettingsStore: reloaded }) => {
+ expect(reloaded.getState().chatFontFamily).toBe(FONT_MONO);
+ });
+ });
+});