chore(frontend): rename Cyrillic clipboard.ts and remove dead code

- src/lib/сlipboard.ts → clipboard.ts: the filename used a Cyrillic 'с'
  (U+0441) instead of Latin 'c', which made the path silently
  unmatchable by anyone typing the import with a Latin letter. Update
  the 4 importers in features/flows.
- Delete unreachable settings-mcp-server(s).tsx (~1.3k lines): no
  routes registered in app.tsx and nothing imports them. Remove the
  matching dead branches and commented menu entry from
  settings-layout.tsx.
- Drop unused dev deps simple-git-hooks and lint-staged: no config in
  the repo, not used by CI or Docker, hooks were never wired up. Also
  remove simple-git-hooks from pnpm.onlyBuiltDependencies.
- Drop unused tsconfig path aliases: @/ui/*, @env (env.ts doesn't even
  exist), and @pkg. Only @/* remains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-15 06:53:53 +07:00
co-authored by Claude Opus 4.7
parent 692d91cffa
commit abe45e33c9
11 changed files with 7 additions and 1404 deletions
+1 -4
View File
@@ -127,11 +127,9 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^29.1.1",
"lint-staged": "^16.2.6",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"simple-git-hooks": "^2.11.1",
"tailwindcss": "^4.1.18",
"tsx": "^4.19.3",
"typescript": "^5.6.2",
@@ -144,8 +142,7 @@
"pnpm": {
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"simple-git-hooks"
"esbuild"
]
},
"eslintConfig": {
-38
View File
@@ -330,9 +330,6 @@ importers:
jsdom:
specifier: ^29.1.1
version: 29.1.1(@noble/hashes@1.8.0)
lint-staged:
specifier: ^16.2.6
version: 16.4.0
postcss:
specifier: ^8.4.47
version: 8.5.9
@@ -342,9 +339,6 @@ importers:
prettier-plugin-tailwindcss:
specifier: ^0.7.2
version: 0.7.2(prettier@3.8.2)
simple-git-hooks:
specifier: ^2.11.1
version: 2.13.1
tailwindcss:
specifier: ^4.1.18
version: 4.2.2
@@ -3109,10 +3103,6 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -4403,11 +4393,6 @@ packages:
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
lint-staged@16.4.0:
resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==}
engines: {node: '>=20.17'}
hasBin: true
listr2@9.0.5:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'}
@@ -5399,10 +5384,6 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-git-hooks@2.13.1:
resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==}
hasBin: true
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
@@ -5465,10 +5446,6 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
string-env-interpolation@1.0.1:
resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==}
@@ -9065,8 +9042,6 @@ snapshots:
comma-separated-tokens@2.0.3: {}
commander@14.0.3: {}
commander@2.20.3: {}
commander@8.3.0: {}
@@ -10506,15 +10481,6 @@ snapshots:
linkifyjs@4.3.2: {}
lint-staged@16.4.0:
dependencies:
commander: 14.0.3
listr2: 9.0.5
picomatch: 4.0.4
string-argv: 0.3.2
tinyexec: 1.1.1
yaml: 2.8.3
listr2@9.0.5:
dependencies:
cli-truncate: 5.2.0
@@ -11785,8 +11751,6 @@ snapshots:
signal-exit@4.1.0: {}
simple-git-hooks@2.13.1: {}
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
@@ -11844,8 +11808,6 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
string-argv@0.3.2: {}
string-env-interpolation@1.0.1: {}
string-width@4.2.3:
@@ -51,12 +51,6 @@ const menuItems: readonly MenuItem[] = [
path: '/settings/api-tokens',
title: 'PentAGI API',
},
// {
// id: 'mcp-servers',
// title: 'MCP Servers',
// path: '/settings/mcp-servers',
// icon: <Server className="size-4" />,
// },
] as const;
// Individual menu item component to properly use hooks
@@ -98,14 +92,6 @@ const SettingsHeader = () => {
return 'Edit Provider';
}
if (path === '/settings/mcp-servers/new') {
return 'Create MCP Server';
}
if (path.startsWith('/settings/mcp-servers/')) {
return 'Edit MCP Server';
}
if (path === '/settings/prompts/new') {
return 'Create Prompt';
}
@@ -5,8 +5,8 @@ import type { AgentLogFragmentFragment } from '@/graphql/types';
import Markdown from '@/components/shared/markdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { copyMessageToClipboard } from '@/lib/clipboard';
import { formatDate } from '@/lib/utils/format';
import { copyMessageToClipboard } from '@/lib/сlipboard';
import FlowAgentIcon from './flow-agent-icon';
@@ -7,9 +7,9 @@ import Markdown from '@/components/shared/markdown';
import Terminal from '@/components/shared/terminal';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { MessageLogType, ResultFormat } from '@/graphql/types';
import { copyMessageToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils';
import { formatDate } from '@/lib/utils/format';
import { copyMessageToClipboard } from '@/lib/сlipboard';
import FlowMessageTypeIcon from './flow-message-type-icon';
@@ -6,8 +6,8 @@ import type { SearchLogFragmentFragment } from '@/graphql/types';
import Markdown from '@/components/shared/markdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import FlowAgentIcon from '@/features/flows/agents/flow-agent-icon';
import { copyMessageToClipboard } from '@/lib/clipboard';
import { formatDate, formatName } from '@/lib/utils/format';
import { copyMessageToClipboard } from '@/lib/сlipboard';
interface FlowToolProps {
log: SearchLogFragmentFragment;
@@ -7,8 +7,8 @@ import Markdown from '@/components/shared/markdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import FlowAgentIcon from '@/features/flows/agents/flow-agent-icon';
import { VectorStoreAction } from '@/graphql/types';
import { copyMessageToClipboard } from '@/lib/clipboard';
import { formatDate } from '@/lib/utils/format';
import { copyMessageToClipboard } from '@/lib/сlipboard';
import FlowVectorStoreActionIcon from './flow-vector-store-action-icon';
@@ -1,668 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Play, Plus, Save, Server, Trash2 } from 'lucide-react';
import { Fragment, useMemo, useState } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { z } from 'zod';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { StatusCard } from '@/components/ui/status-card';
import { Switch } from '@/components/ui/switch';
type McpTransport = 'sse' | 'stdio';
const keyValueSchema = z.object({
key: z.string().min(1, 'Key is required'),
value: z.string().min(1, 'Value is required'),
});
const formSchema = z.object({
name: z
.string({ required_error: 'Name is required' })
.min(1, 'Name is required')
.max(50, 'Maximum 50 characters allowed'),
sse: z
.object({
headers: z.array(keyValueSchema).optional().default([]),
url: z.string().min(1, 'URL is required'),
})
.optional(),
stdio: z
.object({
args: z.string().optional().nullable(),
command: z.string().min(1, 'Command is required'),
env: z.array(keyValueSchema).optional().default([]),
})
.optional(),
tools: z
.array(
z.object({
description: z.string().optional(),
enabled: z.boolean().optional().default(true),
name: z.string().min(1, 'Tool name is required'),
}),
)
.default([]),
transport: z.enum(['stdio', 'sse'], { required_error: 'Transport is required' }),
});
type FormData = z.infer<typeof formSchema>;
// Mock helpers
const getMockServerById = (id: number) => {
const samples = [
{
id: 1,
name: 'Local Filesystem',
sse: undefined,
stdio: {
args: '/opt/mcp/filesystem/index.js --root /Users/sirozha/Projects',
command: '/usr/local/bin/node',
env: [{ key: 'NODE_ENV', value: 'production' }],
},
tools: [
{ description: 'Read a file from disk', enabled: true, name: 'readFile' },
{ description: 'Write content to a file', enabled: false, name: 'writeFile' },
],
transport: 'stdio' as McpTransport,
},
{
id: 2,
name: 'Slack (Prod)',
sse: {
headers: [{ key: 'Authorization', value: 'Bearer ***' }],
url: 'https://mcp.example.com/slack/sse',
},
stdio: undefined,
tools: [
{ description: 'Send a message to a channel', enabled: true, name: 'postMessage' },
{ description: 'Fetch Slack user info', enabled: false, name: 'getUserInfo' },
],
transport: 'sse' as McpTransport,
},
];
return samples.find((s) => s.id === id);
};
const SettingsMcpServer = () => {
const navigate = useNavigate();
const params = useParams();
const isNew = params.mcpServerId === undefined;
const [submitError, setSubmitError] = useState<null | string>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isTestLoading, setIsTestLoading] = useState(false);
const [testMessage, setTestMessage] = useState<null | string>(null);
const [testError, setTestError] = useState<null | string>(null);
const [toolTestLoadingIndex, setToolTestLoadingIndex] = useState<null | number>(null);
const [toolTestIndex, setToolTestIndex] = useState<null | number>(null);
const [toolTestMessage, setToolTestMessage] = useState<null | string>(null);
const [toolTestError, setToolTestError] = useState<null | string>(null);
const defaults: FormData = useMemo(() => {
if (!isNew) {
const id = Number(params.mcpServerId);
const found = !Number.isNaN(id) ? getMockServerById(id) : undefined;
if (found) {
return {
name: found.name,
sse: found.sse,
stdio: found.stdio,
tools: found.tools,
transport: found.transport,
} as FormData;
}
}
return {
name: '',
stdio: { args: '', command: '', env: [] },
tools: [],
transport: 'stdio',
} as FormData;
}, [isNew, params.mcpServerId]);
const form = useForm<FormData>({
defaultValues: defaults,
mode: 'onChange',
resolver: zodResolver(formSchema),
});
const transport = form.watch('transport');
// Field arrays
const stdioEnvArray = useFieldArray({ control: form.control, name: 'stdio.env' as const });
const sseHeadersArray = useFieldArray({ control: form.control, name: 'sse.headers' as const });
const toolsArray = useFieldArray({ control: form.control, name: 'tools' as const });
const handleAddKeyValue = (target: 'env' | 'headers') => {
if (target === 'env') {
stdioEnvArray.append({ key: '', value: '' });
} else {
sseHeadersArray.append({ key: '', value: '' });
}
};
const handleSubmit = async (_data: FormData) => {
try {
setSubmitError(null);
// Simulate request
await new Promise((r) => setTimeout(r, 400));
navigate('/settings/mcp-servers');
} catch {
setSubmitError('Failed to save MCP server');
}
};
const handleDelete = () => {
if (isNew) {
return;
}
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
try {
// Simulate delete
await new Promise((r) => setTimeout(r, 300));
navigate('/settings/mcp-servers');
} catch {
setSubmitError('Failed to delete MCP server');
}
};
const handleTest = async () => {
setTestMessage(null);
setTestError(null);
// Validate minimal required fields based on transport
const valid = await form.trigger();
if (!valid) {
setTestError('Please fix validation errors before testing');
return;
}
try {
setIsTestLoading(true);
// Simulate connectivity test
await new Promise((r) => setTimeout(r, 600));
setTestMessage('Connection successful');
} catch {
setTestError('Connection failed');
} finally {
setIsTestLoading(false);
}
};
const handleTestTool = async (index: number) => {
setToolTestIndex(index);
setToolTestLoadingIndex(index);
setToolTestMessage(null);
setToolTestError(null);
try {
// Basic validation: tool must have a name
const toolName = form.getValues(`tools.${index}.name` as const) as string | undefined;
if (!toolName) {
setToolTestError('Tool name is required');
return;
}
// Simulate tool invocation
await new Promise((r) => setTimeout(r, 600));
setToolTestMessage('Tool test passed');
} catch {
setToolTestError('Tool test failed');
} finally {
setToolTestLoadingIndex(null);
}
};
if (!isNew && !getMockServerById(Number(params.mcpServerId))) {
return (
<StatusCard
action={
<Button
onClick={() => navigate('/settings/mcp-servers')}
variant="secondary"
>
Back to list
</Button>
}
description="The requested MCP server could not be located in mock data"
icon={<Server className="text-muted-foreground size-8" />}
title="MCP Server not found"
/>
);
}
return (
<Fragment>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Server className="text-muted-foreground size-5" />
{isNew ? 'New MCP Server' : 'MCP Server Settings'}
</h2>
<div className="text-muted-foreground">
{isNew ? 'Configure a new MCP server' : 'Update MCP server settings'}
</div>
</div>
<Form {...form}>
<form
className="flex flex-col gap-6"
id="mcp-server-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
{(submitError || testMessage || testError) && (
<Alert variant="destructive">
{submitError && (
<>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{submitError}</AlertDescription>
</>
)}
{testError && (
<>
<AlertTitle>Test Failed</AlertTitle>
<AlertDescription>{testError}</AlertDescription>
</>
)}
{testMessage && !submitError && !testError && (
<>
<AlertTitle>Test Passed</AlertTitle>
<AlertDescription>{testMessage}</AlertDescription>
</>
)}
</Alert>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Enter server name"
/>
</FormControl>
<FormDescription>A unique name for this MCP server</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="transport"
render={({ field }) => (
<FormItem>
<FormLabel>Transport</FormLabel>
<Select
defaultValue={field.value}
onValueChange={(v: McpTransport) => {
field.onChange(v);
// Normalize opposite config to avoid stale values
if (v === 'stdio') {
form.setValue('sse', undefined);
if (!form.getValues('stdio')) {
form.setValue('stdio', { args: '', command: '', env: [] });
}
} else {
form.setValue('stdio', undefined);
if (!form.getValues('sse')) {
form.setValue('sse', { headers: [], url: '' });
}
}
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select transport" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stdio">STDIO</SelectItem>
<SelectItem value="sse">SSE</SelectItem>
</SelectContent>
</Select>
<FormDescription>STDIO for local process; SSE for remote URL</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* STDIO configuration */}
{transport === 'stdio' && (
<div className="flex flex-col gap-4">
<h3 className="text-lg font-medium">STDIO Configuration</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="stdio.command"
render={({ field }) => (
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/usr/local/bin/node"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stdio.args"
render={({ field }) => (
<FormItem>
<FormLabel>Args</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/script.js --flag value"
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>Space-separated arguments</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-sm font-medium">Environment Variables</h4>
<Button
onClick={() => handleAddKeyValue('env')}
size="sm"
type="button"
variant="outline"
>
<Plus className="size-3" /> Add
</Button>
</div>
<div className="flex flex-col gap-2">
{stdioEnvArray.fields.length === 0 && (
<div className="text-muted-foreground text-sm">No variables</div>
)}
{stdioEnvArray.fields.map((field, index) => (
<div
className="grid grid-cols-1 gap-2 md:grid-cols-5"
key={field.id}
>
<Controller
control={form.control}
name={`stdio.env.${index}.key` as const}
render={({ field }) => (
<Input
{...field}
className="md:col-span-2"
placeholder="KEY"
/>
)}
/>
<Controller
control={form.control}
name={`stdio.env.${index}.value` as const}
render={({ field }) => (
<Input
{...field}
className="md:col-span-2"
placeholder="VALUE"
/>
)}
/>
<Button
className="justify-self-start"
onClick={() => stdioEnvArray.remove(index)}
type="button"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)}
{/* SSE configuration */}
{transport === 'sse' && (
<div className="flex flex-col gap-4">
<h3 className="text-lg font-medium">SSE Configuration</h3>
<FormField
control={form.control}
name="sse.url"
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://mcp.example.com/sse"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-sm font-medium">Headers</h4>
<Button
onClick={() => handleAddKeyValue('headers')}
size="sm"
type="button"
variant="outline"
>
<Plus className="size-3" /> Add
</Button>
</div>
<div className="flex flex-col gap-2">
{sseHeadersArray.fields.length === 0 && (
<div className="text-muted-foreground text-sm">No headers</div>
)}
{sseHeadersArray.fields.map((field, index) => (
<div
className="grid grid-cols-1 gap-2 md:grid-cols-5"
key={field.id}
>
<Controller
control={form.control}
name={`sse.headers.${index}.key` as const}
render={({ field }) => (
<Input
{...field}
className="md:col-span-2"
placeholder="Header"
/>
)}
/>
<Controller
control={form.control}
name={`sse.headers.${index}.value` as const}
render={({ field }) => (
<Input
{...field}
className="md:col-span-2"
placeholder="Value"
/>
)}
/>
<Button
className="justify-self-start"
onClick={() => sseHeadersArray.remove(index)}
type="button"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)}
{/* Tools configuration - only for existing servers; toggles only */}
{!isNew && (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-lg font-medium">Tools</h3>
<p className="text-muted-foreground text-sm">Enable or disable available tools</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{toolsArray.fields.length === 0 && (
<div className="text-muted-foreground text-sm">No tools</div>
)}
{toolsArray.fields.map((tool, index) => (
<div
className="flex flex-col gap-2 rounded-md border p-2"
key={tool.id}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 text-sm">
<div className="truncate font-medium">
{form.watch(`tools.${index}.name`) || 'tool'}
</div>
{form.watch(`tools.${index}.description`) && (
<div className="text-muted-foreground">
{form.watch(`tools.${index}.description`) as string}
</div>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Enabled</span>
<Controller
control={form.control}
name={`tools.${index}.enabled` as const}
render={({ field }) => (
<Switch
aria-label={`Toggle ${form.getValues(`tools.${index}.name`) || 'tool'}`}
checked={!!field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<Button
disabled={toolTestLoadingIndex === index}
onClick={() => handleTestTool(index)}
size="sm"
type="button"
variant="outline"
>
{toolTestLoadingIndex === index ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
{toolTestLoadingIndex === index ? 'Testing...' : 'Test'}
</Button>
</div>
</div>
{toolTestIndex === index && (toolTestMessage || toolTestError) && (
<div className="mt-1 text-xs">
{toolTestMessage && (
<span className="text-green-600">{toolTestMessage}</span>
)}
{toolTestError && (
<span className="text-red-600">{toolTestError}</span>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</form>
</Form>
</div>
{/* Sticky buttons */}
<div className="bg-background sticky -bottom-4 -mx-4 mt-4 -mb-4 flex items-center border-t p-4 shadow-lg">
<div className="flex gap-2">
{!isNew && (
<Button
onClick={handleDelete}
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</Button>
)}
<Button
disabled={isTestLoading}
onClick={handleTest}
type="button"
variant="outline"
>
{isTestLoading ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
{isTestLoading ? 'Testing...' : 'Test'}
</Button>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={() => navigate('/settings/mcp-servers')}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={form.formState.isSubmitting}
form="mcp-server-form"
type="submit"
variant="secondary"
>
{form.formState.isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
{form.formState.isSubmitting ? 'Saving...' : isNew ? 'Create MCP Server' : 'Update MCP Server'}
</Button>
</div>
</div>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={handleConfirmDelete}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={form.watch('name')}
itemType="MCP server"
/>
</Fragment>
);
};
export default SettingsMcpServer;
@@ -1,670 +0,0 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format, isToday } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { AlertCircle, ArrowDown, ArrowUp, Copy, Ellipsis, Loader2, Pencil, Plus, Server, Trash } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { StatusCard } from '@/components/ui/status-card';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface McpServerConfigSse {
headers?: Record<string, string>;
url: string;
}
interface McpServerConfigStdio {
args?: string[];
command: string;
env?: Record<string, string>;
}
interface McpServerItem {
config: {
sse?: McpServerConfigSse | null;
stdio?: McpServerConfigStdio | null;
};
createdAt: string; // ISO
id: number;
name: string;
tools: McpTool[];
transport: McpTransport;
updatedAt: string; // ISO
}
interface McpTool {
description?: string;
enabled?: boolean;
name: string;
}
type McpTransport = 'sse' | 'stdio';
const SettingsMcpServersHeader = () => {
const navigate = useNavigate();
const handleCreate = () => {
navigate('/settings/mcp-servers/new');
};
return (
<div className="flex items-center justify-between">
<p className="text-muted-foreground">Manage MCP servers available to the assistant</p>
<Button
onClick={handleCreate}
variant="secondary"
>
Create MCP Server
<Plus className="size-4" />
</Button>
</div>
);
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
if (isToday(date)) {
return format(date, 'HH:mm:ss', { locale: enUS });
}
return format(date, 'd MMM yyyy', { locale: enUS });
};
const formatFullDateTime = (dateString: string) => {
const date = new Date(dateString);
return format(date, 'd MMM yyyy, HH:mm:ss', { locale: enUS });
};
const SettingsMcpServers = () => {
const navigate = useNavigate();
// Mocked data stored locally. This can be replaced by a real query later.
const initialData: McpServerItem[] = useMemo(
() => [
{
config: {
sse: null,
stdio: {
args: ['/opt/mcp/filesystem/index.js', '--root', '/Users/sirozha/Projects'],
command: '/usr/local/bin/node',
env: { NODE_ENV: 'production' },
},
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(),
id: 1,
name: 'Local Filesystem',
tools: [
{ description: 'Read a file from disk', enabled: true, name: 'readFile' },
{ description: 'Write content to a file', enabled: false, name: 'writeFile' },
{ description: 'List files in a directory', enabled: true, name: 'listDirectory' },
],
transport: 'stdio',
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 1).toISOString(),
},
{
config: {
sse: {
headers: { Authorization: 'Bearer ***' },
url: 'https://mcp.example.com/slack/sse',
},
stdio: null,
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 20).toISOString(),
id: 2,
name: 'Slack (Prod)',
tools: [
{ description: 'Send a message to a channel', enabled: true, name: 'postMessage' },
{ description: 'Get a list of channels', enabled: true, name: 'listChannels' },
{ description: 'Fetch Slack user info', enabled: false, name: 'getUserInfo' },
],
transport: 'sse',
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(),
},
{
config: {
sse: {
headers: { Authorization: 'Bearer ***' },
url: 'https://mcp.example.com/github/sse',
},
stdio: null,
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(),
id: 3,
name: 'GitHub Issues',
tools: [
{ description: 'Create a new issue', enabled: true, name: 'createIssue' },
{ description: 'Search issues by query', enabled: true, name: 'searchIssues' },
{ description: 'Add a comment to an issue', enabled: true, name: 'addComment' },
],
transport: 'sse',
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
},
],
[],
);
const [servers, setServers] = useState<McpServerItem[]>(initialData);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingServer, setDeletingServer] = useState<McpServerItem | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<null | string>(null);
// Three-way sorting handler: null -> asc -> desc -> null
const handleColumnSort = (column: {
clearSorting: () => void;
getIsSorted: () => 'asc' | 'desc' | false;
toggleSorting: (desc?: boolean) => void;
}) => {
const sorted = column.getIsSorted();
if (sorted === 'asc') {
column.toggleSorting(true);
} else if (sorted === 'desc') {
column.clearSorting();
} else {
column.toggleSorting(false);
}
};
const handleEdit = (serverId: number) => {
navigate(`/settings/mcp-servers/${serverId}`);
};
const handleClone = (serverId: number) => {
setServers((prev) => {
const source = prev.find((s) => s.id === serverId);
if (!source) {
return prev;
}
const nextId = (prev.reduce((max, s) => Math.max(max, s.id), 0) || 0) + 1;
const nowIso = new Date().toISOString();
const clone: McpServerItem = {
...source,
config: JSON.parse(JSON.stringify(source.config)),
createdAt: nowIso,
id: nextId,
name: `${source.name} (Copy)`,
tools: JSON.parse(JSON.stringify(source.tools || [])),
updatedAt: nowIso,
};
return [clone, ...prev];
});
};
const handleOpenDeleteDialog = (server: McpServerItem) => {
setDeletingServer(server);
setIsDeleteDialogOpen(true);
};
const handleDelete = async (serverId?: number) => {
if (!serverId) {
return;
}
try {
setIsDeleteLoading(true);
setDeleteErrorMessage(null);
// Simulate async delete
await new Promise((r) => setTimeout(r, 400));
setServers((prev) => prev.filter((s) => s.id !== serverId));
setDeletingServer(null);
setIsDeleteDialogOpen(false);
} catch {
setDeleteErrorMessage('Failed to delete MCP server');
} finally {
setIsDeleteLoading(false);
}
};
const columns: ColumnDef<McpServerItem>[] = [
{
accessorKey: 'name',
cell: ({ row }) => (
<div className="flex items-center gap-2 font-medium">{row.getValue('name') as string}</div>
),
enableHiding: false,
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Name
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
size: 300,
},
{
accessorKey: 'transport',
cell: ({ row }) => {
const t = row.getValue('transport') as McpTransport;
return <Badge variant="outline">{t.toUpperCase()}</Badge>;
},
header: 'Transport',
size: 120,
},
{
cell: ({ row }) => {
const s = row.original as McpServerItem;
const total = (s.tools || []).length;
if (total === 0) {
return <span className="text-muted-foreground text-sm"></span>;
}
const enabled = (s.tools || []).filter((t) => t.enabled !== false);
const first = enabled.slice(0, 3);
const rest = enabled.length - first.length;
const disabledCount = total - enabled.length;
return (
<div className="flex w-full flex-wrap items-center gap-1 overflow-hidden">
{first.map((t) => (
<Badge
className="text-[10px]"
key={t.name}
variant="secondary"
>
{t.name}
</Badge>
))}
{rest > 0 && (
<Badge
className="text-[10px]"
variant="outline"
>
+{rest}
</Badge>
)}
{disabledCount > 0 && (
<Badge
className="ml-1 text-[10px]"
variant="outline"
>
{disabledCount} disabled
</Badge>
)}
</div>
);
},
header: 'Tools',
id: 'tools',
size: 220,
},
{
cell: ({ row }) => {
const s = row.original as McpServerItem;
if (s.transport === 'sse' && s.config.sse) {
return <span className="text-muted-foreground text-sm break-all">{s.config.sse.url}</span>;
}
if (s.transport === 'stdio' && s.config.stdio) {
const args = s.config.stdio.args?.join(' ') || '';
return (
<span className="text-muted-foreground text-sm break-all">
{s.config.stdio.command} {args}
</span>
);
}
return <span className="text-muted-foreground text-sm"></span>;
},
header: 'Endpoint',
id: 'endpoint',
size: 320,
},
{
accessorKey: 'createdAt',
cell: ({ row }) => {
const dateString = row.getValue('createdAt') as string;
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-default text-sm">{formatDateTime(dateString)}</div>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">{formatFullDateTime(dateString)}</div>
</TooltipContent>
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Created
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
size: 120,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.getValue('createdAt') as string);
const dateB = new Date(rowB.getValue('createdAt') as string);
return dateA.getTime() - dateB.getTime();
},
},
{
accessorKey: 'updatedAt',
cell: ({ row }) => {
const dateString = row.getValue('updatedAt') as string;
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-default text-sm">{formatDateTime(dateString)}</div>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">{formatFullDateTime(dateString)}</div>
</TooltipContent>
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Updated
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
size: 120,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.getValue('updatedAt') as string);
const dateB = new Date(rowB.getValue('updatedAt') as string);
return dateA.getTime() - dateB.getTime();
},
},
{
cell: ({ row }) => {
const server = row.original as McpServerItem;
return (
<div className="flex justify-end opacity-0 transition-opacity group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="size-8 p-0"
onClick={(e) => e.stopPropagation()}
variant="ghost"
>
<span className="sr-only">Open menu</span>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={() => handleEdit(server.id)}>
<Pencil className="size-3" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleClone(server.id)}>
<Copy className="size-3" />
Clone
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isDeleteLoading && deletingServer?.id === server.id}
onClick={() => handleOpenDeleteDialog(server)}
>
{isDeleteLoading && deletingServer?.id === server.id ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash className="size-4" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
enableHiding: false,
header: () => null,
id: 'actions',
meta: { preventRowClick: true },
size: 48,
},
];
const renderSubComponent = ({ row }: { row: any }) => {
const server = row.original as McpServerItem;
const renderKeyValue = (obj?: Record<string, string>) => {
if (!obj || Object.keys(obj).length === 0) {
return <div className="text-muted-foreground text-sm">No data</div>;
}
return (
<div className="flex flex-col gap-1 text-sm">
{Object.entries(obj)
.filter(([_, v]) => !!v)
.map(([k, v]) => (
<div key={k}>
<span className="text-muted-foreground">{k}:</span> {v}
</div>
))}
</div>
);
};
return (
<div className="bg-muted/20 flex flex-col gap-4 border-t p-4">
<h4 className="font-medium">Configuration</h4>
<hr className="border-muted-foreground/20" />
{server.transport === 'stdio' && server.config.stdio && (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium">STDIO</div>
<div className="flex flex-col gap-1 text-sm">
<div>
<span className="text-muted-foreground">Command:</span> {server.config.stdio.command}
</div>
{!!server.config.stdio.args?.length && (
<div>
<span className="text-muted-foreground">Args:</span>{' '}
{server.config.stdio.args.join(' ')}
</div>
)}
</div>
<div>
<div className="text-sm font-medium">Env</div>
{renderKeyValue(server.config.stdio.env)}
</div>
</div>
)}
{server.transport === 'sse' && server.config.sse && (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium">SSE</div>
<div className="flex flex-col gap-1 text-sm">
<div>
<span className="text-muted-foreground">URL:</span> {server.config.sse.url}
</div>
</div>
<div>
<div className="text-sm font-medium">Headers</div>
{renderKeyValue(server.config.sse.headers)}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<div className="text-sm font-medium">Tools</div>
{server.tools?.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{server.tools.map((t, idx) => (
<div
className="flex items-start justify-between gap-4 rounded-md border p-2"
key={`${t.name}-${idx}`}
>
<div className="text-sm">
<div className="font-medium">{t.name}</div>
{t.description && <div className="text-muted-foreground">{t.description}</div>}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Enabled</span>
<Switch
aria-label={`Toggle ${t.name}`}
checked={t.enabled !== false}
onCheckedChange={(checked) => {
setServers((prev) =>
prev.map((s) =>
s.id === server.id
? {
...s,
tools: s.tools.map((orig, i) =>
i === idx ? { ...orig, enabled: checked } : orig,
),
}
: s,
),
);
}}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground text-sm">No tools available</div>
)}
</div>
</div>
);
};
const renderRowContextMenu = (server: McpServerItem) => (
<>
<ContextMenuItem onClick={() => handleEdit(server.id)}>
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => handleClone(server.id)}>
<Copy />
Clone
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isDeleteLoading && deletingServer?.id === server.id}
onClick={() => handleOpenDeleteDialog(server)}
>
<Trash />
{isDeleteLoading && deletingServer?.id === server.id ? 'Deleting...' : 'Delete'}
</ContextMenuItem>
</>
);
if (servers.length === 0) {
return (
<div className="flex flex-col gap-4">
<SettingsMcpServersHeader />
<StatusCard
action={
<Button
onClick={() => navigate('/settings/mcp-servers/new')}
variant="secondary"
>
<Plus className="size-4" />
Add MCP Server
</Button>
}
description="Get started by adding your first MCP server"
icon={<Server className="text-muted-foreground size-8" />}
title="No MCP servers configured"
/>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<SettingsMcpServersHeader />
{deleteErrorMessage && (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Error deleting MCP server</AlertTitle>
<AlertDescription>{deleteErrorMessage}</AlertDescription>
</Alert>
)}
<DataTable<McpServerItem>
columns={columns}
data={servers}
renderRowContextMenu={renderRowContextMenu}
renderSubComponent={renderSubComponent}
/>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={() => handleDelete(deletingServer?.id)}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={deletingServer?.name}
itemType="MCP server"
/>
</div>
);
};
export default SettingsMcpServers;
+2 -6
View File
@@ -25,12 +25,8 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@env": ["./env.ts"],
"@pkg": ["./package.json"],
"@/ui/*": ["./src/components/ui/*"]
"@/*": ["./src/*"]
}
},
"include": ["src", "./env.ts", "types/**/*.d.ts"]
"include": ["src", "types/**/*.d.ts"]
}