mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-05 09:07:13 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fb4d27333 | |||
| c5d7e3dcb1 | |||
| cd6d5d9c2f | |||
| 1f7d9339fc | |||
| f3b5fcfb7c | |||
| ffabb91bfe | |||
| 96c84740db | |||
| 85b5b5b54b | |||
| 28bffdef21 | |||
| bf3c96026b | |||
| ed9343c686 | |||
| 9a8cdde86c | |||
| a2655e16f8 |
+1
-1
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.4.7",
|
||||
"langbot-plugin==0.4.12",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
|
||||
@@ -96,6 +96,19 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
except Exception as 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)
|
||||
async def _(server_name: str) -> str:
|
||||
"""Read a resource from an MCP server"""
|
||||
|
||||
@@ -188,10 +188,22 @@ class MCPService:
|
||||
persisted_session = runtime_mcp_session
|
||||
|
||||
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()
|
||||
else:
|
||||
await persisted_session.refresh()
|
||||
try:
|
||||
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
|
||||
# even for an already-hosted server.
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
@@ -232,3 +244,19 @@ class MCPService:
|
||||
context=ctx,
|
||||
)
|
||||
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.provider.message as provider_message
|
||||
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).
|
||||
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
||||
@@ -185,6 +185,16 @@ class MCPSessionStatus(enum.Enum):
|
||||
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:
|
||||
"""运行时 MCP 会话"""
|
||||
|
||||
@@ -254,6 +264,16 @@ class RuntimeMCPSession:
|
||||
self._lifecycle_task = None
|
||||
self._shutdown_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_config = self._box_stdio_runtime.config
|
||||
@@ -399,11 +419,39 @@ class RuntimeMCPSession:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
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
|
||||
raise Exception('Box managed process exited unexpectedly')
|
||||
else:
|
||||
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:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = str(e)
|
||||
@@ -424,14 +472,55 @@ class RuntimeMCPSession:
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||
finally:
|
||||
await self._cleanup_box_stdio_session()
|
||||
# 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()
|
||||
|
||||
async def _lifecycle_loop_with_retry(self):
|
||||
"""Wrap _lifecycle_loop with retry and exponential backoff."""
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
attempt = 0
|
||||
while attempt <= self._MAX_RETRIES:
|
||||
try:
|
||||
await self._lifecycle_loop()
|
||||
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:
|
||||
self.retry_count = attempt + 1
|
||||
if self._shutdown_event.is_set():
|
||||
@@ -460,6 +549,7 @@ class RuntimeMCPSession:
|
||||
self.error_message = None
|
||||
self.error_phase = None
|
||||
await asyncio.sleep(delay)
|
||||
attempt += 1
|
||||
|
||||
@staticmethod
|
||||
def _describe_exception(exc: BaseException) -> str:
|
||||
@@ -927,11 +1017,14 @@ class RuntimeMCPSession:
|
||||
return self._box_stdio_runtime.uses_box_stdio()
|
||||
|
||||
def _build_box_session_id(self) -> str:
|
||||
# Transient test sessions get their own isolated Box session so a
|
||||
# failing/short-lived test can never disturb the shared session that
|
||||
# hosts live, already-connected MCP servers.
|
||||
if self.is_transient:
|
||||
return f'mcp-test-{self.server_uuid}'
|
||||
# Both live servers and transient config-page tests share ONE Box
|
||||
# session ('mcp-shared'). A test therefore reuses the already-running
|
||||
# container (and, for an existing server, its live managed process)
|
||||
# instead of paying a full per-test session cold-start + dependency
|
||||
# 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'
|
||||
|
||||
def _rewrite_path(self, path: str, host_path: str | None) -> str:
|
||||
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
import shutil
|
||||
import shlex
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from contextlib import suppress, AsyncExitStack
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pydantic
|
||||
@@ -74,6 +74,35 @@ class MCPServerBoxConfig(pydantic.BaseModel):
|
||||
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:
|
||||
"""Encapsulate Box-backed stdio MCP session orchestration."""
|
||||
|
||||
@@ -113,7 +142,11 @@ class BoxStdioSessionRuntime:
|
||||
read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False,
|
||||
image=self.config.image,
|
||||
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,
|
||||
pids_limit=self.config.pids_limit,
|
||||
persistent=True,
|
||||
)
|
||||
@@ -173,28 +206,55 @@ class BoxStdioSessionRuntime:
|
||||
stderr_preview = (result.stderr or '')[:500]
|
||||
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
||||
|
||||
try:
|
||||
process_workspace = (
|
||||
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
||||
if host_path
|
||||
else workspace
|
||||
# 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:
|
||||
process_workspace = (
|
||||
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
||||
if host_path
|
||||
else workspace
|
||||
)
|
||||
payload = process_workspace.build_process_payload(
|
||||
self.server_config['command'],
|
||||
self.server_config.get('args', []),
|
||||
env=self.server_config.get('env', {}),
|
||||
cwd=process_cwd,
|
||||
)
|
||||
if install_cmd:
|
||||
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
||||
payload['process_id'] = self.process_id
|
||||
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
||||
except Exception:
|
||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||
raise
|
||||
else:
|
||||
self.ap.logger.info(
|
||||
f'MCP server {self.server_name}: reusing live managed process '
|
||||
f'process_id={self.process_id} (transport reconnect)'
|
||||
)
|
||||
payload = process_workspace.build_process_payload(
|
||||
self.server_config['command'],
|
||||
self.server_config.get('args', []),
|
||||
env=self.server_config.get('env', {}),
|
||||
cwd=process_cwd,
|
||||
)
|
||||
if install_cmd:
|
||||
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
||||
payload['process_id'] = self.process_id
|
||||
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
||||
except Exception:
|
||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||
raise
|
||||
|
||||
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:
|
||||
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
||||
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
||||
read_stream, write_stream = transport
|
||||
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
||||
@@ -202,12 +262,19 @@ class BoxStdioSessionRuntime:
|
||||
)
|
||||
except Exception:
|
||||
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
|
||||
|
||||
try:
|
||||
await self.owner.session.initialize()
|
||||
except Exception:
|
||||
await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
|
||||
except Exception as exc:
|
||||
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
|
||||
|
||||
async def monitor_process_health(self) -> None:
|
||||
@@ -234,8 +301,74 @@ class BoxStdioSessionRuntime:
|
||||
)
|
||||
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
|
||||
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)
|
||||
|
||||
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:
|
||||
source_path = normalize_host_path(host_path)
|
||||
if not source_path:
|
||||
@@ -342,16 +475,20 @@ class BoxStdioSessionRuntime:
|
||||
|
||||
workspace = self._build_workspace(host_path=None)
|
||||
|
||||
# Transient test sessions own their isolated Box session, so tear the
|
||||
# whole session down rather than leaking it. This cannot affect live
|
||||
# servers because they live in the separate shared session.
|
||||
# Transient config-page tests now share the same 'mcp-shared' Box
|
||||
# session as live servers, so we must NOT tear the session down here —
|
||||
# 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):
|
||||
try:
|
||||
await workspace.cleanup()
|
||||
await workspace.stop_managed_process(self.process_id)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'MCP server {self.server_name}: failed to delete transient test session '
|
||||
f'{self.owner._build_box_session_id()}: {type(exc).__name__}: {exc}'
|
||||
f'MCP server {self.server_name}: failed to stop transient test process '
|
||||
f'process_id={self.process_id}: {type(exc).__name__}: {exc}'
|
||||
)
|
||||
await self._cleanup_staged_workspace()
|
||||
return
|
||||
|
||||
@@ -639,10 +639,13 @@ class TestGetRuntimeInfoDict:
|
||||
assert info['box_session_id'] == 'mcp-shared'
|
||||
assert info['box_enabled'] is True
|
||||
|
||||
def test_transient_test_session_is_isolated_from_shared(self, mcp_module):
|
||||
"""A transient test session (config-page "test", no persisted UUID)
|
||||
must NOT share the live "mcp-shared" Box session. Regression: a failing
|
||||
test churned the shared session and tore down healthy live servers."""
|
||||
def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module):
|
||||
"""A transient config-page "test" now shares the same 'mcp-shared' Box
|
||||
session as live servers (so a test reuses the running container / live
|
||||
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.box_service.available = True
|
||||
transient = _make_session(
|
||||
@@ -670,10 +673,12 @@ class TestGetRuntimeInfoDict:
|
||||
)
|
||||
assert transient.is_transient is True
|
||||
assert live.is_transient is False
|
||||
# Isolated session id for the test, shared for the live server.
|
||||
assert transient._build_box_session_id() == 'mcp-test-gen-uuid-123'
|
||||
# Both share ONE Box session ...
|
||||
assert transient._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):
|
||||
"""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['args'] == ['/workspace/.mcp/u1/workspace/server.py']
|
||||
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 = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.7" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.12" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2187,7 +2187,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.4.7"
|
||||
version = "0.4.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2208,9 +2208,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ 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/9c/84/78054f9caff83acb96d472c09c1345beed9241adf05afd372972f1dd8d1c/langbot_plugin-0.4.12.tar.gz", hash = "sha256:5344a2280c7d99d18379ea9e5ce224ad573bb875aa5f06ec7da0e1ec16e0200c", size = 334903, upload-time = "2026-07-04T01:27:08.609Z" }
|
||||
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/4f/1e/bc020fb1b5ec656b7b742ead68a88db5396055af88302d105b0420e2657f/langbot_plugin-0.4.12-py3-none-any.whl", hash = "sha256:68606de0c305c823e7e2a399a07b1c0116f90fae664627b7059761ca318d69c0", size = 221886, upload-time = "2026-07-04T01:27:07.352Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -264,6 +264,48 @@ function saveListExpansionState(state: SidebarListExpansionState) {
|
||||
|
||||
// Maximum number of entity sub-items visible before "More" toggle
|
||||
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
|
||||
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
|
||||
@@ -352,11 +394,19 @@ function NavItems({
|
||||
if (extRefreshing) return;
|
||||
setExtRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
const results = await Promise.allSettled([
|
||||
sidebarData.refreshPlugins(),
|
||||
sidebarData.refreshMCPServers(),
|
||||
sidebarData.refreshSkills(),
|
||||
refreshEnabledMCPConnections(),
|
||||
]);
|
||||
const mcpRefreshResult = results[2];
|
||||
if (mcpRefreshResult.status === 'rejected') {
|
||||
console.error(
|
||||
'Failed to refresh MCP connections:',
|
||||
mcpRefreshResult.reason,
|
||||
);
|
||||
}
|
||||
await sidebarData.refreshMCPServers();
|
||||
} finally {
|
||||
setExtRefreshing(false);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,10 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||
}
|
||||
|
||||
const handlePersistedTestComplete = useCallback(async () => {
|
||||
await refreshMCPServers();
|
||||
}, [refreshMCPServers]);
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteMCPServer(id)
|
||||
@@ -364,6 +368,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
onRuntimeInfoChange={(runtimeInfo) =>
|
||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||
}
|
||||
onPersistedTestComplete={handlePersistedTestComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
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 {
|
||||
MCPServerRuntimeInfo,
|
||||
@@ -487,6 +488,7 @@ interface MCPFormProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onTestingChange?: (testing: boolean) => void;
|
||||
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
||||
onPersistedTestComplete?: (serverName: string) => void | Promise<void>;
|
||||
/** Reported when the form cannot be saved because the current mode is
|
||||
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
|
||||
* render the Save button outside this component should disable it. */
|
||||
@@ -511,6 +513,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
onDirtyChange,
|
||||
onTestingChange,
|
||||
onRuntimeInfoChange,
|
||||
onPersistedTestComplete,
|
||||
onSaveBlockedChange,
|
||||
layout = 'stacked',
|
||||
sideHeader,
|
||||
@@ -822,6 +825,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
// are always current.
|
||||
const serverName =
|
||||
isEditMode && initServerName ? initServerName : form.getValues('name');
|
||||
const shouldTestPersistedServer =
|
||||
isEditMode && !!initServerName && !form.formState.isDirty;
|
||||
const formExtraArgs = form.getValues('extra_args') ?? [];
|
||||
const formStdioArgs = form.getValues('args') ?? [];
|
||||
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
||||
@@ -844,12 +849,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
};
|
||||
}
|
||||
|
||||
const { task_id } = await httpClient.testMCPServer('_', {
|
||||
name: serverName,
|
||||
mode,
|
||||
enable: true,
|
||||
extra_args: extraArgsData,
|
||||
} as MCPServer);
|
||||
const testTarget = shouldTestPersistedServer ? serverName : '_';
|
||||
const testPayload = shouldTestPersistedServer
|
||||
? {}
|
||||
: ({
|
||||
name: serverName,
|
||||
mode,
|
||||
enable: true,
|
||||
extra_args: extraArgsData,
|
||||
} as MCPServer);
|
||||
|
||||
const { task_id } = await httpClient.testMCPServer(
|
||||
testTarget,
|
||||
testPayload,
|
||||
);
|
||||
|
||||
if (!task_id) {
|
||||
throw new Error(t('mcp.noTaskId'));
|
||||
@@ -875,14 +888,18 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
});
|
||||
if (shouldTestPersistedServer) {
|
||||
await onPersistedTestComplete?.(serverName);
|
||||
}
|
||||
} else {
|
||||
if (isEditMode) {
|
||||
if (shouldTestPersistedServer) {
|
||||
await loadServerForEdit(serverName);
|
||||
await onPersistedTestComplete?.(serverName);
|
||||
} 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 +
|
||||
// tools) in the test task's metadata before tearing the
|
||||
// transient session down — surface it so a successful test
|
||||
// tools) in the task 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;
|
||||
@@ -1207,6 +1224,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
<TabsTrigger value="resources" className="flex-none px-4">
|
||||
{resourcesTabLabel}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="flex-none px-4">
|
||||
{t('mcp.tabLogs')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||
<MCPReadme readme={readme} />
|
||||
@@ -1235,6 +1255,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||
{persistedServerName && <MCPLogs serverName={persistedServerName} />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
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(
|
||||
author: string,
|
||||
name: string,
|
||||
|
||||
@@ -828,6 +828,12 @@ const enUS = {
|
||||
tabTools: 'Tools',
|
||||
tabResources: 'Resources',
|
||||
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',
|
||||
parseResultFailed: 'Failed to parse test result',
|
||||
noResultReturned: 'Test returned no result',
|
||||
|
||||
@@ -842,6 +842,12 @@ const esES = {
|
||||
tabTools: 'Herramientas',
|
||||
tabResources: 'Recursos',
|
||||
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',
|
||||
parseResultFailed: 'Error al analizar el resultado de la prueba',
|
||||
noResultReturned: 'La prueba no devolvió resultados',
|
||||
|
||||
@@ -834,6 +834,11 @@ const jaJP = {
|
||||
tabTools: 'ツール',
|
||||
tabResources: 'リソース',
|
||||
tabDocs: 'ドキュメント',
|
||||
tabLogs: 'ログ',
|
||||
logsLevelAll: 'すべてのレベル',
|
||||
logsRefresh: '更新',
|
||||
logsAutoRefresh: '自動更新',
|
||||
logsEmpty: 'ログはありません。MCPサーバーの実行ログがここに表示されます。',
|
||||
noReadme: 'ドキュメントがありません',
|
||||
parseResultFailed: 'テスト結果の解析に失敗しました',
|
||||
noResultReturned: 'テスト結果が返されませんでした',
|
||||
|
||||
@@ -839,6 +839,12 @@ const ruRU = {
|
||||
tabTools: 'Инструменты',
|
||||
tabResources: 'Ресурсы',
|
||||
tabDocs: 'Документация',
|
||||
tabLogs: 'Журнал',
|
||||
logsLevelAll: 'Все уровни',
|
||||
logsRefresh: 'Обновить',
|
||||
logsAutoRefresh: 'Автообновление',
|
||||
logsEmpty:
|
||||
'Журналов пока нет. Здесь будут отображаться журналы выполнения MCP-сервера.',
|
||||
noReadme: 'Документация отсутствует',
|
||||
parseResultFailed: 'Не удалось разобрать результат теста',
|
||||
noResultReturned: 'Тест не вернул результат',
|
||||
|
||||
@@ -817,6 +817,11 @@ const thTH = {
|
||||
tabTools: 'เครื่องมือ',
|
||||
tabResources: 'ทรัพยากร',
|
||||
tabDocs: 'เอกสาร',
|
||||
tabLogs: 'บันทึก',
|
||||
logsLevelAll: 'ทุกระดับ',
|
||||
logsRefresh: 'รีเฟรช',
|
||||
logsAutoRefresh: 'รีเฟรชอัตโนมัติ',
|
||||
logsEmpty: 'ยังไม่มีบันทึก บันทึกการทำงานของ MCP Server จะแสดงที่นี่',
|
||||
noReadme: 'ไม่มีเอกสาร',
|
||||
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
|
||||
noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา',
|
||||
|
||||
@@ -832,6 +832,12 @@ const viVN = {
|
||||
tabTools: 'Công cụ',
|
||||
tabResources: 'Tài nguyên',
|
||||
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',
|
||||
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ả',
|
||||
|
||||
@@ -794,6 +794,11 @@ const zhHans = {
|
||||
tabTools: '工具',
|
||||
tabResources: '资源',
|
||||
tabDocs: '文档',
|
||||
tabLogs: '日志',
|
||||
logsLevelAll: '全部级别',
|
||||
logsRefresh: '刷新',
|
||||
logsAutoRefresh: '自动刷新',
|
||||
logsEmpty: '暂无日志。MCP 服务器的运行日志会显示在这里。',
|
||||
noReadme: '暂无文档',
|
||||
parseResultFailed: '解析测试结果失败',
|
||||
noResultReturned: '测试未返回结果',
|
||||
|
||||
@@ -793,6 +793,11 @@ const zhHant = {
|
||||
tabTools: '工具',
|
||||
tabResources: '資源',
|
||||
tabDocs: '文件',
|
||||
tabLogs: '日誌',
|
||||
logsLevelAll: '全部級別',
|
||||
logsRefresh: '重新整理',
|
||||
logsAutoRefresh: '自動重新整理',
|
||||
logsEmpty: '暫無日誌。MCP 服務器的運行日誌會顯示在這裡。',
|
||||
noReadme: '暫無文件',
|
||||
parseResultFailed: '解析測試結果失敗',
|
||||
noResultReturned: '測試未返回結果',
|
||||
|
||||
Reference in New Issue
Block a user