mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-22 13:34:24 +00:00
feat(mcp): simplify external MCP server config to local/remote modes
Replace the three-way transport choice (stdio / sse / httpstream) for connecting LangBot to external MCP servers with two modes: local (stdio) and remote. Remote servers only require a URL; the runtime auto-detects the transport (tries Streamable HTTP, falls back to SSE). - provider/tools/loaders/mcp.py: add _init_remote_server() with Streamable-HTTP-then-SSE probing; dispatch 'remote' lifecycle, keep legacy sse/http branches for back-compat - plugin/connector.py: normalize legacy http/sse marketplace modes to 'remote' on Space install, preserving connection params - entity/persistence/mcp.py: document mode as stdio, remote (legacy: sse, http) - alembic 0006: idempotent data migration mapping existing sse/http rows to remote (downgrade maps back to http) - api/http/service/mcp.py: stash runtime_info (status + tool list) into test task metadata before tearing down the temp session - web: collapse mode dropdown to local/remote, remote renders URL+timeout only, edit auto-maps legacy sse/http to remote; show tools after test in create mode from task metadata; remove dead plugins/mcp-server/ tree - i18n: local/remote labels + mode/url hints across 8 locales
This commit is contained in:
@@ -47,8 +47,7 @@ import {
|
||||
MCPTool,
|
||||
MCPServer,
|
||||
MCPSessionStatus,
|
||||
MCPServerExtraArgsSSE,
|
||||
MCPServerExtraArgsHttp,
|
||||
MCPServerExtraArgsRemote,
|
||||
MCPServerExtraArgsStdio,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
@@ -246,17 +245,18 @@ function ToolsList({ tools, t }: { tools: MCPTool[]; t: TFunction }) {
|
||||
}
|
||||
|
||||
function RuntimePanel({
|
||||
isEditMode,
|
||||
mcpTesting,
|
||||
runtimeInfo,
|
||||
t,
|
||||
}: {
|
||||
isEditMode: boolean;
|
||||
mcpTesting: boolean;
|
||||
runtimeInfo: MCPServerRuntimeInfo | null;
|
||||
t: TFunction;
|
||||
}) {
|
||||
if (!isEditMode || !runtimeInfo) {
|
||||
// Show tools whenever we have runtime info — either an edit-mode server or a
|
||||
// create-mode test result captured from the transient session. Only fall back
|
||||
// to the placeholder when there is genuinely nothing to show.
|
||||
if (!runtimeInfo) {
|
||||
return (
|
||||
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||
{t('mcp.noToolsFound')}
|
||||
@@ -293,7 +293,7 @@ const getFormSchema = (t: TFunction) =>
|
||||
name: z
|
||||
.string({ required_error: t('mcp.nameRequired') })
|
||||
.min(1, { message: t('mcp.nameRequired') }),
|
||||
mode: z.enum(['sse', 'stdio', 'http']),
|
||||
mode: z.enum(['stdio', 'remote']),
|
||||
timeout: z
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
@@ -316,7 +316,7 @@ const getFormSchema = (t: TFunction) =>
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.mode === 'sse' || data.mode === 'http') {
|
||||
if (data.mode === 'remote') {
|
||||
if (!data.url || data.url.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -391,7 +391,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
mode: 'remote',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
@@ -465,7 +465,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
mode: 'remote',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
@@ -535,9 +535,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
const resp = await httpClient.getMCPServer(serverName);
|
||||
const server = resp.server ?? resp;
|
||||
|
||||
// Transport selection collapsed to two modes: 'stdio' (local) and
|
||||
// 'remote' (URL, auto-detected transport). Servers persisted under the
|
||||
// legacy 'sse'/'http' modes are surfaced as 'remote' so they remain
|
||||
// editable; saving rewrites them to 'remote'.
|
||||
const isRemote = server.mode !== 'stdio';
|
||||
|
||||
const formValues: FormValues = {
|
||||
name: server.name.replace(/__/g, '/'),
|
||||
mode: server.mode,
|
||||
mode: isRemote ? 'remote' : 'stdio',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
@@ -553,12 +559,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
}[] = [];
|
||||
let newStdioArgs: { value: string }[] = [];
|
||||
|
||||
if (server.mode === 'sse' || server.mode === 'http') {
|
||||
if (isRemote) {
|
||||
formValues.url = server.extra_args.url;
|
||||
formValues.timeout = server.extra_args.timeout;
|
||||
|
||||
if (server.mode === 'sse') {
|
||||
formValues.ssereadtimeout = server.extra_args.ssereadtimeout;
|
||||
if (typeof server.extra_args.timeout === 'number') {
|
||||
formValues.timeout = server.extra_args.timeout;
|
||||
}
|
||||
|
||||
if (server.extra_args.headers) {
|
||||
@@ -571,7 +575,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
);
|
||||
formValues.extra_args = newExtraArgs;
|
||||
}
|
||||
} else if (server.mode === 'stdio') {
|
||||
} else {
|
||||
formValues.command = server.extra_args.command;
|
||||
newStdioArgs = (server.extra_args.args || []).map((arg: string) => ({
|
||||
value: arg,
|
||||
@@ -611,36 +615,22 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
try {
|
||||
let serverConfig: MCPServer;
|
||||
|
||||
if (value.mode === 'sse' || value.mode === 'http') {
|
||||
if (value.mode === 'remote') {
|
||||
const headers: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
headers[arg.key] = String(arg.value);
|
||||
});
|
||||
|
||||
if (value.mode === 'sse') {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'sse',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers,
|
||||
timeout: value.timeout,
|
||||
ssereadtimeout: value.ssereadtimeout,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'http',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers,
|
||||
timeout: value.timeout,
|
||||
},
|
||||
};
|
||||
}
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'remote',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers,
|
||||
timeout: value.timeout,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const env: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
@@ -694,21 +684,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
// are always current.
|
||||
const formExtraArgs = form.getValues('extra_args') ?? [];
|
||||
const formStdioArgs = form.getValues('args') ?? [];
|
||||
let extraArgsData:
|
||||
| MCPServerExtraArgsSSE
|
||||
| MCPServerExtraArgsHttp
|
||||
| MCPServerExtraArgsStdio;
|
||||
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
||||
|
||||
if (mode === 'sse') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
headers: Object.fromEntries(
|
||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
ssereadtimeout: form.getValues('ssereadtimeout'),
|
||||
};
|
||||
} else if (mode === 'http') {
|
||||
if (mode === 'remote') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
@@ -758,6 +736,17 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
} else {
|
||||
if (isEditMode) {
|
||||
await loadServerForEdit(form.getValues('name'));
|
||||
} else {
|
||||
// Create mode has no persisted server to reload tools from.
|
||||
// The backend stashes the discovered runtime info (status +
|
||||
// tools) in the test task's metadata before tearing the
|
||||
// transient session down — surface it so a successful test
|
||||
// shows the tool list instead of "no tools found".
|
||||
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||
if (runtimeInfoFromTest) {
|
||||
setRuntimeInfo(runtimeInfoFromTest);
|
||||
}
|
||||
}
|
||||
toast.success(t('mcp.testSuccess'));
|
||||
}
|
||||
@@ -871,18 +860,22 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
||||
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
|
||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
||||
{t('mcp.stdio')}
|
||||
{t('mcp.local')}
|
||||
{!boxAvailable && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.boxRequired')})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{watchMode === 'stdio'
|
||||
? t('mcp.localModeDescription')
|
||||
: t('mcp.remoteModeDescription')}
|
||||
</FormDescription>
|
||||
{stdioBlockedByBox && (
|
||||
<BoxUnavailableNotice
|
||||
hint={boxHint}
|
||||
@@ -895,7 +888,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
)}
|
||||
/>
|
||||
|
||||
{(watchMode === 'sse' || watchMode === 'http') && (
|
||||
{watchMode === 'remote' && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -907,8 +900,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t('mcp.remoteUrlPlaceholder')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('mcp.remoteUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -932,27 +931,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchMode === 'sse' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssereadtimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.sseTimeoutDescription')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1006,9 +984,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.headers')
|
||||
: t('mcp.env')}
|
||||
{watchMode === 'remote' ? t('mcp.headers') : t('mcp.env')}
|
||||
</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{extraArgs.map((arg, index) => (
|
||||
@@ -1037,9 +1013,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addExtraArg}>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.addHeader')
|
||||
: t('mcp.addEnvVar')}
|
||||
{watchMode === 'remote' ? t('mcp.addHeader') : t('mcp.addEnvVar')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
@@ -1052,12 +1026,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
);
|
||||
|
||||
const runtimePanel = (
|
||||
<RuntimePanel
|
||||
isEditMode={isEditMode}
|
||||
mcpTesting={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
t={t}
|
||||
/>
|
||||
<RuntimePanel mcpTesting={mcpTesting} runtimeInfo={runtimeInfo} t={t} />
|
||||
);
|
||||
|
||||
// In edit mode the right side shows a tablist switching between the live
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface IExtensionCardVO {
|
||||
hasUpdate?: boolean;
|
||||
runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
|
||||
tools?: number;
|
||||
mode?: 'stdio' | 'sse' | 'http';
|
||||
mode?: 'stdio' | 'sse' | 'http' | 'remote';
|
||||
}
|
||||
|
||||
export class ExtensionCardVO implements IExtensionCardVO {
|
||||
@@ -37,7 +37,7 @@ export class ExtensionCardVO implements IExtensionCardVO {
|
||||
hasUpdate?: boolean;
|
||||
runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
|
||||
tools?: number;
|
||||
mode?: 'stdio' | 'sse' | 'http';
|
||||
mode?: 'stdio' | 'sse' | 'http' | 'remote';
|
||||
|
||||
constructor(prop: IExtensionCardVO) {
|
||||
this.id = prop.id;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
export class MCPCardVO {
|
||||
name: string;
|
||||
mode: 'stdio' | 'sse' | 'http';
|
||||
enable: boolean;
|
||||
status: MCPSessionStatus;
|
||||
tools: number;
|
||||
error?: string;
|
||||
|
||||
constructor(data: MCPServer) {
|
||||
this.name = data.name;
|
||||
this.mode = data.mode;
|
||||
this.enable = data.enable;
|
||||
|
||||
// Determine status from runtime_info
|
||||
if (!data.runtime_info) {
|
||||
this.status = MCPSessionStatus.ERROR;
|
||||
this.tools = 0;
|
||||
} else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) {
|
||||
this.status = data.runtime_info.status;
|
||||
this.tools = data.runtime_info.tool_count || 0;
|
||||
} else {
|
||||
this.status = data.runtime_info.status;
|
||||
this.tools = 0;
|
||||
this.error = data.runtime_info.error_message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent';
|
||||
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
import { Hexagon } from 'lucide-react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
export default function MCPComponent({
|
||||
onEditServer,
|
||||
}: {
|
||||
askInstallServer?: (githubURL: string) => void;
|
||||
onEditServer?: (serverName: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstalledServers();
|
||||
|
||||
return () => {
|
||||
// Cleanup: clear polling interval when component unmounts
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check if any enabled server is connecting and start/stop polling accordingly
|
||||
useEffect(() => {
|
||||
const hasConnecting = installedServers.some(
|
||||
(server) =>
|
||||
server.enable && server.status === MCPSessionStatus.CONNECTING,
|
||||
);
|
||||
|
||||
if (hasConnecting && !pollingIntervalRef.current) {
|
||||
// Start polling every 3 seconds
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
fetchInstalledServers();
|
||||
}, 3000);
|
||||
} else if (!hasConnecting && pollingIntervalRef.current) {
|
||||
// Stop polling when no enabled server is connecting
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [installedServers]);
|
||||
|
||||
function fetchInstalledServers() {
|
||||
setLoading(true);
|
||||
httpClient
|
||||
.getMCPServers()
|
||||
.then((resp) => {
|
||||
const servers = resp.servers.map((server) => new MCPCardVO(server));
|
||||
setInstalledServers(servers);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch MCP servers:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{/* Server list */}
|
||||
<div className="w-full h-full px-[0.8rem] pt-[0rem]">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
|
||||
{t('mcp.loading')}
|
||||
</div>
|
||||
) : installedServers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
|
||||
<Hexagon className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem] pb-6">
|
||||
{installedServers.map((server, index) => (
|
||||
<div key={`${server.name}-${index}`}>
|
||||
<MCPCardComponent
|
||||
cardVO={server}
|
||||
onCardClick={() => {
|
||||
if (onEditServer) {
|
||||
onEditServer(server.name);
|
||||
}
|
||||
}}
|
||||
onRefresh={fetchInstalledServers}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RefreshCcw,
|
||||
Wrench,
|
||||
Ban,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Link,
|
||||
} from 'lucide-react';
|
||||
import { MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
export default function MCPCardComponent({
|
||||
cardVO,
|
||||
onCardClick,
|
||||
onRefresh,
|
||||
}: {
|
||||
cardVO: MCPCardVO;
|
||||
onCardClick: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(cardVO.enable);
|
||||
const [switchEnable, setSwitchEnable] = useState(true);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [toolsCount, setToolsCount] = useState(cardVO.tools);
|
||||
const [status, setStatus] = useState(cardVO.status);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(cardVO.status);
|
||||
setToolsCount(cardVO.tools);
|
||||
setEnabled(cardVO.enable);
|
||||
}, [cardVO.status, cardVO.tools, cardVO.enable]);
|
||||
|
||||
function handleEnable(checked: boolean) {
|
||||
setSwitchEnable(false);
|
||||
httpClient
|
||||
.toggleMCPServer(cardVO.name, checked)
|
||||
.then(() => {
|
||||
setEnabled(checked);
|
||||
toast.success(t('mcp.saveSuccess'));
|
||||
onRefresh();
|
||||
setSwitchEnable(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('mcp.modifyFailed') + err.msg);
|
||||
setSwitchEnable(true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleTest(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setTesting(true);
|
||||
|
||||
httpClient
|
||||
.testMCPServer(cardVO.name, {})
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((taskResp) => {
|
||||
if (taskResp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
setTesting(false);
|
||||
|
||||
if (taskResp.runtime.exception) {
|
||||
toast.error(
|
||||
t('mcp.refreshFailed') + taskResp.runtime.exception,
|
||||
);
|
||||
} else {
|
||||
toast.success(t('mcp.refreshSuccess'));
|
||||
}
|
||||
|
||||
// Refresh to get updated runtime_info
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('mcp.refreshFailed') + err.msg);
|
||||
setTesting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-[1.2rem] cursor-pointer transition-all duration-200 hover:border-[#a1a1aa] dark:hover:border-[#3f3f46]"
|
||||
onClick={onCardClick}
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
<Link
|
||||
className="w-16 h-16 flex-shrink-0"
|
||||
style={{ color: 'rgba(70,146,221,1)' }}
|
||||
/>
|
||||
|
||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start gap-[0.3rem]">
|
||||
<div className="flex flex-row items-center gap-[0.5rem]">
|
||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
|
||||
{cardVO.name}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-[0.65rem] px-1.5 py-0">
|
||||
{cardVO.mode.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
||||
{!enabled ? (
|
||||
// 未启用 - 橙色
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<Ban className="w-4 h-4 text-orange-500 dark:text-orange-400" />
|
||||
<div className="text-sm text-orange-500 dark:text-orange-400 font-medium">
|
||||
{t('mcp.statusDisabled')}
|
||||
</div>
|
||||
</div>
|
||||
) : status === MCPSessionStatus.CONNECTED ? (
|
||||
// 连接成功 - 显示工具数量
|
||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||
<Wrench className="w-5 h-5" />
|
||||
<div className="text-base text-black dark:text-[#f0f0f0] font-medium">
|
||||
{t('mcp.toolCount', { count: toolsCount })}
|
||||
</div>
|
||||
</div>
|
||||
) : status === MCPSessionStatus.ERROR ? (
|
||||
// 连接失败 - 红色(仅在明确报错时)
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||||
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
|
||||
{t('mcp.connectionFailedStatus')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 连接中 - 蓝色加载(CONNECTING 或初始/未知状态,避免误报失败)
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
|
||||
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
|
||||
{t('mcp.connecting')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-between h-full">
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Switch
|
||||
className="cursor-pointer"
|
||||
checked={enabled}
|
||||
onCheckedChange={handleEnable}
|
||||
disabled={!switchEnable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-[0.4rem]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-8 w-8"
|
||||
onClick={(e) => handleTest(e)}
|
||||
disabled={testing}
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
interface MCPDeleteConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
serverName: string | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function MCPDeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
serverName,
|
||||
onSuccess,
|
||||
}: MCPDeleteConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function handleDelete() {
|
||||
if (!serverName) return;
|
||||
|
||||
try {
|
||||
await httpClient.deleteMCPServer(serverName);
|
||||
toast.success(t('mcp.deleteSuccess'));
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete server:', error);
|
||||
toast.error(t('mcp.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>{t('mcp.confirmDeleteServer')}</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,907 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, XCircle, Trash2 } from 'lucide-react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
MCPServerRuntimeInfo,
|
||||
MCPTool,
|
||||
MCPServer,
|
||||
MCPSessionStatus,
|
||||
MCPServerExtraArgsSSE,
|
||||
MCPServerExtraArgsHttp,
|
||||
MCPServerExtraArgsStdio,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
|
||||
// Status Display Component - 在测试中、连接中或连接失败时使用
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
runtimeInfo,
|
||||
t,
|
||||
}: {
|
||||
testing: boolean;
|
||||
runtimeInfo: MCPServerRuntimeInfo;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (testing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="font-medium">{t('mcp.testing')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 连接中
|
||||
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="font-medium">{t('mcp.connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stdio MCP refused because Box is disabled / unreachable. The backend
|
||||
// marks the phase so we can show a localized, actionable message instead
|
||||
// of the raw "box_disabled_in_config" / "box_unavailable" marker.
|
||||
if (runtimeInfo.error_phase === 'box_unavailable') {
|
||||
const isDisabledByConfig =
|
||||
runtimeInfo.error_message === 'box_disabled_in_config';
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
</div>
|
||||
<div className="text-sm text-red-500 pl-7 space-y-0.5">
|
||||
<div>
|
||||
{isDisabledByConfig
|
||||
? t('mcp.boxDisabledStdioRefused')
|
||||
: t('mcp.boxUnavailableStdioRefused')}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('mcp.boxStdioRefusedSuggestion')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 连接失败
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
</div>
|
||||
{runtimeInfo.error_message && (
|
||||
<div className="text-sm text-red-500 pl-7">
|
||||
{runtimeInfo.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tools List Component
|
||||
function ToolsList({ tools }: { tools: MCPTool[] }) {
|
||||
return (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{tools.map((tool, index) => (
|
||||
<Card key={index} className="py-3 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{tool.name}</CardTitle>
|
||||
{tool.description && (
|
||||
<CardDescription className="text-xs">
|
||||
{tool.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
name: z
|
||||
.string({ required_error: t('mcp.nameRequired') })
|
||||
.min(1, { message: t('mcp.nameRequired') }),
|
||||
mode: z.enum(['sse', 'stdio', 'http']),
|
||||
timeout: z
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(30),
|
||||
ssereadtimeout: z
|
||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(300),
|
||||
url: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.object({ value: z.string() })).optional(),
|
||||
extra_args: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean']),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.mode === 'sse' || data.mode === 'http') {
|
||||
if (!data.url || data.url.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('mcp.urlRequired'),
|
||||
path: ['url'],
|
||||
});
|
||||
}
|
||||
} else if (data.mode === 'stdio') {
|
||||
if (!data.command || data.command.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('mcp.commandRequired'),
|
||||
path: ['command'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||
timeout: number;
|
||||
ssereadtimeout: number;
|
||||
};
|
||||
|
||||
interface MCPFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
serverName?: string | null;
|
||||
isEditMode?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function MCPFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
serverName,
|
||||
isEditMode = false,
|
||||
onSuccess,
|
||||
onDelete,
|
||||
}: MCPFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
},
|
||||
});
|
||||
|
||||
const [extraArgs, setExtraArgs] = useState<
|
||||
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
||||
>([]);
|
||||
const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);
|
||||
const [mcpTesting, setMcpTesting] = useState(false);
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
||||
null,
|
||||
);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const watchMode = form.watch('mode');
|
||||
const {
|
||||
available: boxAvailable,
|
||||
hint: boxHint,
|
||||
reason: boxReason,
|
||||
} = useBoxStatus();
|
||||
// stdio mode requires the Box sandbox at runtime. Block creation here
|
||||
// so users aren't surprised by a connection failure on the detail page.
|
||||
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
|
||||
|
||||
// Load server data when editing
|
||||
useEffect(() => {
|
||||
if (open && isEditMode && serverName) {
|
||||
loadServerForEdit(serverName);
|
||||
} else if (open && !isEditMode) {
|
||||
// Reset form when creating new server
|
||||
form.reset({
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
});
|
||||
setExtraArgs([]);
|
||||
setStdioArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
|
||||
// Cleanup polling interval when dialog closes
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, isEditMode, serverName]);
|
||||
|
||||
// Poll for updates when runtime_info status is CONNECTING
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
!isEditMode ||
|
||||
!serverName ||
|
||||
!runtimeInfo ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTING
|
||||
) {
|
||||
// Stop polling if conditions are not met
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Start polling if not already running
|
||||
if (!pollingIntervalRef.current) {
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
loadServerForEdit(serverName);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, isEditMode, serverName, runtimeInfo?.status]);
|
||||
|
||||
async function loadServerForEdit(serverName: string) {
|
||||
try {
|
||||
const resp = await httpClient.getMCPServer(serverName);
|
||||
const server = resp.server ?? resp;
|
||||
|
||||
form.setValue('name', server.name);
|
||||
form.setValue('mode', server.mode);
|
||||
|
||||
if (server.mode === 'sse' || server.mode === 'http') {
|
||||
form.setValue('url', server.extra_args.url);
|
||||
form.setValue('timeout', server.extra_args.timeout);
|
||||
|
||||
if (server.mode === 'sse') {
|
||||
form.setValue('ssereadtimeout', server.extra_args.ssereadtimeout);
|
||||
}
|
||||
|
||||
if (server.extra_args.headers) {
|
||||
const headers = Object.entries(server.extra_args.headers).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
type: 'string' as const,
|
||||
value: String(value),
|
||||
}),
|
||||
);
|
||||
setExtraArgs(headers);
|
||||
form.setValue('extra_args', headers);
|
||||
}
|
||||
} else if (server.mode === 'stdio') {
|
||||
form.setValue('command', server.extra_args.command);
|
||||
const args = (server.extra_args.args || []).map((arg: string) => ({
|
||||
value: arg,
|
||||
}));
|
||||
setStdioArgs(args);
|
||||
form.setValue('args', args);
|
||||
|
||||
if (server.extra_args.env) {
|
||||
const envs = Object.entries(server.extra_args.env).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
type: 'string' as const,
|
||||
value: String(value),
|
||||
}),
|
||||
);
|
||||
setExtraArgs(envs);
|
||||
form.setValue('extra_args', envs);
|
||||
}
|
||||
}
|
||||
|
||||
if (server.runtime_info) {
|
||||
setRuntimeInfo(server.runtime_info);
|
||||
} else {
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load server:', error);
|
||||
toast.error(t('mcp.loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
// Belt-and-suspenders: Save button is also disabled in this case, but
|
||||
// a programmatic submit (e.g. Enter key) should still be refused.
|
||||
if (value.mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let serverConfig: MCPServer;
|
||||
|
||||
if (value.mode === 'sse' || value.mode === 'http') {
|
||||
const headers: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
headers[arg.key] = String(arg.value);
|
||||
});
|
||||
|
||||
if (value.mode === 'sse') {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'sse',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers: headers,
|
||||
timeout: value.timeout,
|
||||
ssereadtimeout: value.ssereadtimeout,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'http',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers: headers,
|
||||
timeout: value.timeout,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Convert extra_args to env
|
||||
const env: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
env[arg.key] = String(arg.value);
|
||||
});
|
||||
|
||||
// Convert args object array to string array
|
||||
const args = value.args?.map((arg) => arg.value) || [];
|
||||
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'stdio',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
command: value.command!,
|
||||
args: args,
|
||||
env: env,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isEditMode && serverName) {
|
||||
await httpClient.updateMCPServer(serverName, serverConfig);
|
||||
toast.success(t('mcp.updateSuccess'));
|
||||
} else {
|
||||
await httpClient.createMCPServer(serverConfig);
|
||||
toast.success(t('mcp.createSuccess'));
|
||||
}
|
||||
|
||||
handleDialogClose(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to save MCP server:', error);
|
||||
const errMsg = (error as CustomApiError).msg || '';
|
||||
toast.error(
|
||||
(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testMcp() {
|
||||
setMcpTesting(true);
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
let extraArgsData:
|
||||
| MCPServerExtraArgsSSE
|
||||
| MCPServerExtraArgsHttp
|
||||
| MCPServerExtraArgsStdio;
|
||||
|
||||
if (mode === 'sse') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
headers: Object.fromEntries(
|
||||
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
ssereadtimeout: form.getValues('ssereadtimeout'),
|
||||
};
|
||||
} else if (mode === 'http') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
headers: Object.fromEntries(
|
||||
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
};
|
||||
} else {
|
||||
extraArgsData = {
|
||||
command: form.getValues('command')!,
|
||||
args: stdioArgs.map((arg) => arg.value),
|
||||
env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])),
|
||||
};
|
||||
}
|
||||
|
||||
const { task_id } = await httpClient.testMCPServer('_', {
|
||||
name: form.getValues('name'),
|
||||
mode: mode,
|
||||
enable: true,
|
||||
extra_args: extraArgsData,
|
||||
} as MCPServer);
|
||||
|
||||
if (!task_id) {
|
||||
throw new Error(t('mcp.noTaskId'));
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const taskResp = await httpClient.getAsyncTask(task_id);
|
||||
|
||||
if (taskResp.runtime?.done) {
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
|
||||
if (taskResp.runtime.exception) {
|
||||
const errorMsg =
|
||||
taskResp.runtime.exception || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
setRuntimeInfo({
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
});
|
||||
} else {
|
||||
if (isEditMode) {
|
||||
await loadServerForEdit(form.getValues('name'));
|
||||
}
|
||||
toast.success(t('mcp.testSuccess'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg || t('mcp.getTaskFailed');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setMcpTesting(false);
|
||||
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const addExtraArg = () => {
|
||||
const newArgs = [
|
||||
...extraArgs,
|
||||
{ key: '', type: 'string' as const, value: '' },
|
||||
];
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs);
|
||||
};
|
||||
|
||||
const removeExtraArg = (index: number) => {
|
||||
const newArgs = extraArgs.filter((_, i) => i !== index);
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs);
|
||||
};
|
||||
|
||||
const updateExtraArg = (
|
||||
index: number,
|
||||
field: 'key' | 'type' | 'value',
|
||||
value: string,
|
||||
) => {
|
||||
const newArgs = [...extraArgs];
|
||||
newArgs[index] = { ...newArgs[index], [field]: value };
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs);
|
||||
};
|
||||
|
||||
const addStdioArg = () => {
|
||||
const newArgs = [...stdioArgs, { value: '' }];
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs);
|
||||
};
|
||||
|
||||
const removeStdioArg = (index: number) => {
|
||||
const newArgs = stdioArgs.filter((_, i) => i !== index);
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs);
|
||||
};
|
||||
|
||||
const updateStdioArg = (index: number, value: string) => {
|
||||
const newArgs = [...stdioArgs];
|
||||
newArgs[index] = { value };
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs);
|
||||
};
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setExtraArgs([]);
|
||||
setStdioArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isEditMode && runtimeInfo && (
|
||||
<div className="mb-0 space-y-3">
|
||||
{/* 测试中或连接失败时显示状态 */}
|
||||
{(mcpTesting ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
|
||||
<div className="p-3 rounded-lg border">
|
||||
<StatusDisplay
|
||||
testing={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连接成功时只显示工具列表 */}
|
||||
{!mcpTesting &&
|
||||
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
|
||||
runtimeInfo.tools?.length > 0 && (
|
||||
<>
|
||||
<div className="text-sm font-medium">
|
||||
{t('mcp.toolCount', {
|
||||
count: runtimeInfo.tools?.length || 0,
|
||||
})}
|
||||
</div>
|
||||
<ToolsList tools={runtimeInfo.tools} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.serverMode')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('mcp.selectMode')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
||||
{t('mcp.stdio')}
|
||||
{!boxAvailable && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.boxRequired')})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{stdioBlockedByBox && (
|
||||
<BoxUnavailableNotice
|
||||
hint={boxHint}
|
||||
reason={boxReason}
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(watchMode === 'sse' || watchMode === 'http') && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.url')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.timeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.timeout')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchMode === 'sse' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssereadtimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.sseTimeoutDescription')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchMode === 'stdio' && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.command')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.args')}</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{stdioArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('mcp.args')}
|
||||
value={arg.value}
|
||||
onChange={(e) =>
|
||||
updateStdioArg(index, e.target.value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
onClick={() => removeStdioArg(index)}
|
||||
>
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addStdioArg}
|
||||
>
|
||||
{t('mcp.addArgument')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.headers')
|
||||
: t('mcp.env')}
|
||||
</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{extraArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
onChange={(e) =>
|
||||
updateExtraArg(index, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
{/* Only show type select for SSE headers if needed, but usually headers are strings. Env vars are definitely strings.
|
||||
The original code had type selector. Let's keep it for compatibility or remove if not needed.
|
||||
Headers are strings. Env vars are strings.
|
||||
Let's hide the type selector as it was confusing anyway, or force it to string.
|
||||
*/}
|
||||
{/* <Select
|
||||
value={arg.type}
|
||||
onValueChange={(value) =>
|
||||
updateExtraArg(index, 'type', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('models.type')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectItem value="string">
|
||||
{t('models.string')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select> */}
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
onChange={(e) =>
|
||||
updateExtraArg(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
onClick={() => removeExtraArg(index)}
|
||||
>
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addExtraArg}>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.addHeader')
|
||||
: t('mcp.addEnvVar')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t('mcp.extraParametersDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
<DialogFooter>
|
||||
{isEditMode && onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={stdioBlockedByBox}>
|
||||
{isEditMode ? t('common.save') : t('common.submit')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleDialogClose(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -531,6 +531,15 @@ export interface MCPServerExtraArgsHttp {
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
// "remote" mode: the user only supplies a URL; the backend auto-detects the
|
||||
// transport (Streamable HTTP first, falling back to legacy SSE). headers /
|
||||
// timeout are optional advanced settings.
|
||||
export interface MCPServerExtraArgsRemote {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export enum MCPSessionStatus {
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
@@ -577,6 +586,17 @@ export type MCPServer =
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
| {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
mode: 'remote';
|
||||
enable: boolean;
|
||||
extra_args: MCPServerExtraArgsRemote;
|
||||
runtime_info?: MCPServerRuntimeInfo;
|
||||
readme?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
| {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
|
||||
@@ -754,6 +754,15 @@ const enUS = {
|
||||
stdio: 'Stdio Mode',
|
||||
sse: 'SSE Mode',
|
||||
http: 'HTTP Mode',
|
||||
local: 'Local (Stdio)',
|
||||
remote: 'Remote',
|
||||
localModeDescription:
|
||||
'Run an MCP server locally as a subprocess inside the Box sandbox.',
|
||||
remoteModeDescription:
|
||||
'Connect to a remote MCP server by URL. The transport (Streamable HTTP or SSE) is detected automatically.',
|
||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||
remoteUrlDescription:
|
||||
'Paste the MCP server URL. Both Streamable HTTP and legacy SSE endpoints are supported.',
|
||||
noServerInstalled: 'No MCP servers configured',
|
||||
serverNameRequired: 'Server name cannot be empty',
|
||||
commandRequired: 'Command cannot be empty',
|
||||
|
||||
@@ -768,6 +768,15 @@ const esES = {
|
||||
stdio: 'Modo Stdio',
|
||||
sse: 'Modo SSE',
|
||||
http: 'Modo HTTP',
|
||||
local: 'Local (Stdio)',
|
||||
remote: 'Remoto',
|
||||
localModeDescription:
|
||||
'Ejecuta un servidor MCP localmente como subproceso dentro del sandbox de Box.',
|
||||
remoteModeDescription:
|
||||
'Conéctate a un servidor MCP remoto por URL. El transporte (Streamable HTTP o SSE) se detecta automáticamente.',
|
||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||
remoteUrlDescription:
|
||||
'Pega la URL del servidor MCP. Se admiten tanto endpoints Streamable HTTP como SSE heredados.',
|
||||
noServerInstalled: 'No hay servidores MCP configurados',
|
||||
serverNameRequired: 'El nombre del servidor no puede estar vacío',
|
||||
commandRequired: 'El comando no puede estar vacío',
|
||||
|
||||
@@ -759,6 +759,14 @@ const jaJP = {
|
||||
stdio: 'Stdioモード',
|
||||
sse: 'SSEモード',
|
||||
http: 'HTTPモード',
|
||||
local: 'ローカル(Stdio)',
|
||||
remote: 'リモート',
|
||||
localModeDescription: 'Box サンドボックス内でサブプロセスとして MCP サーバーをローカル実行します。',
|
||||
remoteModeDescription:
|
||||
'URL でリモート MCP サーバーに接続します。トランスポート(Streamable HTTP または SSE)は自動検出されます。',
|
||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||
remoteUrlDescription:
|
||||
'MCP サーバーの URL を貼り付けてください。Streamable HTTP と従来の SSE エンドポイントの両方に対応しています。',
|
||||
selectMode: '接続モードを選択',
|
||||
noServerInstalled: 'MCPサーバーが設定されていません',
|
||||
serverNameRequired: 'サーバー名は必須です',
|
||||
|
||||
@@ -765,6 +765,15 @@ const ruRU = {
|
||||
stdio: 'Режим Stdio',
|
||||
sse: 'Режим SSE',
|
||||
http: 'Режим HTTP',
|
||||
local: 'Локально (Stdio)',
|
||||
remote: 'Удалённо',
|
||||
localModeDescription:
|
||||
'Запуск MCP-сервера локально как подпроцесса внутри песочницы Box.',
|
||||
remoteModeDescription:
|
||||
'Подключение к удалённому MCP-серверу по URL. Транспорт (Streamable HTTP или SSE) определяется автоматически.',
|
||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||
remoteUrlDescription:
|
||||
'Вставьте URL MCP-сервера. Поддерживаются как Streamable HTTP, так и устаревшие SSE-эндпоинты.',
|
||||
noServerInstalled: 'MCP-серверы не настроены',
|
||||
serverNameRequired: 'Имя сервера не может быть пустым',
|
||||
commandRequired: 'Команда не может быть пустой',
|
||||
|
||||
@@ -743,6 +743,15 @@ const thTH = {
|
||||
stdio: 'โหมด Stdio',
|
||||
sse: 'โหมด SSE',
|
||||
http: 'โหมด HTTP',
|
||||
local: 'ภายในเครื่อง (Stdio)',
|
||||
remote: 'ระยะไกล',
|
||||
localModeDescription:
|
||||
'รันเซิร์ฟเวอร์ MCP ภายในเครื่องเป็นโปรเซสย่อยภายในแซนด์บ็อกซ์ Box',
|
||||
remoteModeDescription:
|
||||
'เชื่อมต่อกับเซิร์ฟเวอร์ MCP ระยะไกลด้วย URL ระบบจะตรวจจับการขนส่ง (Streamable HTTP หรือ SSE) โดยอัตโนมัติ',
|
||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||
remoteUrlDescription:
|
||||
'วาง URL ของเซิร์ฟเวอร์ MCP รองรับทั้งเอนด์พอยต์ Streamable HTTP และ SSE แบบเดิม',
|
||||
noServerInstalled: 'ยังไม่มีเซิร์ฟเวอร์ MCP ที่กำหนดค่า',
|
||||
serverNameRequired: 'ชื่อเซิร์ฟเวอร์ต้องไม่ว่างเปล่า',
|
||||
commandRequired: 'คำสั่งต้องไม่ว่างเปล่า',
|
||||
|
||||
@@ -758,6 +758,15 @@ const viVN = {
|
||||
stdio: 'Chế độ Stdio',
|
||||
sse: 'Chế độ SSE',
|
||||
http: 'Chế độ HTTP',
|
||||
local: 'Cục bộ (Stdio)',
|
||||
remote: 'Từ xa',
|
||||
localModeDescription:
|
||||
'Chạy máy chủ MCP cục bộ dưới dạng tiến trình con bên trong sandbox Box.',
|
||||
remoteModeDescription:
|
||||
'Kết nối đến máy chủ MCP từ xa bằng URL. Phương thức truyền tải (Streamable HTTP hoặc SSE) được phát hiện tự động.',
|
||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||
remoteUrlDescription:
|
||||
'Dán URL của máy chủ MCP. Hỗ trợ cả endpoint Streamable HTTP và SSE cũ.',
|
||||
noServerInstalled: 'Chưa cấu hình máy chủ MCP nào',
|
||||
serverNameRequired: 'Tên máy chủ không được để trống',
|
||||
commandRequired: 'Lệnh không được để trống',
|
||||
|
||||
@@ -722,6 +722,14 @@ const zhHans = {
|
||||
stdio: 'Stdio模式',
|
||||
sse: 'SSE模式',
|
||||
http: 'HTTP模式',
|
||||
local: '本地(Stdio)',
|
||||
remote: '远程',
|
||||
localModeDescription: '在 Box 沙箱中以子进程方式本地运行 MCP 服务器。',
|
||||
remoteModeDescription:
|
||||
'通过 URL 连接远程 MCP 服务器,传输方式(Streamable HTTP 或 SSE)将自动检测。',
|
||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||
remoteUrlDescription:
|
||||
'粘贴 MCP 服务器链接即可,同时支持 Streamable HTTP 和旧版 SSE 端点。',
|
||||
noServerInstalled: '暂未配置任何 MCP 服务器',
|
||||
serverNameRequired: '服务器名称不能为空',
|
||||
commandRequired: '命令不能为空',
|
||||
|
||||
@@ -721,6 +721,14 @@ const zhHant = {
|
||||
sse: 'SSE模式',
|
||||
selectMode: '選擇連接模式',
|
||||
http: 'HTTP模式',
|
||||
local: '本機(Stdio)',
|
||||
remote: '遠端',
|
||||
localModeDescription: '在 Box 沙箱中以子程序方式於本機執行 MCP 伺服器。',
|
||||
remoteModeDescription:
|
||||
'透過 URL 連接遠端 MCP 伺服器,傳輸方式(Streamable HTTP 或 SSE)將自動偵測。',
|
||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||
remoteUrlDescription:
|
||||
'貼上 MCP 伺服器連結即可,同時支援 Streamable HTTP 與舊版 SSE 端點。',
|
||||
noServerInstalled: '暫未設定任何MCP伺服器',
|
||||
serverNameRequired: '伺服器名稱不能為空',
|
||||
commandRequired: '命令不能為空',
|
||||
|
||||
Reference in New Issue
Block a user