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:
RockChinQ
2026-06-21 11:20:32 -04:00
parent 2ff854f79a
commit 64ed6d994b
21 changed files with 268 additions and 1389 deletions
+20 -5
View File
@@ -141,15 +141,25 @@ class MCPService:
runtime_mcp_session: RuntimeMCPSession | None = None
ctx = taskmgr.TaskContext.new()
if server_name != '_':
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
if runtime_mcp_session is None:
raise ValueError(f'Server not found: {server_name}')
if runtime_mcp_session.status == MCPSessionStatus.ERROR:
coroutine = runtime_mcp_session.start()
else:
coroutine = runtime_mcp_session.refresh()
persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None:
if persisted_session.status == MCPSessionStatus.ERROR:
await persisted_session.start()
else:
await persisted_session.refresh()
# Surface the discovered tools so the config page can render them
# even for an already-hosted server.
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
coroutine = _refresh_and_report()
else:
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
@@ -160,6 +170,12 @@ class MCPService:
async def _run_and_cleanup() -> None:
try:
await test_session.start()
# Capture the runtime info (status + discovered tools) BEFORE
# shutting the transient session down. The create/edit config
# page has no persisted server to reload from, so without this
# a successful test could only show "no tools found". The
# frontend reads ctx.metadata.runtime_info to render the tools.
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
finally:
try:
await test_session.shutdown()
@@ -171,7 +187,6 @@ class MCPService:
coroutine = _run_and_cleanup()
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
coroutine,
kind='mcp-operation',
+1 -1
View File
@@ -9,7 +9,7 @@ class MCPServer(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, remote (legacy: sse, http)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
# Markdown documentation captured from LangBot Space at install time so the
# detail page can show docs even when the server is offline / has no tools.
@@ -0,0 +1,51 @@
"""normalize mcp_servers transport mode to local/remote
The MCP transport selection for servers LangBot connects to was simplified
from three persisted modes (``stdio`` / ``sse`` / ``http``) down to two:
``stdio`` (local, Box-sandboxed) and ``remote`` (the runtime auto-detects
Streamable HTTP vs. legacy SSE from the URL). This migration rewrites any
existing ``sse`` / ``http`` rows to ``remote`` so the stored value matches the
new two-option UI. The connection args (url / headers / timeout /
ssereadtimeout) live in ``extra_args`` and are left untouched — the
auto-detecting remote transport consumes them regardless.
Revision ID: 0006_normalize_mcp_remote_mode
Revises: 0005_add_llm_context_length
Create Date: 2026-06-21
"""
import sqlalchemy as sa
from alembic import op
revision = '0006_normalize_mcp_remote_mode'
down_revision = '0005_add_llm_context_length'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Idempotent data migration: collapse legacy remote transports into the
# unified ``remote`` mode. Guard against the table being absent (truly empty
# DB migrated before create_all()).
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'mcp_servers' not in inspector.get_table_names():
return
conn.execute(
sa.text("UPDATE mcp_servers SET mode = 'remote' WHERE mode IN ('sse', 'http')")
)
def downgrade() -> None:
# The legacy distinction between ``sse`` and ``http`` cannot be recovered
# from ``remote`` alone (the transport is auto-detected at runtime, not
# stored). Map everything that is not ``stdio`` back to ``http`` as a
# best-effort reversal — both legacy modes still route correctly in the
# backend lifecycle dispatch.
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'mcp_servers' not in inspector.get_table_names():
return
conn.execute(
sa.text("UPDATE mcp_servers SET mode = 'http' WHERE mode = 'remote'")
)
+9
View File
@@ -248,6 +248,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
mode = mcp_data.get('mode') or 'stdio'
extra_args = mcp_data.get('extra_args') or {}
# The MCP transport selection was simplified to two modes: 'stdio'
# (local, Box-sandboxed) and 'remote' (the runtime auto-detects
# Streamable HTTP vs. legacy SSE from the URL). Marketplace records may
# still carry the older 'http'/'sse' modes — normalize them to 'remote'
# so the installed server shows up correctly in the two-option UI. The
# connection args (url/headers/timeout/ssereadtimeout) are preserved and
# consumed by the auto-detecting remote transport regardless.
if mode in ('http', 'sse'):
mode = 'remote'
# Marketplace records carry the rendered README markdown; persist it so
# the detail page Docs tab works offline and without a marketplace round-trip.
readme = mcp_data.get('readme') or ''
@@ -167,6 +167,38 @@ class RuntimeMCPSession:
await self.session.initialize()
async def _init_remote_server(self):
"""Connect to a remote MCP server, auto-detecting the transport.
The user only supplies a URL ("remote" mode); they should not have to
know whether the server speaks the modern Streamable HTTP transport or
the legacy HTTP+SSE transport. Following the MCP backwards-compatibility
guidance, we try Streamable HTTP first and fall back to SSE when it
fails (e.g. the endpoint returns 4xx to the initialize POST).
"""
try:
await self._init_streamable_http_server()
return
except Exception as e:
self.ap.logger.info(
f'MCP server {self.server_name}: Streamable HTTP transport failed '
f'({self._describe_exception(e)}), falling back to SSE'
)
# The Streamable HTTP attempt may have partially entered the transport /
# session into the exit stack before failing. Tear it down and start
# from a clean stack before trying SSE so we do not leak connections.
try:
await self.exit_stack.aclose()
except Exception as cleanup_err:
self.ap.logger.debug(
f'MCP server {self.server_name}: error cleaning up before SSE fallback: {cleanup_err}'
)
self.exit_stack = AsyncExitStack()
self.session = None
await self._init_sse_server()
_MAX_RETRIES = 3
_RETRY_DELAYS = [2, 4, 8]
@@ -175,6 +207,8 @@ class RuntimeMCPSession:
try:
if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'remote':
await self._init_remote_server()
elif self.server_config['mode'] == 'sse':
await self._init_sse_server()
elif self.server_config['mode'] == 'http':
@@ -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>
);
}
+20
View File
@@ -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;
+9
View File
@@ -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',
+9
View File
@@ -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',
+8
View File
@@ -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: 'サーバー名は必須です',
+9
View File
@@ -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: 'Команда не может быть пустой',
+9
View File
@@ -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: 'คำสั่งต้องไม่ว่างเปล่า',
+9
View File
@@ -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',
+8
View File
@@ -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: '命令不能为空',
+8
View File
@@ -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: '命令不能為空',