mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-26 13:06:32 +00:00
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:
co-authored by
Claude Opus 4.7
parent
692d91cffa
commit
abe45e33c9
@@ -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": {
|
||||
|
||||
Generated
-38
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user