mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
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:
parent
f4d6e49217
commit
d5dd15be72
7 changed files with 1234 additions and 4 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
209
ts/packages/workbench/src/hooks/use-mcp-config.ts
Normal file
209
ts/packages/workbench/src/hooks/use-mcp-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
1012
ts/packages/workbench/src/pages/mcp-tools.tsx
Normal file
1012
ts/packages/workbench/src/pages/mcp-tools.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue