From 64ed6d994ba1ac1544e8f45d4ea462a1e909a9f2 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 21 Jun 2026 11:20:32 -0400 Subject: [PATCH] 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 --- src/langbot/pkg/api/http/service/mcp.py | 25 +- src/langbot/pkg/entity/persistence/mcp.py | 2 +- .../0006_normalize_mcp_remote_mode.py | 51 + src/langbot/pkg/plugin/connector.py | 9 + src/langbot/pkg/provider/tools/loaders/mcp.py | 34 + .../home/mcp/components/mcp-form/MCPForm.tsx | 155 ++- .../plugin-installed/ExtensionCardVO.ts | 4 +- .../app/home/plugins/mcp-server/MCPCardVO.ts | 29 - .../plugins/mcp-server/MCPServerComponent.tsx | 106 -- .../mcp-server/mcp-card/MCPCardComponent.tsx | 180 ---- .../mcp-form/MCPDeleteConfirmDialog.tsx | 66 -- .../mcp-server/mcp-form/MCPFormDialog.tsx | 907 ------------------ web/src/app/infra/entities/api/index.ts | 20 + web/src/i18n/locales/en-US.ts | 9 + web/src/i18n/locales/es-ES.ts | 9 + web/src/i18n/locales/ja-JP.ts | 8 + web/src/i18n/locales/ru-RU.ts | 9 + web/src/i18n/locales/th-TH.ts | 9 + web/src/i18n/locales/vi-VN.ts | 9 + web/src/i18n/locales/zh-Hans.ts | 8 + web/src/i18n/locales/zh-Hant.ts | 8 + 21 files changed, 268 insertions(+), 1389 deletions(-) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py delete mode 100644 web/src/app/home/plugins/mcp-server/MCPCardVO.ts delete mode 100644 web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx delete mode 100644 web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx delete mode 100644 web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx delete mode 100644 web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index e0c64b9f6..9db699c20 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -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', diff --git a/src/langbot/pkg/entity/persistence/mcp.py b/src/langbot/pkg/entity/persistence/mcp.py index 31348fcea..983fdce53 100644 --- a/src/langbot/pkg/entity/persistence/mcp.py +++ b/src/langbot/pkg/entity/persistence/mcp.py @@ -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. diff --git a/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py b/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py new file mode 100644 index 000000000..1b5410d95 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py @@ -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'") + ) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 12413e49d..9cef9cb67 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -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 '' diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 8049b185d..8ef7d85e7 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -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': diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index ae5f2ace5..e92d635f8 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -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 (
{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(function MCPForm( resolver: zodResolver(formSchema) as unknown as Resolver, defaultValues: { name: '', - mode: 'sse', + mode: 'remote', url: '', command: '', args: [], @@ -465,7 +465,7 @@ const MCPForm = forwardRef(function MCPForm( } else { form.reset({ name: '', - mode: 'sse', + mode: 'remote', url: '', command: '', args: [], @@ -535,9 +535,15 @@ const MCPForm = forwardRef(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(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(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(function MCPForm( try { let serverConfig: MCPServer; - if (value.mode === 'sse' || value.mode === 'http') { + if (value.mode === 'remote') { const headers: Record = {}; 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 = {}; value.extra_args?.forEach((arg) => { @@ -694,21 +684,9 @@ const MCPForm = forwardRef(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(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(function MCPForm( - {t('mcp.http')} + {t('mcp.remote')} - {t('mcp.stdio')} + {t('mcp.local')} {!boxAvailable && ( ({t('mcp.boxRequired')}) )} - {t('mcp.sse')} + + {watchMode === 'stdio' + ? t('mcp.localModeDescription') + : t('mcp.remoteModeDescription')} + {stdioBlockedByBox && ( (function MCPForm( )} /> - {(watchMode === 'sse' || watchMode === 'http') && ( + {watchMode === 'remote' && ( <> (function MCPForm( * - + + + {t('mcp.remoteUrlDescription')} + )} @@ -932,27 +931,6 @@ const MCPForm = forwardRef(function MCPForm( )} /> - - {watchMode === 'sse' && ( - ( - - {t('mcp.sseTimeout')} - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> - )} )} @@ -1006,9 +984,7 @@ const MCPForm = forwardRef(function MCPForm( - {watchMode === 'sse' || watchMode === 'http' - ? t('mcp.headers') - : t('mcp.env')} + {watchMode === 'remote' ? t('mcp.headers') : t('mcp.env')}
{extraArgs.map((arg, index) => ( @@ -1037,9 +1013,7 @@ const MCPForm = forwardRef(function MCPForm(
))}
@@ -1052,12 +1026,7 @@ const MCPForm = forwardRef(function MCPForm( ); const runtimePanel = ( - + ); // In edit mode the right side shows a tablist switching between the live diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardVO.ts b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardVO.ts index b83667b2a..746252837 100644 --- a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardVO.ts +++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardVO.ts @@ -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; diff --git a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts deleted file mode 100644 index 500b381bc..000000000 --- a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts +++ /dev/null @@ -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; - } - } -} diff --git a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx deleted file mode 100644 index ba638dacb..000000000 --- a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(false); - const pollingIntervalRef = useRef(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 ( -
- {/* Server list */} -
- {loading ? ( -
- {t('mcp.loading')} -
- ) : installedServers.length === 0 ? ( -
- -
{t('mcp.noServerInstalled')}
-
- ) : ( -
- {installedServers.map((server, index) => ( -
- { - if (onEditServer) { - onEditServer(server.name); - } - }} - onRefresh={fetchInstalledServers} - /> -
- ))} -
- )} -
-
- ); -} diff --git a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx deleted file mode 100644 index cdf315f01..000000000 --- a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx +++ /dev/null @@ -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 ( -
-
- - -
-
-
-
- {cardVO.name} -
- - {cardVO.mode.toUpperCase()} - -
-
- -
- {!enabled ? ( - // 未启用 - 橙色 -
- -
- {t('mcp.statusDisabled')} -
-
- ) : status === MCPSessionStatus.CONNECTED ? ( - // 连接成功 - 显示工具数量 -
- -
- {t('mcp.toolCount', { count: toolsCount })} -
-
- ) : status === MCPSessionStatus.ERROR ? ( - // 连接失败 - 红色(仅在明确报错时) -
- -
- {t('mcp.connectionFailedStatus')} -
-
- ) : ( - // 连接中 - 蓝色加载(CONNECTING 或初始/未知状态,避免误报失败) -
- -
- {t('mcp.connecting')} -
-
- )} -
-
- -
-
e.stopPropagation()} - > - -
- -
- -
-
-
-
- ); -} diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx deleted file mode 100644 index 44315a886..000000000 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx +++ /dev/null @@ -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 ( - - - - {t('mcp.confirmDeleteTitle')} - - {t('mcp.confirmDeleteServer')} - - - - - - - ); -} diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx deleted file mode 100644 index 1e9de9df1..000000000 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx +++ /dev/null @@ -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 ( -
- - {t('mcp.testing')} -
- ); - } - - // 连接中 - if (runtimeInfo.status === MCPSessionStatus.CONNECTING) { - return ( -
- - {t('mcp.connecting')} -
- ); - } - - // 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 ( -
-
- - {t('mcp.connectionFailed')} -
-
-
- {isDisabledByConfig - ? t('mcp.boxDisabledStdioRefused') - : t('mcp.boxUnavailableStdioRefused')} -
-
- {t('mcp.boxStdioRefusedSuggestion')} -
-
-
- ); - } - - // 连接失败 - return ( -
-
- - {t('mcp.connectionFailed')} -
- {runtimeInfo.error_message && ( -
- {runtimeInfo.error_message} -
- )} -
- ); -} - -// Tools List Component -function ToolsList({ tools }: { tools: MCPTool[] }) { - return ( -
- {tools.map((tool, index) => ( - - - {tool.name} - {tool.description && ( - - {tool.description} - - )} - - - ))} -
- ); -} - -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> & { - 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({ - resolver: zodResolver(formSchema) as unknown as Resolver, - 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( - null, - ); - const pollingIntervalRef = useRef(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) { - // 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 = {}; - 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 = {}; - 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 ( - - - - - {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} - - - - {isEditMode && runtimeInfo && ( -
- {/* 测试中或连接失败时显示状态 */} - {(mcpTesting || - runtimeInfo.status !== MCPSessionStatus.CONNECTED) && ( -
- -
- )} - - {/* 连接成功时只显示工具列表 */} - {!mcpTesting && - runtimeInfo.status === MCPSessionStatus.CONNECTED && - runtimeInfo.tools?.length > 0 && ( - <> -
- {t('mcp.toolCount', { - count: runtimeInfo.tools?.length || 0, - })} -
- - - )} -
- )} - -
- -
- ( - - {t('mcp.name')} - - - - - - )} - /> - - ( - - {t('mcp.serverMode')} - - {stdioBlockedByBox && ( - - )} - - - )} - /> - - {(watchMode === 'sse' || watchMode === 'http') && ( - <> - ( - - {t('mcp.url')} - - - - - - )} - /> - - ( - - {t('mcp.timeout')} - - - field.onChange(Number(e.target.value)) - } - /> - - - - )} - /> - - {watchMode === 'sse' && ( - ( - - {t('mcp.sseTimeout')} - - - field.onChange(Number(e.target.value)) - } - /> - - - - )} - /> - )} - - )} - - {watchMode === 'stdio' && ( - <> - ( - - {t('mcp.command')} - - - - - - )} - /> - - - {t('mcp.args')} -
- {stdioArgs.map((arg, index) => ( -
- - updateStdioArg(index, e.target.value) - } - /> - -
- ))} - -
-
- - )} - - - - {watchMode === 'sse' || watchMode === 'http' - ? t('mcp.headers') - : t('mcp.env')} - -
- {extraArgs.map((arg, index) => ( -
- - 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. - */} - {/* */} - - updateExtraArg(index, 'value', e.target.value) - } - /> - -
- ))} - -
- - {t('mcp.extraParametersDescription')} - - -
- - - {isEditMode && onDelete && ( - - )} - - - - - - - -
-
- -
-
- ); -} diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index c9a5d01e2..582364656 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -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; + 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; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index c4a79c589..86ef136e0 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 019b48f2d..7feff88b8 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 898011b0b..e6de73cd6 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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: 'サーバー名は必須です', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 8aec863fa..91e42b4ed 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -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: 'Команда не может быть пустой', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index d5c331f2e..01cf80a31 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -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: 'คำสั่งต้องไม่ว่างเปล่า', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index d28f8a47a..78b2b3eae 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -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', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 9fa90be9f..46065637d 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '命令不能为空', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 09568d6b9..2c932cdce 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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: '命令不能為空',