feat: MCP Tools management UI with QA accessibility fixes

Add dedicated /mcp-tools page for managing MCP servers and tools from the
workbench. Includes CRUD dialogs, config API integration, and feature flag
gating via mcpTools switch. QA pass also fixes accessibility across existing
pages: aria-expanded on chat phase blocks, tabpanel tabindex on prompts,
toggle contrast ratio (WCAG 2.1 SC 1.4.11) on settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-12 00:59:20 -05:00
parent f4d6e49217
commit d5dd15be72
7 changed files with 1234 additions and 4 deletions

View file

@ -8,6 +8,7 @@ import PromptsPage from "@/pages/prompts";
import TokenCostPage from "@/pages/token-cost";
import KnowledgeCoresPage from "@/pages/knowledge-cores";
import FlowsPage from "@/pages/flows";
import McpToolsPage from "@/pages/mcp-tools";
import SettingsPage from "@/pages/settings";
import { NotificationToasts } from "@/components/notification-toasts";
@ -24,6 +25,7 @@ export default function App() {
<Route path="/token-cost" element={<ErrorBoundary><TokenCostPage /></ErrorBoundary>} />
<Route path="/knowledge-cores" element={<ErrorBoundary><KnowledgeCoresPage /></ErrorBoundary>} />
<Route path="/flows" element={<ErrorBoundary><FlowsPage /></ErrorBoundary>} />
<Route path="/mcp-tools" element={<ErrorBoundary><McpToolsPage /></ErrorBoundary>} />
<Route path="/settings" element={<ErrorBoundary><SettingsPage /></ErrorBoundary>} />
<Route path="*" element={<Navigate to="/chat" replace />} />
</Route>

View file

@ -7,6 +7,7 @@ import {
Coins,
BrainCircuit,
Workflow,
Plug,
Settings,
TestTube2,
Wifi,
@ -147,6 +148,8 @@ function FlowSelectorDropdown() {
// ---------------------------------------------------------------------------
export function Sidebar() {
const { featureSwitches } = useSettings((s) => s.settings);
return (
<aside aria-label="Sidebar" className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
{/* Logo area */}
@ -175,6 +178,9 @@ export function Sidebar() {
<NavItem to="/token-cost" icon={Coins} label="Token Cost" />
<NavItem to="/knowledge-cores" icon={BrainCircuit} label="Knowledge Cores" />
<NavItem to="/flows" icon={Workflow} label="Flows" />
{featureSwitches.mcpTools && (
<NavItem to="/mcp-tools" icon={Plug} label="MCP Tools" />
)}
<NavItem to="/settings" icon={Settings} label="Settings" />
</nav>

View file

@ -0,0 +1,209 @@
import { useCallback, useEffect, useState } from "react";
import { useSocket } from "@/providers/socket-provider";
import { useConnectionState } from "@/providers/socket-provider";
import { useProgressStore } from "./use-progress-store";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface McpServerConfig {
url: string;
"remote-name"?: string;
"auth-token"?: string;
}
export interface McpServerEntry {
key: string;
config: McpServerConfig;
}
export interface ToolArgument {
name: string;
type: string;
description: string;
}
export interface ToolConfig {
type: string;
name: string;
description: string;
"mcp-tool"?: string;
group?: string[];
arguments?: ToolArgument[];
}
export interface ToolEntry {
key: string;
config: ToolConfig;
}
export interface UseMcpConfigReturn {
servers: McpServerEntry[];
tools: ToolEntry[];
loading: boolean;
error: string | null;
loadServers: () => Promise<void>;
saveServer: (key: string, config: McpServerConfig) => Promise<void>;
deleteServer: (key: string) => Promise<void>;
loadTools: () => Promise<void>;
saveTool: (key: string, config: ToolConfig) => Promise<void>;
deleteTool: (key: string) => Promise<void>;
refresh: () => Promise<void>;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useMcpConfig(): UseMcpConfigReturn {
const socket = useSocket();
const connectionState = useConnectionState();
const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity);
const [servers, setServers] = useState<McpServerEntry[]>([]);
const [tools, setTools] = useState<ToolEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadServers = useCallback(async () => {
try {
const raw = await socket.config().getValues("mcp");
const entries: McpServerEntry[] = [];
for (const item of raw as { key: string; value: string }[]) {
try {
entries.push({ key: item.key, config: JSON.parse(item.value) });
} catch {
console.warn(`[useMcpConfig] Failed to parse MCP server config: ${item.key}`);
}
}
setServers(entries);
} catch (err) {
console.error("[useMcpConfig] loadServers error:", err);
throw err;
}
}, [socket]);
const loadTools = useCallback(async () => {
try {
const raw = await socket.config().getValues("tool");
const entries: ToolEntry[] = [];
for (const item of raw as { key: string; value: string }[]) {
try {
entries.push({ key: item.key, config: JSON.parse(item.value) });
} catch {
console.warn(`[useMcpConfig] Failed to parse tool config: ${item.key}`);
}
}
setTools(entries);
} catch (err) {
console.error("[useMcpConfig] loadTools error:", err);
throw err;
}
}, [socket]);
const refresh = useCallback(async () => {
const act = "Load MCP config";
try {
setLoading(true);
setError(null);
addActivity(act);
await Promise.all([loadServers(), loadTools()]);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
setLoading(false);
removeActivity(act);
}
}, [addActivity, removeActivity, loadServers, loadTools]);
const saveServer = useCallback(
async (key: string, config: McpServerConfig) => {
const act = `Save MCP server ${key}`;
try {
addActivity(act);
await socket
.config()
.putConfig([{ type: "mcp", key, value: JSON.stringify(config) }]);
await loadServers();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, loadServers],
);
const deleteServer = useCallback(
async (key: string) => {
const act = `Delete MCP server ${key}`;
try {
addActivity(act);
await socket.config().deleteConfig({ type: "mcp", key });
await loadServers();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, loadServers],
);
const saveTool = useCallback(
async (key: string, config: ToolConfig) => {
const act = `Save tool ${key}`;
try {
addActivity(act);
await socket
.config()
.putConfig([{ type: "tool", key, value: JSON.stringify(config) }]);
await loadTools();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, loadTools],
);
const deleteTool = useCallback(
async (key: string) => {
const act = `Delete tool ${key}`;
try {
addActivity(act);
await socket.config().deleteConfig({ type: "tool", key });
await loadTools();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, loadTools],
);
// Auto-load when connection becomes ready
useEffect(() => {
if (
connectionState.status === "connected" ||
connectionState.status === "authenticated" ||
connectionState.status === "unauthenticated"
) {
refresh();
}
}, [connectionState.status, refresh]);
return {
servers,
tools,
loading,
error,
loadServers,
saveServer,
deleteServer,
loadTools,
saveTool,
deleteTool,
refresh,
};
}

View file

@ -78,6 +78,7 @@ function AgentPhaseBlock({
>
<button
onClick={() => setExpanded((p) => !p)}
aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
>
{expanded ? (

File diff suppressed because it is too large Load diff

View file

@ -111,7 +111,7 @@ export default function PromptsPage() {
{/* Templates tab */}
{activeTab === "templates" && (
<div id="panel-templates" role="tabpanel" aria-labelledby="tab-templates" className="flex flex-1 flex-col gap-4 overflow-hidden">
<div id="panel-templates" role="tabpanel" aria-labelledby="tab-templates" tabIndex={0} className="flex flex-1 flex-col gap-4 overflow-hidden">
{loading && prompts.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
@ -201,7 +201,7 @@ export default function PromptsPage() {
{/* System Prompt tab */}
{activeTab === "system" && (
<div id="panel-system" role="tabpanel" aria-labelledby="tab-system" className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
<div id="panel-system" role="tabpanel" aria-labelledby="tab-system" tabIndex={0} className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
<div className="border-b border-border bg-surface-100 px-4 py-3">
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
System Prompt

View file

@ -342,7 +342,7 @@ export default function SettingsPage() {
onClick={toggleTheme}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isDark ? "bg-brand-600" : "bg-surface-400",
isDark ? "bg-brand-600" : "bg-fg-subtle",
)}
>
<span
@ -372,7 +372,7 @@ export default function SettingsPage() {
onClick={() => updateFeatureSwitches({ [key]: !enabled })}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
enabled ? "bg-brand-600" : "bg-surface-400",
enabled ? "bg-brand-600" : "bg-fg-subtle",
)}
>
<span className={cn(