fix(mcp): make tool call timeout configurable

This commit is contained in:
Junyan Qin
2026-07-23 18:15:57 +08:00
parent 7677d1a288
commit 0dfae76e39
13 changed files with 277 additions and 9 deletions
+48 -6
View File
@@ -3,10 +3,12 @@ from __future__ import annotations
import base64 import base64
import enum import enum
import json import json
import math
import re import re
import time import time
import typing import typing
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from datetime import timedelta
import traceback import traceback
from langbot_plugin.api.entities.events import pipeline_query from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy import sqlalchemy
@@ -51,6 +53,7 @@ MCP_RESOURCE_CONTEXT_MAX_BYTES = 96 * 1024
MCP_RESOURCE_TRACE_QUERY_KEY = '_mcp_resource_reads' MCP_RESOURCE_TRACE_QUERY_KEY = '_mcp_resource_reads'
MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links' MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links'
MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context' MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context'
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS = 300.0
TEXT_LIKE_MIME_TYPES = { TEXT_LIKE_MIME_TYPES = {
'application/json', 'application/json',
@@ -213,6 +216,10 @@ class _CallerReconnect(Exception):
""" """
class MCPToolCallTimeoutError(TimeoutError):
"""An MCP tool call exceeded its configured per-server deadline."""
class RuntimeMCPSession: class RuntimeMCPSession:
"""运行时 MCP 会话""" """运行时 MCP 会话"""
@@ -262,6 +269,9 @@ class RuntimeMCPSession:
self.ap = ap self.ap = ap
self.enable = enable self.enable = enable
self.session = None self.session = None
self.tool_call_timeout_sec = self._parse_tool_call_timeout(
server_config.get('tool_call_timeout_sec', MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS)
)
# Transient test sessions (created from the config page "test" button, # Transient test sessions (created from the config page "test" button,
# which carry no persisted server UUID) must NOT share the live # which carry no persisted server UUID) must NOT share the live
@@ -302,6 +312,21 @@ class RuntimeMCPSession:
self._box_stdio_runtime = BoxStdioSessionRuntime(self) self._box_stdio_runtime = BoxStdioSessionRuntime(self)
self.box_config = self._box_stdio_runtime.config self.box_config = self._box_stdio_runtime.config
def _parse_tool_call_timeout(self, value: typing.Any) -> float:
"""Return a safe tool-call timeout; zero explicitly disables it."""
try:
timeout = float(value)
except (TypeError, ValueError):
timeout = -1
if not math.isfinite(timeout) or timeout < 0:
self.ap.logger.warning(
f'Invalid MCP tool call timeout {value!r} for {self.server_name}; '
f'using {MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS:g} seconds'
)
return MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
return timeout
async def _init_stdio_python_server(self): async def _init_stdio_python_server(self):
if self._uses_box_stdio(): if self._uses_box_stdio():
await self._box_stdio_runtime.initialize() await self._box_stdio_runtime.initialize()
@@ -968,13 +993,22 @@ class RuntimeMCPSession:
raise Exception('MCP session is not connected') raise Exception('MCP session is not connected')
try: try:
async with asyncio.timeout(30): read_timeout = timedelta(seconds=self.tool_call_timeout_sec) if self.tool_call_timeout_sec > 0 else None
result = await self.session.call_tool(tool_name, arguments) result = await self.session.call_tool(
except TimeoutError as e: tool_name,
raise Exception( arguments,
f"MCP tool '{tool_name}' on server '{self.server_name}' timed out after 30 seconds" read_timeout_seconds=read_timeout,
) from e )
except Exception as e: except Exception as e:
if self._is_tool_call_timeout(e):
self.ap.logger.warning(
f'MCP tool {tool_name} on {self.server_name} timed out after '
f'{self.tool_call_timeout_sec:g} seconds'
)
raise MCPToolCallTimeoutError(
f"MCP tool '{tool_name}' on server '{self.server_name}' timed out after "
f'{self.tool_call_timeout_sec:g} seconds'
) from e
if attempt == 0 and self._is_session_terminated(e): if attempt == 0 and self._is_session_terminated(e):
self.ap.logger.warning( self.ap.logger.warning(
f'MCP tool {tool_name} on {self.server_name} got session terminated, triggering reconnect...' f'MCP tool {tool_name} on {self.server_name} got session terminated, triggering reconnect...'
@@ -997,6 +1031,14 @@ class RuntimeMCPSession:
raise Exception('MCP session is not connected') raise Exception('MCP session is not connected')
@staticmethod
def _is_tool_call_timeout(exc: BaseException) -> bool:
"""Recognize the MCP SDK's per-request timeout without retrying it."""
return any(
isinstance(leaf, McpError) and leaf.error.code == httpx.codes.REQUEST_TIMEOUT
for leaf in RuntimeMCPSession._iter_exception_leaves(exc)
)
def get_tools(self) -> list[resource_tool.LLMTool]: def get_tools(self) -> list[resource_tool.LLMTool]:
return self.functions return self.functions
@@ -12,7 +12,7 @@ import pytest
from aiohttp import web from aiohttp import web
from mcp import types as mcp_types from mcp import types as mcp_types
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession from langbot.pkg.provider.tools.loaders.mcp import MCPToolCallTimeoutError, RuntimeMCPSession
class _TransportProbe: class _TransportProbe:
@@ -65,6 +65,25 @@ class _TransportProbe:
}, },
} }
) )
if method == 'tools/call':
tool_name = message.get('params', {}).get('name')
if tool_name == 'hang':
return web.Response(status=202)
return web.json_response(
{
'jsonrpc': '2.0',
'id': message['id'],
'result': {
'content': [
{
'type': 'text',
'text': 'healthy',
}
],
'isError': False,
},
}
)
return web.Response(status=202) return web.Response(status=202)
return web.Response(status=self.streamable_status) return web.Response(status=self.streamable_status)
@@ -126,11 +145,22 @@ async def _transport_server(streamable_status: int | None):
await runner.cleanup() await runner.cleanup()
def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession: def _session(
url: str,
*,
timeout: float = 2,
tool_call_timeout_sec: float = 300,
) -> RuntimeMCPSession:
app = cast(Any, SimpleNamespace(logger=Mock())) app = cast(Any, SimpleNamespace(logger=Mock()))
return RuntimeMCPSession( return RuntimeMCPSession(
'remote-transport-test', 'remote-transport-test',
{'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout}, {
'uuid': 'srv-1',
'mode': 'remote',
'url': url,
'timeout': timeout,
'tool_call_timeout_sec': tool_call_timeout_sec,
},
True, True,
app, app,
) )
@@ -164,6 +194,24 @@ async def test_remote_transport_real_streamable_http_success_keeps_session_usabl
await _close_session(session) await _close_session(session)
@pytest.mark.asyncio
async def test_remote_transport_tool_timeout_does_not_poison_session():
async with _transport_server(200) as (probe, url):
session = _session(url, tool_call_timeout_sec=0.05)
try:
await session._init_remote_server()
with pytest.raises(MCPToolCallTimeoutError, match='timed out after 0.05 seconds'):
await session.invoke_mcp_tool('hang', {})
result = await session.invoke_mcp_tool('health_check', {})
assert result[0].text == 'healthy'
assert probe.streamable_messages.count('tools/call') == 2
finally:
await _close_session(session)
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize('status_code', [400, 404, 405]) @pytest.mark.parametrize('status_code', [400, 404, 405])
async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int): async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int):
@@ -2,20 +2,24 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
from datetime import timedelta
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import httpx import httpx
import pytest import pytest
from mcp import types as mcp_types from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from langbot.pkg.provider.tools.loaders.mcp import ( from langbot.pkg.provider.tools.loaders.mcp import (
MCP_RESOURCE_CONTEXT_QUERY_KEY, MCP_RESOURCE_CONTEXT_QUERY_KEY,
MCP_RESOURCE_TRACE_QUERY_KEY, MCP_RESOURCE_TRACE_QUERY_KEY,
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS,
MCP_TOOL_LIST_RESOURCES, MCP_TOOL_LIST_RESOURCES,
MCP_TOOL_READ_RESOURCE, MCP_TOOL_READ_RESOURCE,
MCPLoader, MCPLoader,
MCPSessionStatus, MCPSessionStatus,
MCPToolCallTimeoutError,
RuntimeMCPSession, RuntimeMCPSession,
) )
from langbot.pkg.telemetry import features as telemetry_features from langbot.pkg.telemetry import features as telemetry_features
@@ -62,6 +66,111 @@ def _http_status_error(status_code: int) -> httpx.HTTPStatusError:
return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response) return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response)
def _tool_result(text: str = 'ok') -> mcp_types.CallToolResult:
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(type='text', text=text)],
isError=False,
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_uses_configurable_request_timeout():
session = RuntimeMCPSession(
'slow-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 900,
},
True,
_app(),
)
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
result = await session.invoke_mcp_tool('render_video', {'quality': 'high'})
assert result[0].text == 'ok'
session.session.call_tool.assert_awaited_once_with(
'render_video',
{'quality': 'high'},
read_timeout_seconds=timedelta(seconds=900),
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_zero_timeout_disables_request_deadline():
session = RuntimeMCPSession(
'unbounded-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 0,
},
True,
_app(),
)
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
await session.invoke_mcp_tool('long_job', {})
session.session.call_tool.assert_awaited_once_with(
'long_job',
{},
read_timeout_seconds=None,
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_timeout_is_not_retried_and_session_remains_usable():
session = RuntimeMCPSession(
'recoverable-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 5,
},
True,
_app(),
)
timeout = McpError(
mcp_types.ErrorData(
code=httpx.codes.REQUEST_TIMEOUT,
message='Timed out while waiting for response to ClientRequest. Waited 5 seconds.',
)
)
call_tool = AsyncMock(side_effect=[timeout, _tool_result('recovered')])
session.session = SimpleNamespace(call_tool=call_tool)
with pytest.raises(
MCPToolCallTimeoutError,
match="MCP tool 'long_job' on server 'recoverable-tools' timed out after 5 seconds",
):
await session.invoke_mcp_tool('long_job', {})
assert call_tool.await_count == 1
second_result = await session.invoke_mcp_tool('health_check', {})
assert second_result[0].text == 'recovered'
assert call_tool.await_count == 2
@pytest.mark.parametrize('invalid_timeout', [-1, float('inf'), 'not-a-number'])
def test_invalid_tool_call_timeout_falls_back_to_default(invalid_timeout):
ap = _app()
session = RuntimeMCPSession(
'invalid-timeout',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': invalid_timeout,
},
True,
ap,
)
assert session.tool_call_timeout_sec == MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
ap.logger.warning.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group(): async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
session = RuntimeMCPSession( session = RuntimeMCPSession(
@@ -435,6 +435,10 @@ const getFormSchema = (t: TFunction) =>
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') }) .positive({ message: t('mcp.timeoutMustBePositive') })
.default(30), .default(30),
tool_call_timeout_sec: z
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.nonnegative({ message: t('mcp.timeoutNonNegative') })
.default(300),
ssereadtimeout: z ssereadtimeout: z
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') }) .positive({ message: t('mcp.timeoutMustBePositive') })
@@ -474,6 +478,7 @@ const getFormSchema = (t: TFunction) =>
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & { type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
timeout: number; timeout: number;
tool_call_timeout_sec: number;
ssereadtimeout: number; ssereadtimeout: number;
}; };
@@ -535,6 +540,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '', command: '',
args: [], args: [],
timeout: 30, timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300, ssereadtimeout: 300,
extra_args: [], extra_args: [],
...initialDraftRef.current, ...initialDraftRef.current,
@@ -609,6 +615,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '', command: '',
args: [], args: [],
timeout: 30, timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300, ssereadtimeout: 300,
extra_args: [], extra_args: [],
...initialDraftRef.current, ...initialDraftRef.current,
@@ -687,10 +694,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '', command: '',
args: [], args: [],
timeout: 30, timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300, ssereadtimeout: 300,
extra_args: [], extra_args: [],
}; };
if (typeof server.extra_args.tool_call_timeout_sec === 'number') {
formValues.tool_call_timeout_sec =
server.extra_args.tool_call_timeout_sec;
}
let newExtraArgs: { let newExtraArgs: {
key: string; key: string;
type: 'string' | 'number' | 'boolean'; type: 'string' | 'number' | 'boolean';
@@ -770,6 +783,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
url: value.url!, url: value.url!,
headers, headers,
timeout: value.timeout, timeout: value.timeout,
tool_call_timeout_sec: value.tool_call_timeout_sec,
}, },
}; };
} else { } else {
@@ -786,6 +800,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: value.command!, command: value.command!,
args: value.args?.map((arg) => arg.value) || [], args: value.args?.map((arg) => arg.value) || [],
env, env,
tool_call_timeout_sec: value.tool_call_timeout_sec,
}, },
}; };
} }
@@ -835,6 +850,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
extraArgsData = { extraArgsData = {
url: form.getValues('url')!, url: form.getValues('url')!,
timeout: form.getValues('timeout'), timeout: form.getValues('timeout'),
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
headers: Object.fromEntries( headers: Object.fromEntries(
formExtraArgs.map((arg) => [arg.key, arg.value]), formExtraArgs.map((arg) => [arg.key, arg.value]),
), ),
@@ -846,6 +862,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
env: Object.fromEntries( env: Object.fromEntries(
formExtraArgs.map((arg) => [arg.key, arg.value]), formExtraArgs.map((arg) => [arg.key, arg.value]),
), ),
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
}; };
} }
@@ -1047,6 +1064,30 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
)} )}
/> />
<FormField
control={form.control}
name="tool_call_timeout_sec"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.toolCallTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step={1}
placeholder="300"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
{t('mcp.toolCallTimeoutDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'remote' && ( {watchMode === 'remote' && (
<> <>
<FormField <FormField
+4
View File
@@ -519,18 +519,21 @@ export interface MCPServerExtraArgsSSE {
headers: Record<string, string>; headers: Record<string, string>;
timeout: number; timeout: number;
ssereadtimeout: number; ssereadtimeout: number;
tool_call_timeout_sec?: number;
} }
export interface MCPServerExtraArgsStdio { export interface MCPServerExtraArgsStdio {
command: string; command: string;
args: string[]; args: string[];
env: Record<string, string>; env: Record<string, string>;
tool_call_timeout_sec?: number;
} }
export interface MCPServerExtraArgsHttp { export interface MCPServerExtraArgsHttp {
url: string; url: string;
headers: Record<string, string>; headers: Record<string, string>;
timeout: number; timeout: number;
tool_call_timeout_sec?: number;
} }
// "remote" mode: the user only supplies a URL; the backend auto-detects the // "remote" mode: the user only supplies a URL; the backend auto-detects the
@@ -540,6 +543,7 @@ export interface MCPServerExtraArgsRemote {
url: string; url: string;
headers?: Record<string, string>; headers?: Record<string, string>;
timeout?: number; timeout?: number;
tool_call_timeout_sec?: number;
} }
export enum MCPSessionStatus { export enum MCPSessionStatus {
+3
View File
@@ -797,6 +797,9 @@ const enUS = {
url: 'URL', url: 'URL',
headers: 'Headers', headers: 'Headers',
timeout: 'Timeout', timeout: 'Timeout',
toolCallTimeout: 'Tool call timeout (seconds)',
toolCallTimeoutDescription:
'Maximum wait for one tool call. Set to 0 for no timeout. Defaults to 300 seconds.',
addArgument: 'Add Argument', addArgument: 'Add Argument',
addEnvVar: 'Add Environment Variable', addEnvVar: 'Add Environment Variable',
addHeader: 'Add Header', addHeader: 'Add Header',
+3
View File
@@ -811,6 +811,9 @@ const esES = {
url: 'URL', url: 'URL',
headers: 'Encabezados', headers: 'Encabezados',
timeout: 'Tiempo de espera', timeout: 'Tiempo de espera',
toolCallTimeout: 'Tiempo de espera de herramienta (segundos)',
toolCallTimeoutDescription:
'Espera máxima para una llamada de herramienta. Use 0 para no limitar. El valor predeterminado es 300 segundos.',
addArgument: 'Añadir argumento', addArgument: 'Añadir argumento',
addEnvVar: 'Añadir variable de entorno', addEnvVar: 'Añadir variable de entorno',
addHeader: 'Añadir encabezado', addHeader: 'Añadir encabezado',
+3
View File
@@ -803,6 +803,9 @@ const jaJP = {
url: 'URL', url: 'URL',
headers: 'ヘッダー', headers: 'ヘッダー',
timeout: 'タイムアウト', timeout: 'タイムアウト',
toolCallTimeout: 'ツール呼び出しタイムアウト(秒)',
toolCallTimeoutDescription:
'1 回のツール呼び出しの最大待機時間です。0 で無制限、既定値は 300 秒です。',
addArgument: '引数を追加', addArgument: '引数を追加',
addEnvVar: '環境変数を追加', addEnvVar: '環境変数を追加',
addHeader: 'ヘッダーを追加', addHeader: 'ヘッダーを追加',
+3
View File
@@ -808,6 +808,9 @@ const ruRU = {
url: 'URL', url: 'URL',
headers: 'Заголовки', headers: 'Заголовки',
timeout: 'Таймаут', timeout: 'Таймаут',
toolCallTimeout: 'Таймаут вызова инструмента (секунды)',
toolCallTimeoutDescription:
'Максимальное ожидание одного вызова. 0 отключает ограничение. По умолчанию 300 секунд.',
addArgument: 'Добавить аргумент', addArgument: 'Добавить аргумент',
addEnvVar: 'Добавить переменную окружения', addEnvVar: 'Добавить переменную окружения',
addHeader: 'Добавить заголовок', addHeader: 'Добавить заголовок',
+3
View File
@@ -786,6 +786,9 @@ const thTH = {
url: 'URL', url: 'URL',
headers: 'ส่วนหัว', headers: 'ส่วนหัว',
timeout: 'หมดเวลา', timeout: 'หมดเวลา',
toolCallTimeout: 'หมดเวลาการเรียกเครื่องมือ (วินาที)',
toolCallTimeoutDescription:
'เวลารอสูงสุดต่อการเรียกเครื่องมือหนึ่งครั้ง ตั้งเป็น 0 เพื่อไม่จำกัด ค่าเริ่มต้นคือ 300 วินาที',
addArgument: 'เพิ่มอาร์กิวเมนต์', addArgument: 'เพิ่มอาร์กิวเมนต์',
addEnvVar: 'เพิ่มตัวแปรสภาพแวดล้อม', addEnvVar: 'เพิ่มตัวแปรสภาพแวดล้อม',
addHeader: 'เพิ่มส่วนหัว', addHeader: 'เพิ่มส่วนหัว',
+3
View File
@@ -801,6 +801,9 @@ const viVN = {
url: 'URL', url: 'URL',
headers: 'Tiêu đề', headers: 'Tiêu đề',
timeout: 'Thời gian chờ', timeout: 'Thời gian chờ',
toolCallTimeout: 'Thời gian chờ gọi công cụ (giây)',
toolCallTimeoutDescription:
'Thời gian chờ tối đa cho một lần gọi công cụ. Đặt 0 để không giới hạn. Mặc định là 300 giây.',
addArgument: 'Thêm tham số', addArgument: 'Thêm tham số',
addEnvVar: 'Thêm biến môi trường', addEnvVar: 'Thêm biến môi trường',
addHeader: 'Thêm tiêu đề', addHeader: 'Thêm tiêu đề',
+3
View File
@@ -763,6 +763,9 @@ const zhHans = {
url: 'URL地址', url: 'URL地址',
headers: '请求头', headers: '请求头',
timeout: '超时时间', timeout: '超时时间',
toolCallTimeout: '工具调用超时(秒)',
toolCallTimeoutDescription:
'单次工具调用的最长等待时间。设为 0 表示不限制,默认 300 秒。',
addArgument: '添加参数', addArgument: '添加参数',
addEnvVar: '添加环境变量', addEnvVar: '添加环境变量',
addHeader: '添加请求头', addHeader: '添加请求头',
+3
View File
@@ -762,6 +762,9 @@ const zhHant = {
url: 'URL位址', url: 'URL位址',
headers: '請求標頭', headers: '請求標頭',
timeout: '逾時時間', timeout: '逾時時間',
toolCallTimeout: '工具呼叫逾時(秒)',
toolCallTimeoutDescription:
'單次工具呼叫的最長等待時間。設為 0 表示不限制,預設 300 秒。',
addArgument: '新增參數', addArgument: '新增參數',
addEnvVar: '新增環境變數', addEnvVar: '新增環境變數',
addHeader: '新增請求標頭', addHeader: '新增請求標頭',