mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 11:29:39 +08:00
perf: mcp server status checking logic
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import sqlalchemy
|
||||
import uuid
|
||||
import traceback
|
||||
import asyncio
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
@@ -124,7 +125,6 @@ class MCPService:
|
||||
|
||||
async def create_mcp_server(self, server_data: dict) -> str:
|
||||
server_data['uuid'] = str(uuid.uuid4())
|
||||
print('server_data:', server_data)
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -134,7 +134,8 @@ class MCPService:
|
||||
if server_entity:
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
|
||||
if self.ap.tool_mgr.mcp_tool_loader:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config)
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
|
||||
return server_data['uuid']
|
||||
|
||||
@@ -175,7 +176,9 @@ class MCPService:
|
||||
if updated_server:
|
||||
# convert entity to config dict
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
|
||||
await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config)
|
||||
# await self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
|
||||
async def delete_mcp_server(self, server_uuid: str) -> None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import typing
|
||||
from contextlib import AsyncExitStack
|
||||
import traceback
|
||||
@@ -16,6 +17,12 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
|
||||
|
||||
class MCPSessionStatus(enum.Enum):
|
||||
CONNECTING = 'connecting'
|
||||
CONNECTED = 'connected'
|
||||
ERROR = 'error'
|
||||
|
||||
|
||||
class RuntimeMCPSession:
|
||||
"""运行时 MCP 会话"""
|
||||
|
||||
@@ -33,7 +40,8 @@ class RuntimeMCPSession:
|
||||
|
||||
enable: bool
|
||||
|
||||
connected: bool
|
||||
# connected: bool
|
||||
status: MCPSessionStatus
|
||||
|
||||
last_test_error_message: str
|
||||
|
||||
@@ -47,7 +55,7 @@ class RuntimeMCPSession:
|
||||
self.exit_stack = AsyncExitStack()
|
||||
self.functions = []
|
||||
|
||||
self.connected = False
|
||||
self.status = MCPSessionStatus.CONNECTING
|
||||
self.last_test_error_message = ''
|
||||
|
||||
async def _init_stdio_python_server(self):
|
||||
@@ -117,10 +125,10 @@ class RuntimeMCPSession:
|
||||
)
|
||||
)
|
||||
|
||||
self.connected = True
|
||||
self.status = MCPSessionStatus.CONNECTED
|
||||
self.last_test_error_message = ''
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.last_test_error_message = str(e)
|
||||
raise e
|
||||
|
||||
@@ -129,7 +137,7 @@ class RuntimeMCPSession:
|
||||
|
||||
def get_runtime_info_dict(self) -> dict:
|
||||
return {
|
||||
'connected': self.connected,
|
||||
'status': self.status.value,
|
||||
'error_message': self.last_test_error_message,
|
||||
'tool_count': len(self.get_tools()),
|
||||
'tools': [
|
||||
@@ -163,13 +171,13 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
_last_listed_functions: list[resource_tool.LLMTool]
|
||||
|
||||
_startup_load_tasks: list[asyncio.Task]
|
||||
_hosted_mcp_tasks: list[asyncio.Task]
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
super().__init__(ap)
|
||||
self.sessions = {}
|
||||
self._last_listed_functions = []
|
||||
self._startup_load_tasks = []
|
||||
self._hosted_mcp_tasks = []
|
||||
|
||||
async def initialize(self):
|
||||
await self.load_mcp_servers_from_db()
|
||||
@@ -185,30 +193,30 @@ class MCPLoader(loader.ToolLoader):
|
||||
for server in servers:
|
||||
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
|
||||
async def load_mcp_server_task(server_config: dict):
|
||||
self.ap.logger.debug(f'Loading MCP server {server_config}')
|
||||
try:
|
||||
session = await self.load_mcp_server(server_config)
|
||||
self.sessions[server_config['name']] = session
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
return
|
||||
task = asyncio.create_task(self.host_mcp_server(config))
|
||||
self._hosted_mcp_tasks.append(task)
|
||||
|
||||
self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
try:
|
||||
await session.start()
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
return
|
||||
async def host_mcp_server(self, server_config: dict):
|
||||
self.ap.logger.debug(f'Loading MCP server {server_config}')
|
||||
try:
|
||||
session = await self.load_mcp_server(server_config)
|
||||
self.sessions[server_config['name']] = session
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
return
|
||||
|
||||
self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
try:
|
||||
await session.start()
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
return
|
||||
|
||||
task = asyncio.create_task(load_mcp_server_task(config))
|
||||
self._startup_load_tasks.append(task)
|
||||
self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
|
||||
async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
|
||||
"""加载 MCP 服务器到运行时
|
||||
@@ -271,20 +279,6 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
raise ValueError(f'Tool not found: {name}')
|
||||
|
||||
async def reload_mcp_server(self, server_config: dict):
|
||||
"""重新加载 MCP 服务器(先移除再加载)
|
||||
|
||||
Args:
|
||||
server_config: 服务器配置字典,必须包含 name 字段
|
||||
"""
|
||||
server_name = server_config['name']
|
||||
|
||||
if server_name in self.sessions:
|
||||
await self.remove_mcp_server(server_name)
|
||||
|
||||
# 重新加载
|
||||
await self.load_mcp_server(server_config)
|
||||
|
||||
async def remove_mcp_server(self, server_name: str):
|
||||
"""移除 MCP 服务器"""
|
||||
if server_name not in self.sessions:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { MCPServer } from '@/app/infra/entities/api';
|
||||
import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
export class MCPCardVO {
|
||||
name: string;
|
||||
mode: 'stdio' | 'sse';
|
||||
enable: boolean;
|
||||
status: 'connected' | 'disconnected' | 'error' | 'disabled';
|
||||
status: MCPSessionStatus;
|
||||
tools: number;
|
||||
error?: string;
|
||||
|
||||
@@ -15,45 +15,15 @@ export class MCPCardVO {
|
||||
|
||||
// Determine status from runtime_info
|
||||
if (!data.runtime_info) {
|
||||
this.status = 'disconnected';
|
||||
this.status = MCPSessionStatus.ERROR;
|
||||
this.tools = 0;
|
||||
} else if (data.runtime_info.connected) {
|
||||
this.status = 'connected';
|
||||
} else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) {
|
||||
this.status = data.runtime_info.status;
|
||||
this.tools = data.runtime_info.tool_count || 0;
|
||||
} else {
|
||||
this.status = 'error';
|
||||
this.status = data.runtime_info.status;
|
||||
this.tools = 0;
|
||||
this.error = data.runtime_info.error_message;
|
||||
}
|
||||
}
|
||||
|
||||
getStatusColor(): string {
|
||||
switch (this.status) {
|
||||
case 'connected':
|
||||
return 'text-green-600';
|
||||
case 'disconnected':
|
||||
return 'text-gray-500';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'disabled':
|
||||
return 'text-gray-400';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
switch (this.status) {
|
||||
case 'connected':
|
||||
return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
case 'disconnected':
|
||||
return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
case 'error':
|
||||
return 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
case 'disabled':
|
||||
return 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636';
|
||||
default:
|
||||
return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent';
|
||||
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
@@ -16,11 +17,44 @@ export default function MCPComponent({
|
||||
const { t } = useTranslation();
|
||||
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstalledServers();
|
||||
|
||||
return () => {
|
||||
// Cleanup: clear polling interval when component unmounts
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check if any server is connecting and start/stop polling accordingly
|
||||
useEffect(() => {
|
||||
const hasConnecting = installedServers.some(
|
||||
(server) => server.status === MCPSessionStatus.CONNECTING,
|
||||
);
|
||||
|
||||
if (hasConnecting && !pollingIntervalRef.current) {
|
||||
// Start polling every 3 seconds
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
fetchInstalledServers();
|
||||
}, 3000);
|
||||
} else if (!hasConnecting && pollingIntervalRef.current) {
|
||||
// Stop polling when no server is connecting
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [installedServers]);
|
||||
|
||||
function fetchInstalledServers() {
|
||||
setLoading(true);
|
||||
httpClient
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw, Wrench, Ban, AlertCircle } from 'lucide-react';
|
||||
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
export default function MCPCardComponent({
|
||||
cardVO,
|
||||
@@ -110,7 +111,7 @@ export default function MCPCardComponent({
|
||||
{t('mcp.statusDisabled')}
|
||||
</div>
|
||||
</div>
|
||||
) : status === 'connected' ? (
|
||||
) : status === MCPSessionStatus.CONNECTED ? (
|
||||
// 连接成功 - 显示工具数量
|
||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||
<Wrench className="w-5 h-5" />
|
||||
@@ -118,6 +119,14 @@ export default function MCPCardComponent({
|
||||
{t('mcp.toolCount', { count: toolsCount })}
|
||||
</div>
|
||||
</div>
|
||||
) : status === MCPSessionStatus.CONNECTING ? (
|
||||
// 连接中 - 蓝色加载
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
|
||||
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
|
||||
{t('mcp.connecting')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 连接失败 - 红色
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -42,9 +42,10 @@ import {
|
||||
MCPServerRuntimeInfo,
|
||||
MCPTool,
|
||||
MCPServer,
|
||||
MCPSessionStatus,
|
||||
} from '@/app/infra/entities/api';
|
||||
|
||||
// Status Display Component - 只在测试中或连接失败时使用
|
||||
// Status Display Component - 在测试中、连接中或连接失败时使用
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
runtimeInfo,
|
||||
@@ -82,6 +83,35 @@ function StatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// 连接中
|
||||
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 连接失败
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -201,6 +231,7 @@ export default function MCPFormDialog({
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
||||
null,
|
||||
);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load server data when editing
|
||||
useEffect(() => {
|
||||
@@ -212,8 +243,48 @@ export default function MCPFormDialog({
|
||||
setExtraArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
|
||||
// Cleanup polling interval when dialog closes
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, isEditMode, serverName]);
|
||||
|
||||
// Poll for updates when runtime_info status is CONNECTING
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
!isEditMode ||
|
||||
!serverName ||
|
||||
!runtimeInfo ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTING
|
||||
) {
|
||||
// Stop polling if conditions are not met
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Start polling if not already running
|
||||
if (!pollingIntervalRef.current) {
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
loadServerForEdit(serverName);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, isEditMode, serverName, runtimeInfo?.status]);
|
||||
|
||||
async function loadServerForEdit(serverName: string) {
|
||||
try {
|
||||
const resp = await httpClient.getMCPServer(serverName);
|
||||
@@ -312,7 +383,7 @@ export default function MCPFormDialog({
|
||||
taskResp.runtime.exception || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
setRuntimeInfo({
|
||||
connected: false,
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
@@ -387,7 +458,8 @@ export default function MCPFormDialog({
|
||||
{isEditMode && runtimeInfo && (
|
||||
<div className="mb-4 space-y-3">
|
||||
{/* 测试中或连接失败时显示状态 */}
|
||||
{(mcpTesting || !runtimeInfo.connected) && (
|
||||
{(mcpTesting ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
|
||||
<div className="p-3 rounded-lg border">
|
||||
<StatusDisplay
|
||||
testing={mcpTesting}
|
||||
@@ -399,7 +471,7 @@ export default function MCPFormDialog({
|
||||
|
||||
{/* 连接成功时只显示工具列表 */}
|
||||
{!mcpTesting &&
|
||||
runtimeInfo.connected &&
|
||||
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
|
||||
runtimeInfo.tools?.length > 0 && (
|
||||
<ToolsList tools={runtimeInfo.tools} />
|
||||
)}
|
||||
|
||||
@@ -325,8 +325,14 @@ export interface MCPServerExtraArgsSSE {
|
||||
ssereadtimeout: number;
|
||||
}
|
||||
|
||||
export enum MCPSessionStatus {
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export interface MCPServerRuntimeInfo {
|
||||
connected: boolean;
|
||||
status: MCPSessionStatus;
|
||||
error_message: string;
|
||||
tool_count: number;
|
||||
tools: MCPTool[];
|
||||
|
||||
@@ -311,6 +311,7 @@ const enUS = {
|
||||
keyName: 'Key Name',
|
||||
value: 'Value',
|
||||
testing: 'Testing...',
|
||||
connecting: 'Connecting...',
|
||||
testSuccess: 'Connection test successful',
|
||||
testFailed: 'Connection test failed: ',
|
||||
testError: 'Connection test error',
|
||||
|
||||
@@ -313,6 +313,7 @@ const jaJP = {
|
||||
keyName: 'キー名',
|
||||
value: '値',
|
||||
testing: 'テスト中...',
|
||||
connecting: '接続中...',
|
||||
testSuccess: '接続テストに成功しました',
|
||||
testFailed: '接続テストに失敗しました:',
|
||||
testError: '接続テストエラー',
|
||||
|
||||
@@ -299,6 +299,7 @@ const zhHans = {
|
||||
keyName: '键名',
|
||||
value: '值',
|
||||
testing: '测试中...',
|
||||
connecting: '连接中...',
|
||||
testSuccess: '连接测试成功',
|
||||
testFailed: '连接测试失败:',
|
||||
testError: '连接测试出错',
|
||||
|
||||
@@ -297,6 +297,7 @@ const zhHant = {
|
||||
keyName: '鍵名',
|
||||
value: '值',
|
||||
testing: '測試中...',
|
||||
connecting: '連接中...',
|
||||
testSuccess: '連接測試成功',
|
||||
testFailed: '連接測試失敗:',
|
||||
testError: '連接測試出錯',
|
||||
|
||||
Reference in New Issue
Block a user