mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-07 01:57:15 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5208066df2 | |||
| 14ae46d178 | |||
| f3b5fcfb7c | |||
| ffabb91bfe | |||
| 96c84740db | |||
| 85b5b5b54b | |||
| 28bffdef21 | |||
| bf3c96026b | |||
| ed9343c686 | |||
| 9a8cdde86c | |||
| a2655e16f8 |
+1
-1
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.7",
|
"langbot-plugin==0.4.10",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
|
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/servers/<path:server_name>/logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(server_name: str) -> str:
|
||||||
|
"""Get logs from an MCP server"""
|
||||||
|
server_name = unquote(server_name)
|
||||||
|
try:
|
||||||
|
limit = int(quart.request.args.get('limit', 200))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 200
|
||||||
|
limit = min(limit, 500)
|
||||||
|
level = quart.request.args.get('level') or None
|
||||||
|
logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
|
||||||
|
return self.success(data={'logs': logs})
|
||||||
|
|
||||||
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""Read a resource from an MCP server"""
|
"""Read a resource from an MCP server"""
|
||||||
|
|||||||
@@ -188,10 +188,22 @@ class MCPService:
|
|||||||
persisted_session = runtime_mcp_session
|
persisted_session = runtime_mcp_session
|
||||||
|
|
||||||
async def _refresh_and_report() -> None:
|
async def _refresh_and_report() -> None:
|
||||||
if persisted_session.status == MCPSessionStatus.ERROR:
|
# Testing a persisted server should REUSE its live shared-session
|
||||||
|
# process, not rebuild it. Try a lightweight refresh (a real
|
||||||
|
# list_tools probe over the existing connection) first; only fall
|
||||||
|
# back to a full start() when the session has no live connection
|
||||||
|
# to probe (never connected, or the process is actually gone).
|
||||||
|
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||||
|
if needs_start:
|
||||||
await persisted_session.start()
|
await persisted_session.start()
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
await persisted_session.refresh()
|
await persisted_session.refresh()
|
||||||
|
except Exception:
|
||||||
|
# The live connection was stale/dropped: reconnect once
|
||||||
|
# (reusing the live managed process where possible) and
|
||||||
|
# re-probe, instead of reporting a false failure.
|
||||||
|
await persisted_session.start()
|
||||||
# Surface the discovered tools so the config page can render them
|
# Surface the discovered tools so the config page can render them
|
||||||
# even for an already-hosted server.
|
# even for an already-hosted server.
|
||||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||||
@@ -232,3 +244,19 @@ class MCPService:
|
|||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
return wrapper.id
|
return wrapper.id
|
||||||
|
|
||||||
|
async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
|
||||||
|
"""Get recent log lines captured from the MCP server's stderr."""
|
||||||
|
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||||
|
if not session:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get logs from the session's buffer
|
||||||
|
logs = list(session._log_buffer)
|
||||||
|
|
||||||
|
# Filter by level if specified
|
||||||
|
if level:
|
||||||
|
logs = [log for log in logs if log.get('level') == level]
|
||||||
|
|
||||||
|
# Return the most recent 'limit' logs
|
||||||
|
return logs[-limit:]
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from ....core import app
|
|||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
from ....entity.persistence import mcp as persistence_mcp
|
from ....entity.persistence import mcp as persistence_mcp
|
||||||
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase # noqa: F401
|
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
|
||||||
|
|
||||||
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
||||||
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
||||||
@@ -185,6 +185,16 @@ class MCPSessionStatus(enum.Enum):
|
|||||||
ERROR = 'error'
|
ERROR = 'error'
|
||||||
|
|
||||||
|
|
||||||
|
class _TransportReconnect(Exception):
|
||||||
|
"""Internal signal: the Box stdio WS transport dropped but the managed
|
||||||
|
process is still alive. Triggers a lightweight transport reconnect that
|
||||||
|
reuses the live process, instead of a full process rebuild.
|
||||||
|
|
||||||
|
Reconnect attempts are NOT counted toward the fatal retry budget, so a
|
||||||
|
long-lived session can survive arbitrarily many transient drops.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class RuntimeMCPSession:
|
class RuntimeMCPSession:
|
||||||
"""运行时 MCP 会话"""
|
"""运行时 MCP 会话"""
|
||||||
|
|
||||||
@@ -254,6 +264,16 @@ class RuntimeMCPSession:
|
|||||||
self._lifecycle_task = None
|
self._lifecycle_task = None
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
self._ready_event = asyncio.Event()
|
self._ready_event = asyncio.Event()
|
||||||
|
# Set transiently when a WS transport drop should NOT stop the managed
|
||||||
|
# process (it will be re-attached on the next initialize()).
|
||||||
|
self._preserve_managed_process = False
|
||||||
|
|
||||||
|
# Log buffer for capturing stderr from Box managed process (maxlen=500 keeps
|
||||||
|
# recent lines without unbounded memory growth)
|
||||||
|
import collections as _collections
|
||||||
|
|
||||||
|
self._log_buffer: _collections.deque = _collections.deque(maxlen=500)
|
||||||
|
self._last_stderr_text: str = ''
|
||||||
|
|
||||||
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
|
||||||
@@ -399,11 +419,39 @@ class RuntimeMCPSession:
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
for task in done:
|
for task in done:
|
||||||
if task is monitor_task and not self._shutdown_event.is_set():
|
if task is monitor_task and not self._shutdown_event.is_set():
|
||||||
|
# The monitor completed. This is EITHER the managed
|
||||||
|
# process actually exiting OR just the WS transport
|
||||||
|
# dropping while the process stays alive in the Box
|
||||||
|
# runtime. Re-check the real process state so a
|
||||||
|
# transient transport drop reconnects (reusing the live
|
||||||
|
# process) instead of tearing the process down and
|
||||||
|
# running a full rebuild+backoff cycle.
|
||||||
|
process_still_running = False
|
||||||
|
try:
|
||||||
|
process_still_running = await self._box_stdio_runtime._managed_process_is_running()
|
||||||
|
except Exception:
|
||||||
|
process_still_running = False
|
||||||
|
if process_still_running:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP server {self.server_name}: transport dropped but '
|
||||||
|
f'managed process is still running; reconnecting transport'
|
||||||
|
)
|
||||||
|
self.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
||||||
|
# Preserve the live process across the finally-block
|
||||||
|
# cleanup: only the WS transport should be torn down.
|
||||||
|
self._preserve_managed_process = True
|
||||||
|
raise _TransportReconnect('Box managed process transport dropped; reconnecting')
|
||||||
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
||||||
raise Exception('Box managed process exited unexpectedly')
|
raise Exception('Box managed process exited unexpectedly')
|
||||||
else:
|
else:
|
||||||
await self._shutdown_event.wait()
|
await self._shutdown_event.wait()
|
||||||
|
|
||||||
|
except _ColdStartRetry:
|
||||||
|
# Cold-start in progress: set the preserve flag BEFORE the finally
|
||||||
|
# block runs so it does not stop the live managed process. The outer
|
||||||
|
# _lifecycle_loop_with_retry will reuse it on the next attempt.
|
||||||
|
self._preserve_managed_process = True
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status = MCPSessionStatus.ERROR
|
self.status = MCPSessionStatus.ERROR
|
||||||
self.error_message = str(e)
|
self.error_message = str(e)
|
||||||
@@ -424,14 +472,55 @@ class RuntimeMCPSession:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||||
finally:
|
finally:
|
||||||
|
# On a transport-only reconnect the managed process is healthy
|
||||||
|
# and will be re-attached on the next initialize(); do NOT stop
|
||||||
|
# it. Any other exit path fully tears the session down.
|
||||||
|
if getattr(self, '_preserve_managed_process', False):
|
||||||
|
self._preserve_managed_process = False
|
||||||
|
else:
|
||||||
await self._cleanup_box_stdio_session()
|
await self._cleanup_box_stdio_session()
|
||||||
|
|
||||||
async def _lifecycle_loop_with_retry(self):
|
async def _lifecycle_loop_with_retry(self):
|
||||||
"""Wrap _lifecycle_loop with retry and exponential backoff."""
|
"""Wrap _lifecycle_loop with retry and exponential backoff."""
|
||||||
for attempt in range(self._MAX_RETRIES + 1):
|
attempt = 0
|
||||||
|
while attempt <= self._MAX_RETRIES:
|
||||||
try:
|
try:
|
||||||
await self._lifecycle_loop()
|
await self._lifecycle_loop()
|
||||||
return # Normal shutdown, don't retry
|
return # Normal shutdown, don't retry
|
||||||
|
except _TransportReconnect as e:
|
||||||
|
# Transient WS transport drop while the managed process is still
|
||||||
|
# alive. Reconnect promptly WITHOUT consuming the fatal retry
|
||||||
|
# budget and WITHOUT stopping the process — initialize() will
|
||||||
|
# re-attach to the live process. This is what lets a long-lived
|
||||||
|
# stdio MCP survive repeated brief event-loop stalls / pings.
|
||||||
|
if self._shutdown_event.is_set():
|
||||||
|
return
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP session {self.server_name}: reconnecting transport ({self._describe_exception(e)})'
|
||||||
|
)
|
||||||
|
self.status = MCPSessionStatus.CONNECTING
|
||||||
|
self.error_message = None
|
||||||
|
self.error_phase = None
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
except _ColdStartRetry as e:
|
||||||
|
# The managed process is alive but still cold-starting (e.g.
|
||||||
|
# `npx -y <pkg>` is still installing) and cannot yet answer the
|
||||||
|
# handshake. Reuse the live process and retry the attach WITHOUT
|
||||||
|
# consuming the fatal retry budget or stopping the process, so a
|
||||||
|
# slow cold start is waited out instead of failing. Preserve the
|
||||||
|
# process across the finally-block cleanup.
|
||||||
|
if self._shutdown_event.is_set():
|
||||||
|
return
|
||||||
|
self._preserve_managed_process = True
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'MCP session {self.server_name}: waiting for cold start ({self._describe_exception(e)})'
|
||||||
|
)
|
||||||
|
self.status = MCPSessionStatus.CONNECTING
|
||||||
|
self.error_message = None
|
||||||
|
self.error_phase = None
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.retry_count = attempt + 1
|
self.retry_count = attempt + 1
|
||||||
if self._shutdown_event.is_set():
|
if self._shutdown_event.is_set():
|
||||||
@@ -460,6 +549,7 @@ class RuntimeMCPSession:
|
|||||||
self.error_message = None
|
self.error_message = None
|
||||||
self.error_phase = None
|
self.error_phase = None
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _describe_exception(exc: BaseException) -> str:
|
def _describe_exception(exc: BaseException) -> str:
|
||||||
@@ -927,11 +1017,14 @@ class RuntimeMCPSession:
|
|||||||
return self._box_stdio_runtime.uses_box_stdio()
|
return self._box_stdio_runtime.uses_box_stdio()
|
||||||
|
|
||||||
def _build_box_session_id(self) -> str:
|
def _build_box_session_id(self) -> str:
|
||||||
# Transient test sessions get their own isolated Box session so a
|
# Both live servers and transient config-page tests share ONE Box
|
||||||
# failing/short-lived test can never disturb the shared session that
|
# session ('mcp-shared'). A test therefore reuses the already-running
|
||||||
# hosts live, already-connected MCP servers.
|
# container (and, for an existing server, its live managed process)
|
||||||
if self.is_transient:
|
# instead of paying a full per-test session cold-start + dependency
|
||||||
return f'mcp-test-{self.server_uuid}'
|
# bootstrap. Isolation between a test and the live servers is provided
|
||||||
|
# at the *process* level: each server/test has its own process_id and a
|
||||||
|
# test only ever stops its own process_id (see cleanup_session), so it
|
||||||
|
# never disturbs another server's process or the shared session itself.
|
||||||
return 'mcp-shared'
|
return 'mcp-shared'
|
||||||
|
|
||||||
def _rewrite_path(self, path: str, host_path: str | None) -> str:
|
def _rewrite_path(self, path: str, host_path: str | None) -> str:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import shlex
|
import shlex
|
||||||
import threading
|
import threading
|
||||||
from contextlib import suppress
|
from contextlib import suppress, AsyncExitStack
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -74,6 +74,35 @@ class MCPServerBoxConfig(pydantic.BaseModel):
|
|||||||
model_config = pydantic.ConfigDict(extra='ignore')
|
model_config = pydantic.ConfigDict(extra='ignore')
|
||||||
|
|
||||||
|
|
||||||
|
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class _TransferredStack:
|
||||||
|
"""Adapts an already-populated AsyncExitStack into an async context manager
|
||||||
|
so ownership of its resources can be transferred into another exit stack.
|
||||||
|
Entering is a no-op; exiting closes the wrapped stack (and thus the live WS
|
||||||
|
transport + ClientSession) when the owning session shuts down."""
|
||||||
|
|
||||||
|
def __init__(self, stack: AsyncExitStack):
|
||||||
|
self._stack = stack
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
await self._stack.aclose()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _ColdStartRetry(Exception):
|
||||||
|
"""Signal: the managed process is alive but not yet answering the MCP
|
||||||
|
handshake because it is still cold-starting (e.g. `npx -y <pkg>` is still
|
||||||
|
installing). The outer lifecycle retry treats this like a transient
|
||||||
|
reconnect: it reuses the live process and does not count toward the fatal
|
||||||
|
retry budget, so a slow cold start is waited out rather than failing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BoxStdioSessionRuntime:
|
class BoxStdioSessionRuntime:
|
||||||
"""Encapsulate Box-backed stdio MCP session orchestration."""
|
"""Encapsulate Box-backed stdio MCP session orchestration."""
|
||||||
|
|
||||||
@@ -113,7 +142,15 @@ class BoxStdioSessionRuntime:
|
|||||||
read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False,
|
read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False,
|
||||||
image=self.config.image,
|
image=self.config.image,
|
||||||
cpus=self.config.cpus,
|
cpus=self.config.cpus,
|
||||||
memory_mb=self.config.memory_mb,
|
# Node.js runtimes (npx/bunx) reserve large virtual address space and
|
||||||
|
# load WebAssembly modules (llhttp) on startup; the default 512 MB
|
||||||
|
# cgroup_mem_max is too small and causes OOM kills (return_code=137).
|
||||||
|
# Auto-bump to 1024 MB when the runner is npx/bunx/pnpm dlx.
|
||||||
|
memory_mb=(
|
||||||
|
(self.config.memory_mb or 1024)
|
||||||
|
if self.server_config.get('command', '') in ('npx', 'bunx', 'pnpm')
|
||||||
|
else self.config.memory_mb
|
||||||
|
),
|
||||||
pids_limit=self.config.pids_limit,
|
pids_limit=self.config.pids_limit,
|
||||||
persistent=True,
|
persistent=True,
|
||||||
)
|
)
|
||||||
@@ -173,6 +210,12 @@ class BoxStdioSessionRuntime:
|
|||||||
stderr_preview = (result.stderr or '')[:500]
|
stderr_preview = (result.stderr or '')[:500]
|
||||||
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
||||||
|
|
||||||
|
# Reuse an already-running managed process instead of rebuilding it.
|
||||||
|
# The Box runtime keeps the managed process alive across a transient
|
||||||
|
# WebSocket transport drop, so on a reconnect we only need to re-attach
|
||||||
|
# the WS below. Rebuilding here would needlessly stop a healthy process
|
||||||
|
# and re-run the (slow, network-touching) dependency bootstrap.
|
||||||
|
if not await self._managed_process_is_running():
|
||||||
try:
|
try:
|
||||||
process_workspace = (
|
process_workspace = (
|
||||||
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
||||||
@@ -192,9 +235,30 @@ class BoxStdioSessionRuntime:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||||
raise
|
raise
|
||||||
|
else:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP server {self.server_name}: reusing live managed process '
|
||||||
|
f'process_id={self.process_id} (transport reconnect)'
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
||||||
|
|
||||||
|
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
|
||||||
|
# in the same task as the serve loop that follows. websocket_client and
|
||||||
|
# ClientSession use anyio task groups whose cancel scope is bound to the
|
||||||
|
# frame/stack that entered them, so they must live on the owner exit
|
||||||
|
# stack (not a deferred/transferred one) or the streams close the moment
|
||||||
|
# initialize() returns and the next request fails with "Connection
|
||||||
|
# closed".
|
||||||
|
#
|
||||||
|
# A slow (`npx -y <pkg>`) cold start makes this single attempt fail
|
||||||
|
# while the process is still alive — the package is still installing and
|
||||||
|
# cannot answer the handshake. We surface that to the outer retry loop
|
||||||
|
# as a _ColdStartRetry: it must NOT stop the process (it is healthy and
|
||||||
|
# will be reused) and must NOT consume the fatal retry budget. The next
|
||||||
|
# attempt re-attaches to the same live process; once it has finished
|
||||||
|
# cold start the handshake succeeds and stays healthy.
|
||||||
|
try:
|
||||||
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
||||||
read_stream, write_stream = transport
|
read_stream, write_stream = transport
|
||||||
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
||||||
@@ -202,12 +266,19 @@ class BoxStdioSessionRuntime:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
||||||
|
if not await self._managed_process_has_exited():
|
||||||
|
# Process is alive but not yet serving (cold start) — reconnect.
|
||||||
|
raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.owner.session.initialize()
|
await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
||||||
|
if not await self._managed_process_has_exited():
|
||||||
|
raise _ColdStartRetry(
|
||||||
|
f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})'
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def monitor_process_health(self) -> None:
|
async def monitor_process_health(self) -> None:
|
||||||
@@ -234,8 +305,74 @@ class BoxStdioSessionRuntime:
|
|||||||
)
|
)
|
||||||
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
|
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Capture stderr logs from the managed process
|
||||||
|
if isinstance(info, dict):
|
||||||
|
stderr_text = info.get('stderr', '') or info.get('stderr_preview', '')
|
||||||
|
else:
|
||||||
|
stderr_text = getattr(info, 'stderr', '') or getattr(info, 'stderr_preview', '')
|
||||||
|
|
||||||
|
if stderr_text and stderr_text != self.owner._last_stderr_text:
|
||||||
|
# Find new lines not in the previous snapshot
|
||||||
|
old_lines = set(self.owner._last_stderr_text.splitlines()) if self.owner._last_stderr_text else set()
|
||||||
|
new_lines = [l for l in stderr_text.splitlines() if l and l not in old_lines]
|
||||||
|
self.owner._last_stderr_text = stderr_text
|
||||||
|
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
for line in new_lines:
|
||||||
|
level = (
|
||||||
|
'error'
|
||||||
|
if any(k in line.upper() for k in ('ERROR', 'CRITICAL'))
|
||||||
|
else 'warning'
|
||||||
|
if 'WARNING' in line.upper()
|
||||||
|
else 'debug'
|
||||||
|
if 'DEBUG' in line.upper()
|
||||||
|
else 'info'
|
||||||
|
)
|
||||||
|
self.owner._log_buffer.append({'ts': _time.time(), 'level': level, 'text': line})
|
||||||
|
|
||||||
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
|
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
|
||||||
|
|
||||||
|
async def _managed_process_is_running(self) -> bool:
|
||||||
|
"""Return True if this server's managed process exists and is running.
|
||||||
|
|
||||||
|
Used to decide whether initialize() must (re)start the process or can
|
||||||
|
simply re-attach the WebSocket transport to a process the Box runtime
|
||||||
|
kept alive across a transient transport drop.
|
||||||
|
"""
|
||||||
|
from langbot_plugin.box.models import BoxManagedProcessStatus
|
||||||
|
|
||||||
|
workspace = self._build_workspace()
|
||||||
|
try:
|
||||||
|
info = await workspace.get_managed_process(self.process_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
|
||||||
|
return status in (BoxManagedProcessStatus.RUNNING.value, BoxManagedProcessStatus.RUNNING)
|
||||||
|
|
||||||
|
async def _managed_process_has_exited(self) -> bool:
|
||||||
|
"""Return True only if the process is DEFINITIVELY gone (reports EXITED).
|
||||||
|
|
||||||
|
Distinct from ``not _managed_process_is_running()``: a process that has
|
||||||
|
just been spawned may not yet report RUNNING, and a transient query
|
||||||
|
error is not proof of exit. During the cold-start handshake retry we
|
||||||
|
must NOT treat 'not yet running' or 'query failed' as a terminal
|
||||||
|
failure, or we bail out to the outer rebuild path and churn the
|
||||||
|
process (relay then rejects the early re-attach with HTTP 400). Only a
|
||||||
|
successful query that reports EXITED stops the retry loop.
|
||||||
|
"""
|
||||||
|
from langbot_plugin.box.models import BoxManagedProcessStatus
|
||||||
|
|
||||||
|
workspace = self._build_workspace()
|
||||||
|
try:
|
||||||
|
info = await workspace.get_managed_process(self.process_id)
|
||||||
|
except Exception:
|
||||||
|
# Unknown — treat as 'still coming up', not exited.
|
||||||
|
return False
|
||||||
|
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
|
||||||
|
return status in (BoxManagedProcessStatus.EXITED.value, BoxManagedProcessStatus.EXITED)
|
||||||
|
|
||||||
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
|
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
|
||||||
source_path = normalize_host_path(host_path)
|
source_path = normalize_host_path(host_path)
|
||||||
if not source_path:
|
if not source_path:
|
||||||
@@ -342,16 +479,20 @@ class BoxStdioSessionRuntime:
|
|||||||
|
|
||||||
workspace = self._build_workspace(host_path=None)
|
workspace = self._build_workspace(host_path=None)
|
||||||
|
|
||||||
# Transient test sessions own their isolated Box session, so tear the
|
# Transient config-page tests now share the same 'mcp-shared' Box
|
||||||
# whole session down rather than leaking it. This cannot affect live
|
# session as live servers, so we must NOT tear the session down here —
|
||||||
# servers because they live in the separate shared session.
|
# that would kill every other MCP server in the container. A test is
|
||||||
|
# isolated at the process level: it ran under its own process_id, so we
|
||||||
|
# stop only that process, exactly like a live server does below. The
|
||||||
|
# shared session and all other servers' live processes are untouched.
|
||||||
|
# (Staged per-test workspace files are still cleaned up.)
|
||||||
if getattr(self.owner, 'is_transient', False):
|
if getattr(self.owner, 'is_transient', False):
|
||||||
try:
|
try:
|
||||||
await workspace.cleanup()
|
await workspace.stop_managed_process(self.process_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
f'MCP server {self.server_name}: failed to delete transient test session '
|
f'MCP server {self.server_name}: failed to stop transient test process '
|
||||||
f'{self.owner._build_box_session_id()}: {type(exc).__name__}: {exc}'
|
f'process_id={self.process_id}: {type(exc).__name__}: {exc}'
|
||||||
)
|
)
|
||||||
await self._cleanup_staged_workspace()
|
await self._cleanup_staged_workspace()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -639,10 +639,13 @@ class TestGetRuntimeInfoDict:
|
|||||||
assert info['box_session_id'] == 'mcp-shared'
|
assert info['box_session_id'] == 'mcp-shared'
|
||||||
assert info['box_enabled'] is True
|
assert info['box_enabled'] is True
|
||||||
|
|
||||||
def test_transient_test_session_is_isolated_from_shared(self, mcp_module):
|
def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module):
|
||||||
"""A transient test session (config-page "test", no persisted UUID)
|
"""A transient config-page "test" now shares the same 'mcp-shared' Box
|
||||||
must NOT share the live "mcp-shared" Box session. Regression: a failing
|
session as live servers (so a test reuses the running container / live
|
||||||
test churned the shared session and tore down healthy live servers."""
|
process instead of a cold per-test session bootstrap). Isolation is at
|
||||||
|
the PROCESS level: the test runs under its own process_id and only ever
|
||||||
|
stops that process_id, so it cannot disturb another server's live
|
||||||
|
process or the shared session itself."""
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.box_service.available = True
|
ap.box_service.available = True
|
||||||
transient = _make_session(
|
transient = _make_session(
|
||||||
@@ -670,10 +673,12 @@ class TestGetRuntimeInfoDict:
|
|||||||
)
|
)
|
||||||
assert transient.is_transient is True
|
assert transient.is_transient is True
|
||||||
assert live.is_transient is False
|
assert live.is_transient is False
|
||||||
# Isolated session id for the test, shared for the live server.
|
# Both share ONE Box session ...
|
||||||
assert transient._build_box_session_id() == 'mcp-test-gen-uuid-123'
|
assert transient._build_box_session_id() == 'mcp-shared'
|
||||||
assert live._build_box_session_id() == 'mcp-shared'
|
assert live._build_box_session_id() == 'mcp-shared'
|
||||||
assert transient._build_box_session_id() != live._build_box_session_id()
|
assert transient._build_box_session_id() == live._build_box_session_id()
|
||||||
|
# ... but are isolated by distinct process_ids within that session.
|
||||||
|
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
|
||||||
|
|
||||||
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
|
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
|
||||||
"""Policy: when Box is configured but unavailable (disabled in config
|
"""Policy: when Box is configured but unavailable (disabled in config
|
||||||
@@ -824,3 +829,129 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
|
|||||||
assert process_payload['command'] == 'python'
|
assert process_payload['command'] == 'python'
|
||||||
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
|
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
|
||||||
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
|
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch):
|
||||||
|
"""During a slow (npx) cold start the handshake fails while the managed
|
||||||
|
process is still alive. initialize() must raise _ColdStartRetry (so the
|
||||||
|
outer lifecycle loop reuses the live process and retries without stopping it
|
||||||
|
or consuming the fatal budget), NOT a fatal error."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||||
|
|
||||||
|
class ColdClientSession:
|
||||||
|
def __init__(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
# Process still cold-starting: handshake fails.
|
||||||
|
raise Exception('Connection closed')
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_websocket_client(_url: str):
|
||||||
|
yield ('read-stream', 'write-stream')
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession)
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
|
||||||
|
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.box_service.available = True
|
||||||
|
ap.box_service.create_session = AsyncMock(return_value={})
|
||||||
|
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
||||||
|
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
|
||||||
|
|
||||||
|
session = _make_session(
|
||||||
|
mcp_module,
|
||||||
|
{
|
||||||
|
'name': 'slow',
|
||||||
|
'uuid': 'slow-uuid',
|
||||||
|
'mode': 'stdio',
|
||||||
|
'command': 'npx',
|
||||||
|
'args': ['-y', 'some-mcp'],
|
||||||
|
},
|
||||||
|
ap=ap,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process is NOT exited (still cold-starting) and not yet running for reuse.
|
||||||
|
async def _not_exited():
|
||||||
|
return False
|
||||||
|
|
||||||
|
session._box_stdio_runtime._managed_process_has_exited = _not_exited
|
||||||
|
|
||||||
|
async def _not_running():
|
||||||
|
return False
|
||||||
|
|
||||||
|
session._box_stdio_runtime._managed_process_is_running = _not_running
|
||||||
|
|
||||||
|
with pytest.raises(mcp_stdio_module._ColdStartRetry):
|
||||||
|
await session._init_box_stdio_server()
|
||||||
|
|
||||||
|
# Process was started exactly once (the retry will reuse it, not rebuild).
|
||||||
|
assert ap.box_service.start_managed_process.await_count == 1
|
||||||
|
await session.exit_stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch):
|
||||||
|
"""If the handshake fails AND the process has definitively exited, that is a
|
||||||
|
real failure — initialize() must NOT swallow it as a cold-start retry."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||||
|
|
||||||
|
class DeadClientSession:
|
||||||
|
def __init__(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
raise Exception('Connection closed')
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_websocket_client(_url: str):
|
||||||
|
yield ('read-stream', 'write-stream')
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession)
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
|
||||||
|
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.box_service.available = True
|
||||||
|
ap.box_service.create_session = AsyncMock(return_value={})
|
||||||
|
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
||||||
|
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
|
||||||
|
|
||||||
|
session = _make_session(
|
||||||
|
mcp_module,
|
||||||
|
{'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']},
|
||||||
|
ap=ap,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _exited():
|
||||||
|
return True
|
||||||
|
|
||||||
|
session._box_stdio_runtime._managed_process_has_exited = _exited
|
||||||
|
|
||||||
|
async def _not_running():
|
||||||
|
return False
|
||||||
|
|
||||||
|
session._box_stdio_runtime._managed_process_is_running = _not_running
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as ei:
|
||||||
|
await session._init_box_stdio_server()
|
||||||
|
assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry)
|
||||||
|
await session.exit_stack.aclose()
|
||||||
|
|||||||
@@ -2123,7 +2123,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.4.7" },
|
{ name = "langbot-plugin", specifier = "==0.4.9" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2187,7 +2187,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.4.7"
|
version = "0.4.9"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2208,9 +2208,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/00/6c/850a92cc05583ab71e6ac527ff50fa5f3db43cc16e86f4f6d5ac684da9a8/langbot_plugin-0.4.7.tar.gz", hash = "sha256:16b24d79fc55c0a6b15d901b1134de9e298e2cadab9a9b25c9231f731333ebe9", size = 333867, upload-time = "2026-07-02T16:49:46.258Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/54/52/f85939d3be929696dc5f9f3369f18bd6652981a8985c6e6f0dfe48e04f75/langbot_plugin-0.4.9.tar.gz", hash = "sha256:1cc2882f1be96cd52be6c392c3925e44cf785c97c10f82a321339636da318c6e", size = 334682, upload-time = "2026-07-03T04:57:42.813Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/f5/a00a929ffb4dcbd958e5f5249516f19ecc720f25f072c9bfda8bbb56851c/langbot_plugin-0.4.7-py3-none-any.whl", hash = "sha256:1364f80fcf448f4503e87d1367af60438abd2cf16e78be6807032cbdb4710a56", size = 220999, upload-time = "2026-07-02T16:49:44.868Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/12/1cf535377a81cf01607bbee5e3f4745a8c9f6dbb561887467001a182a533/langbot_plugin-0.4.9-py3-none-any.whl", hash = "sha256:693225a089ba38bc8d0b58d9e0fdf07b027b5be6b5e858cf7b4e56d095b68ba5", size = 221722, upload-time = "2026-07-03T04:57:41.578Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -264,6 +264,48 @@ function saveListExpansionState(state: SidebarListExpansionState) {
|
|||||||
|
|
||||||
// Maximum number of entity sub-items visible before "More" toggle
|
// Maximum number of entity sub-items visible before "More" toggle
|
||||||
const MAX_VISIBLE_ITEMS = 5;
|
const MAX_VISIBLE_ITEMS = 5;
|
||||||
|
const MCP_REFRESH_POLL_INTERVAL_MS = 1000;
|
||||||
|
const MCP_REFRESH_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMCPRefreshTask(taskId: number) {
|
||||||
|
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const task = await httpClient.getAsyncTask(taskId);
|
||||||
|
if (task.runtime.done) return task;
|
||||||
|
await sleep(MCP_REFRESH_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for MCP refresh task ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshEnabledMCPConnections() {
|
||||||
|
const resp = await httpClient.getMCPServers();
|
||||||
|
const enabledServers = resp.servers.filter((server) => server.enable);
|
||||||
|
if (enabledServers.length === 0) return;
|
||||||
|
|
||||||
|
const taskResults = await Promise.allSettled(
|
||||||
|
enabledServers.map((server) => httpClient.testMCPServer(server.name, {})),
|
||||||
|
);
|
||||||
|
const taskIds: number[] = [];
|
||||||
|
|
||||||
|
for (const result of taskResults) {
|
||||||
|
if (
|
||||||
|
result.status === 'fulfilled' &&
|
||||||
|
typeof result.value.task_id === 'number'
|
||||||
|
) {
|
||||||
|
taskIds.push(result.value.task_id);
|
||||||
|
} else if (result.status === 'rejected') {
|
||||||
|
console.error('Failed to start MCP refresh task:', result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(taskIds.map(waitForMCPRefreshTask));
|
||||||
|
}
|
||||||
|
|
||||||
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
|
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
|
||||||
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
|
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
|
||||||
@@ -352,11 +394,19 @@ function NavItems({
|
|||||||
if (extRefreshing) return;
|
if (extRefreshing) return;
|
||||||
setExtRefreshing(true);
|
setExtRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
const results = await Promise.allSettled([
|
||||||
sidebarData.refreshPlugins(),
|
sidebarData.refreshPlugins(),
|
||||||
sidebarData.refreshMCPServers(),
|
|
||||||
sidebarData.refreshSkills(),
|
sidebarData.refreshSkills(),
|
||||||
|
refreshEnabledMCPConnections(),
|
||||||
]);
|
]);
|
||||||
|
const mcpRefreshResult = results[2];
|
||||||
|
if (mcpRefreshResult.status === 'rejected') {
|
||||||
|
console.error(
|
||||||
|
'Failed to refresh MCP connections:',
|
||||||
|
mcpRefreshResult.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await sidebarData.refreshMCPServers();
|
||||||
} finally {
|
} finally {
|
||||||
setExtRefreshing(false);
|
setExtRefreshing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePersistedTestComplete = useCallback(async () => {
|
||||||
|
await refreshMCPServers();
|
||||||
|
}, [refreshMCPServers]);
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
httpClient
|
httpClient
|
||||||
.deleteMCPServer(id)
|
.deleteMCPServer(id)
|
||||||
@@ -364,6 +368,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
onRuntimeInfoChange={(runtimeInfo) =>
|
onRuntimeInfoChange={(runtimeInfo) =>
|
||||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||||
}
|
}
|
||||||
|
onPersistedTestComplete={handlePersistedTestComplete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import MCPLogs from '@/app/home/mcp/components/mcp-form/MCPLogs';
|
||||||
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
|
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
|
||||||
import {
|
import {
|
||||||
MCPServerRuntimeInfo,
|
MCPServerRuntimeInfo,
|
||||||
@@ -487,6 +488,7 @@ interface MCPFormProps {
|
|||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onTestingChange?: (testing: boolean) => void;
|
onTestingChange?: (testing: boolean) => void;
|
||||||
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
||||||
|
onPersistedTestComplete?: (serverName: string) => void | Promise<void>;
|
||||||
/** Reported when the form cannot be saved because the current mode is
|
/** Reported when the form cannot be saved because the current mode is
|
||||||
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
|
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
|
||||||
* render the Save button outside this component should disable it. */
|
* render the Save button outside this component should disable it. */
|
||||||
@@ -511,6 +513,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
onTestingChange,
|
onTestingChange,
|
||||||
onRuntimeInfoChange,
|
onRuntimeInfoChange,
|
||||||
|
onPersistedTestComplete,
|
||||||
onSaveBlockedChange,
|
onSaveBlockedChange,
|
||||||
layout = 'stacked',
|
layout = 'stacked',
|
||||||
sideHeader,
|
sideHeader,
|
||||||
@@ -822,6 +825,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
// are always current.
|
// are always current.
|
||||||
const serverName =
|
const serverName =
|
||||||
isEditMode && initServerName ? initServerName : form.getValues('name');
|
isEditMode && initServerName ? initServerName : form.getValues('name');
|
||||||
|
const shouldTestPersistedServer =
|
||||||
|
isEditMode && !!initServerName && !form.formState.isDirty;
|
||||||
const formExtraArgs = form.getValues('extra_args') ?? [];
|
const formExtraArgs = form.getValues('extra_args') ?? [];
|
||||||
const formStdioArgs = form.getValues('args') ?? [];
|
const formStdioArgs = form.getValues('args') ?? [];
|
||||||
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
||||||
@@ -844,13 +849,21 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { task_id } = await httpClient.testMCPServer('_', {
|
const testTarget = shouldTestPersistedServer ? serverName : '_';
|
||||||
|
const testPayload = shouldTestPersistedServer
|
||||||
|
? {}
|
||||||
|
: ({
|
||||||
name: serverName,
|
name: serverName,
|
||||||
mode,
|
mode,
|
||||||
enable: true,
|
enable: true,
|
||||||
extra_args: extraArgsData,
|
extra_args: extraArgsData,
|
||||||
} as MCPServer);
|
} as MCPServer);
|
||||||
|
|
||||||
|
const { task_id } = await httpClient.testMCPServer(
|
||||||
|
testTarget,
|
||||||
|
testPayload,
|
||||||
|
);
|
||||||
|
|
||||||
if (!task_id) {
|
if (!task_id) {
|
||||||
throw new Error(t('mcp.noTaskId'));
|
throw new Error(t('mcp.noTaskId'));
|
||||||
}
|
}
|
||||||
@@ -875,14 +888,18 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
resource_count: 0,
|
resource_count: 0,
|
||||||
resources: [],
|
resources: [],
|
||||||
});
|
});
|
||||||
|
if (shouldTestPersistedServer) {
|
||||||
|
await onPersistedTestComplete?.(serverName);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isEditMode) {
|
if (shouldTestPersistedServer) {
|
||||||
await loadServerForEdit(serverName);
|
await loadServerForEdit(serverName);
|
||||||
|
await onPersistedTestComplete?.(serverName);
|
||||||
} else {
|
} else {
|
||||||
// Create mode has no persisted server to reload tools from.
|
// Transient tests have no persisted server to reload tools from.
|
||||||
// The backend stashes the discovered runtime info (status +
|
// The backend stashes the discovered runtime info (status +
|
||||||
// tools) in the test task's metadata before tearing the
|
// tools) in the task metadata before tearing the transient
|
||||||
// transient session down — surface it so a successful test
|
// session down — surface it so a successful test
|
||||||
// shows the tool list instead of "no tools found".
|
// shows the tool list instead of "no tools found".
|
||||||
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||||
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||||
@@ -1207,6 +1224,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
<TabsTrigger value="resources" className="flex-none px-4">
|
<TabsTrigger value="resources" className="flex-none px-4">
|
||||||
{resourcesTabLabel}
|
{resourcesTabLabel}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="logs" className="flex-none px-4">
|
||||||
|
{t('mcp.tabLogs')}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||||
<MCPReadme readme={readme} />
|
<MCPReadme readme={readme} />
|
||||||
@@ -1235,6 +1255,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="logs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{persistedServerName && <MCPLogs serverName={persistedServerName} />}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
runtimePanel
|
runtimePanel
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
|
||||||
|
|
||||||
|
function levelClassName(level: string): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'ERROR':
|
||||||
|
case 'CRITICAL':
|
||||||
|
return 'text-red-500';
|
||||||
|
case 'WARNING':
|
||||||
|
return 'text-amber-500';
|
||||||
|
case 'DEBUG':
|
||||||
|
return 'text-gray-400 dark:text-gray-500';
|
||||||
|
default:
|
||||||
|
return 'text-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MCPLogs({ serverName }: { serverName: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [level, setLevel] = useState<string>('ALL');
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const atBottomRef = useRef(true);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
httpClient
|
||||||
|
.getMcpServerLogs(serverName, 500, level === 'ALL' ? undefined : level)
|
||||||
|
.then((res) => {
|
||||||
|
setLogs(res.logs ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLogs([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [serverName, level]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
// Auto-refresh poll loop.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const timer = setInterval(fetchLogs, 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
|
// Keep view pinned to bottom when the user is already at the bottom.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el && atBottomRef.current) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1 pb-3 sm:px-6">
|
||||||
|
<Select value={level} onValueChange={setLevel}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LEVEL_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt === 'ALL' ? t('mcp.logsLevelAll') : opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={fetchLogs}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
{t('mcp.logsRefresh')}
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="mcp-logs-auto-refresh"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onCheckedChange={setAutoRefresh}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="mcp-logs-auto-refresh"
|
||||||
|
className="cursor-pointer text-sm font-normal text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t('mcp.logsAutoRefresh')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-3 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40 sm:px-6"
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('mcp.logsEmpty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
logs.map((entry, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.ts}-${idx}`}
|
||||||
|
className={`whitespace-pre-wrap break-all ${levelClassName(
|
||||||
|
entry.level,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{entry.text}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -671,6 +671,21 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMcpServerLogs(
|
||||||
|
serverName: string,
|
||||||
|
limit: number = 200,
|
||||||
|
level?: string,
|
||||||
|
): Promise<{ logs: PluginLogEntry[] }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
if (level) {
|
||||||
|
params.set('level', level);
|
||||||
|
}
|
||||||
|
return this.get(
|
||||||
|
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/logs?${params.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public getPluginAssetURL(
|
public getPluginAssetURL(
|
||||||
author: string,
|
author: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -828,6 +828,12 @@ const enUS = {
|
|||||||
tabTools: 'Tools',
|
tabTools: 'Tools',
|
||||||
tabResources: 'Resources',
|
tabResources: 'Resources',
|
||||||
tabDocs: 'Docs',
|
tabDocs: 'Docs',
|
||||||
|
tabLogs: 'Logs',
|
||||||
|
logsLevelAll: 'All levels',
|
||||||
|
logsRefresh: 'Refresh',
|
||||||
|
logsAutoRefresh: 'Auto refresh',
|
||||||
|
logsEmpty:
|
||||||
|
'No logs yet. Runtime logs from the MCP server will appear here.',
|
||||||
noReadme: 'No documentation available',
|
noReadme: 'No documentation available',
|
||||||
parseResultFailed: 'Failed to parse test result',
|
parseResultFailed: 'Failed to parse test result',
|
||||||
noResultReturned: 'Test returned no result',
|
noResultReturned: 'Test returned no result',
|
||||||
|
|||||||
@@ -842,6 +842,12 @@ const esES = {
|
|||||||
tabTools: 'Herramientas',
|
tabTools: 'Herramientas',
|
||||||
tabResources: 'Recursos',
|
tabResources: 'Recursos',
|
||||||
tabDocs: 'Documentación',
|
tabDocs: 'Documentación',
|
||||||
|
tabLogs: 'Registros',
|
||||||
|
logsLevelAll: 'Todos los niveles',
|
||||||
|
logsRefresh: 'Actualizar',
|
||||||
|
logsAutoRefresh: 'Actualización automática',
|
||||||
|
logsEmpty:
|
||||||
|
'Aún no hay registros. Los registros de ejecución del servidor MCP aparecerán aquí.',
|
||||||
noReadme: 'No hay documentación disponible',
|
noReadme: 'No hay documentación disponible',
|
||||||
parseResultFailed: 'Error al analizar el resultado de la prueba',
|
parseResultFailed: 'Error al analizar el resultado de la prueba',
|
||||||
noResultReturned: 'La prueba no devolvió resultados',
|
noResultReturned: 'La prueba no devolvió resultados',
|
||||||
|
|||||||
@@ -834,6 +834,11 @@ const jaJP = {
|
|||||||
tabTools: 'ツール',
|
tabTools: 'ツール',
|
||||||
tabResources: 'リソース',
|
tabResources: 'リソース',
|
||||||
tabDocs: 'ドキュメント',
|
tabDocs: 'ドキュメント',
|
||||||
|
tabLogs: 'ログ',
|
||||||
|
logsLevelAll: 'すべてのレベル',
|
||||||
|
logsRefresh: '更新',
|
||||||
|
logsAutoRefresh: '自動更新',
|
||||||
|
logsEmpty: 'ログはありません。MCPサーバーの実行ログがここに表示されます。',
|
||||||
noReadme: 'ドキュメントがありません',
|
noReadme: 'ドキュメントがありません',
|
||||||
parseResultFailed: 'テスト結果の解析に失敗しました',
|
parseResultFailed: 'テスト結果の解析に失敗しました',
|
||||||
noResultReturned: 'テスト結果が返されませんでした',
|
noResultReturned: 'テスト結果が返されませんでした',
|
||||||
|
|||||||
@@ -839,6 +839,12 @@ const ruRU = {
|
|||||||
tabTools: 'Инструменты',
|
tabTools: 'Инструменты',
|
||||||
tabResources: 'Ресурсы',
|
tabResources: 'Ресурсы',
|
||||||
tabDocs: 'Документация',
|
tabDocs: 'Документация',
|
||||||
|
tabLogs: 'Журнал',
|
||||||
|
logsLevelAll: 'Все уровни',
|
||||||
|
logsRefresh: 'Обновить',
|
||||||
|
logsAutoRefresh: 'Автообновление',
|
||||||
|
logsEmpty:
|
||||||
|
'Журналов пока нет. Здесь будут отображаться журналы выполнения MCP-сервера.',
|
||||||
noReadme: 'Документация отсутствует',
|
noReadme: 'Документация отсутствует',
|
||||||
parseResultFailed: 'Не удалось разобрать результат теста',
|
parseResultFailed: 'Не удалось разобрать результат теста',
|
||||||
noResultReturned: 'Тест не вернул результат',
|
noResultReturned: 'Тест не вернул результат',
|
||||||
|
|||||||
@@ -817,6 +817,11 @@ const thTH = {
|
|||||||
tabTools: 'เครื่องมือ',
|
tabTools: 'เครื่องมือ',
|
||||||
tabResources: 'ทรัพยากร',
|
tabResources: 'ทรัพยากร',
|
||||||
tabDocs: 'เอกสาร',
|
tabDocs: 'เอกสาร',
|
||||||
|
tabLogs: 'บันทึก',
|
||||||
|
logsLevelAll: 'ทุกระดับ',
|
||||||
|
logsRefresh: 'รีเฟรช',
|
||||||
|
logsAutoRefresh: 'รีเฟรชอัตโนมัติ',
|
||||||
|
logsEmpty: 'ยังไม่มีบันทึก บันทึกการทำงานของ MCP Server จะแสดงที่นี่',
|
||||||
noReadme: 'ไม่มีเอกสาร',
|
noReadme: 'ไม่มีเอกสาร',
|
||||||
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
|
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
|
||||||
noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา',
|
noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา',
|
||||||
|
|||||||
@@ -832,6 +832,12 @@ const viVN = {
|
|||||||
tabTools: 'Công cụ',
|
tabTools: 'Công cụ',
|
||||||
tabResources: 'Tài nguyên',
|
tabResources: 'Tài nguyên',
|
||||||
tabDocs: 'Tài liệu',
|
tabDocs: 'Tài liệu',
|
||||||
|
tabLogs: 'Nhật ký',
|
||||||
|
logsLevelAll: 'Tất cả cấp độ',
|
||||||
|
logsRefresh: 'Làm mới',
|
||||||
|
logsAutoRefresh: 'Tự động làm mới',
|
||||||
|
logsEmpty:
|
||||||
|
'Chưa có nhật ký. Nhật ký chạy của MCP Server sẽ hiển thị ở đây.',
|
||||||
noReadme: 'Không có tài liệu',
|
noReadme: 'Không có tài liệu',
|
||||||
parseResultFailed: 'Phân tích kết quả kiểm tra thất bại',
|
parseResultFailed: 'Phân tích kết quả kiểm tra thất bại',
|
||||||
noResultReturned: 'Kiểm tra không trả về kết quả',
|
noResultReturned: 'Kiểm tra không trả về kết quả',
|
||||||
|
|||||||
@@ -794,6 +794,11 @@ const zhHans = {
|
|||||||
tabTools: '工具',
|
tabTools: '工具',
|
||||||
tabResources: '资源',
|
tabResources: '资源',
|
||||||
tabDocs: '文档',
|
tabDocs: '文档',
|
||||||
|
tabLogs: '日志',
|
||||||
|
logsLevelAll: '全部级别',
|
||||||
|
logsRefresh: '刷新',
|
||||||
|
logsAutoRefresh: '自动刷新',
|
||||||
|
logsEmpty: '暂无日志。MCP 服务器的运行日志会显示在这里。',
|
||||||
noReadme: '暂无文档',
|
noReadme: '暂无文档',
|
||||||
parseResultFailed: '解析测试结果失败',
|
parseResultFailed: '解析测试结果失败',
|
||||||
noResultReturned: '测试未返回结果',
|
noResultReturned: '测试未返回结果',
|
||||||
|
|||||||
@@ -793,6 +793,11 @@ const zhHant = {
|
|||||||
tabTools: '工具',
|
tabTools: '工具',
|
||||||
tabResources: '資源',
|
tabResources: '資源',
|
||||||
tabDocs: '文件',
|
tabDocs: '文件',
|
||||||
|
tabLogs: '日誌',
|
||||||
|
logsLevelAll: '全部級別',
|
||||||
|
logsRefresh: '重新整理',
|
||||||
|
logsAutoRefresh: '自動重新整理',
|
||||||
|
logsEmpty: '暫無日誌。MCP 服務器的運行日誌會顯示在這裡。',
|
||||||
noReadme: '暫無文件',
|
noReadme: '暫無文件',
|
||||||
parseResultFailed: '解析測試結果失敗',
|
parseResultFailed: '解析測試結果失敗',
|
||||||
noResultReturned: '測試未返回結果',
|
noResultReturned: '測試未返回結果',
|
||||||
|
|||||||
Reference in New Issue
Block a user