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(
))}
- {watchMode === 'sse' || watchMode === 'http'
- ? t('mcp.addHeader')
- : t('mcp.addEnvVar')}
+ {watchMode === 'remote' ? t('mcp.addHeader') : t('mcp.addEnvVar')}
@@ -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()}
- >
-
-
-
-
- handleTest(e)}
- disabled={testing}
- >
-
-
-
-
-
-
- );
-}
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')}
-
- onOpenChange(false)}>
- {t('common.cancel')}
-
-
- {t('common.confirm')}
-
-
-
-
- );
-}
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,
- })}
-
-
- >
- )}
-
- )}
-
-
-
-
-
- );
-}
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: '命令不能為空',