perf: mcp server status checking logic

This commit is contained in:
Junyan Qin
2025-11-04 17:32:05 +08:00
parent 3ee7736361
commit 1afecf01e4
11 changed files with 182 additions and 90 deletions
+6 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import sqlalchemy import sqlalchemy
import uuid import uuid
import traceback import traceback
import asyncio
from ....core import app from ....core import app
from ....entity.persistence import mcp as persistence_mcp from ....entity.persistence import mcp as persistence_mcp
@@ -124,7 +125,6 @@ class MCPService:
async def create_mcp_server(self, server_data: dict) -> str: async def create_mcp_server(self, server_data: dict) -> str:
server_data['uuid'] = str(uuid.uuid4()) 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)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
@@ -134,7 +134,8 @@ class MCPService:
if server_entity: if server_entity:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
if self.ap.tool_mgr.mcp_tool_loader: 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'] return server_data['uuid']
@@ -175,7 +176,9 @@ class MCPService:
if updated_server: if updated_server:
# convert entity to config dict # convert entity to config dict
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) 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: async def delete_mcp_server(self, server_uuid: str) -> None:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
+36 -42
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import enum
import typing import typing
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
import traceback 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 from ....entity.persistence import mcp as persistence_mcp
class MCPSessionStatus(enum.Enum):
CONNECTING = 'connecting'
CONNECTED = 'connected'
ERROR = 'error'
class RuntimeMCPSession: class RuntimeMCPSession:
"""运行时 MCP 会话""" """运行时 MCP 会话"""
@@ -33,7 +40,8 @@ class RuntimeMCPSession:
enable: bool enable: bool
connected: bool # connected: bool
status: MCPSessionStatus
last_test_error_message: str last_test_error_message: str
@@ -47,7 +55,7 @@ class RuntimeMCPSession:
self.exit_stack = AsyncExitStack() self.exit_stack = AsyncExitStack()
self.functions = [] self.functions = []
self.connected = False self.status = MCPSessionStatus.CONNECTING
self.last_test_error_message = '' self.last_test_error_message = ''
async def _init_stdio_python_server(self): 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 = '' self.last_test_error_message = ''
except Exception as e: except Exception as e:
self.connected = False self.status = MCPSessionStatus.ERROR
self.last_test_error_message = str(e) self.last_test_error_message = str(e)
raise e raise e
@@ -129,7 +137,7 @@ class RuntimeMCPSession:
def get_runtime_info_dict(self) -> dict: def get_runtime_info_dict(self) -> dict:
return { return {
'connected': self.connected, 'status': self.status.value,
'error_message': self.last_test_error_message, 'error_message': self.last_test_error_message,
'tool_count': len(self.get_tools()), 'tool_count': len(self.get_tools()),
'tools': [ 'tools': [
@@ -163,13 +171,13 @@ class MCPLoader(loader.ToolLoader):
_last_listed_functions: list[resource_tool.LLMTool] _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): def __init__(self, ap: app.Application):
super().__init__(ap) super().__init__(ap)
self.sessions = {} self.sessions = {}
self._last_listed_functions = [] self._last_listed_functions = []
self._startup_load_tasks = [] self._hosted_mcp_tasks = []
async def initialize(self): async def initialize(self):
await self.load_mcp_servers_from_db() await self.load_mcp_servers_from_db()
@@ -185,30 +193,30 @@ class MCPLoader(loader.ToolLoader):
for server in servers: for server in servers:
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
async def load_mcp_server_task(server_config: dict): task = asyncio.create_task(self.host_mcp_server(config))
self.ap.logger.debug(f'Loading MCP server {server_config}') self._hosted_mcp_tasks.append(task)
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'Starting MCP server {server_config["name"]}({server_config["uuid"]})') async def host_mcp_server(self, server_config: dict):
try: self.ap.logger.debug(f'Loading MCP server {server_config}')
await session.start() try:
except Exception as e: session = await self.load_mcp_server(server_config)
self.ap.logger.error( self.sessions[server_config['name']] = session
f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' except Exception as e:
) self.ap.logger.error(
return 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.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
self._startup_load_tasks.append(task)
async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession: async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
"""加载 MCP 服务器到运行时 """加载 MCP 服务器到运行时
@@ -271,20 +279,6 @@ class MCPLoader(loader.ToolLoader):
raise ValueError(f'Tool not found: {name}') 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): async def remove_mcp_server(self, server_name: str):
"""移除 MCP 服务器""" """移除 MCP 服务器"""
if server_name not in self.sessions: 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 { export class MCPCardVO {
name: string; name: string;
mode: 'stdio' | 'sse'; mode: 'stdio' | 'sse';
enable: boolean; enable: boolean;
status: 'connected' | 'disconnected' | 'error' | 'disabled'; status: MCPSessionStatus;
tools: number; tools: number;
error?: string; error?: string;
@@ -15,45 +15,15 @@ export class MCPCardVO {
// Determine status from runtime_info // Determine status from runtime_info
if (!data.runtime_info) { if (!data.runtime_info) {
this.status = 'disconnected'; this.status = MCPSessionStatus.ERROR;
this.tools = 0; this.tools = 0;
} else if (data.runtime_info.connected) { } else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) {
this.status = 'connected'; this.status = data.runtime_info.status;
this.tools = data.runtime_info.tool_count || 0; this.tools = data.runtime_info.tool_count || 0;
} else { } else {
this.status = 'error'; this.status = data.runtime_info.status;
this.tools = 0; this.tools = 0;
this.error = data.runtime_info.error_message; 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'; '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 MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent';
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO'; import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MCPSessionStatus } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
@@ -16,11 +17,44 @@ export default function MCPComponent({
const { t } = useTranslation(); const { t } = useTranslation();
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]); const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
fetchInstalledServers(); 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() { function fetchInstalledServers() {
setLoading(true); setLoading(true);
httpClient httpClient
@@ -5,7 +5,8 @@ import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; 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({ export default function MCPCardComponent({
cardVO, cardVO,
@@ -110,7 +111,7 @@ export default function MCPCardComponent({
{t('mcp.statusDisabled')} {t('mcp.statusDisabled')}
</div> </div>
</div> </div>
) : status === 'connected' ? ( ) : status === MCPSessionStatus.CONNECTED ? (
// 连接成功 - 显示工具数量 // 连接成功 - 显示工具数量
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]"> <div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<Wrench className="w-5 h-5" /> <Wrench className="w-5 h-5" />
@@ -118,6 +119,14 @@ export default function MCPCardComponent({
{t('mcp.toolCount', { count: toolsCount })} {t('mcp.toolCount', { count: toolsCount })}
</div> </div>
</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]"> <div className="flex flex-row items-center gap-[0.4rem]">
@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Resolver, useForm } from 'react-hook-form'; import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -42,9 +42,10 @@ import {
MCPServerRuntimeInfo, MCPServerRuntimeInfo,
MCPTool, MCPTool,
MCPServer, MCPServer,
MCPSessionStatus,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
// Status Display Component - 在测试中或连接失败时使用 // Status Display Component - 在测试中、连接中或连接失败时使用
function StatusDisplay({ function StatusDisplay({
testing, testing,
runtimeInfo, 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 ( return (
<div className="space-y-1"> <div className="space-y-1">
@@ -201,6 +231,7 @@ export default function MCPFormDialog({
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>( const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
null, null,
); );
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Load server data when editing // Load server data when editing
useEffect(() => { useEffect(() => {
@@ -212,8 +243,48 @@ export default function MCPFormDialog({
setExtraArgs([]); setExtraArgs([]);
setRuntimeInfo(null); setRuntimeInfo(null);
} }
// Cleanup polling interval when dialog closes
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [open, isEditMode, serverName]); }, [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) { async function loadServerForEdit(serverName: string) {
try { try {
const resp = await httpClient.getMCPServer(serverName); const resp = await httpClient.getMCPServer(serverName);
@@ -312,7 +383,7 @@ export default function MCPFormDialog({
taskResp.runtime.exception || t('mcp.unknownError'); taskResp.runtime.exception || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`); toast.error(`${t('mcp.testError')}: ${errorMsg}`);
setRuntimeInfo({ setRuntimeInfo({
connected: false, status: MCPSessionStatus.ERROR,
error_message: errorMsg, error_message: errorMsg,
tool_count: 0, tool_count: 0,
tools: [], tools: [],
@@ -387,7 +458,8 @@ export default function MCPFormDialog({
{isEditMode && runtimeInfo && ( {isEditMode && runtimeInfo && (
<div className="mb-4 space-y-3"> <div className="mb-4 space-y-3">
{/* 测试中或连接失败时显示状态 */} {/* 测试中或连接失败时显示状态 */}
{(mcpTesting || !runtimeInfo.connected) && ( {(mcpTesting ||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<div className="p-3 rounded-lg border"> <div className="p-3 rounded-lg border">
<StatusDisplay <StatusDisplay
testing={mcpTesting} testing={mcpTesting}
@@ -399,7 +471,7 @@ export default function MCPFormDialog({
{/* 连接成功时只显示工具列表 */} {/* 连接成功时只显示工具列表 */}
{!mcpTesting && {!mcpTesting &&
runtimeInfo.connected && runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && ( runtimeInfo.tools?.length > 0 && (
<ToolsList tools={runtimeInfo.tools} /> <ToolsList tools={runtimeInfo.tools} />
)} )}
+7 -1
View File
@@ -325,8 +325,14 @@ export interface MCPServerExtraArgsSSE {
ssereadtimeout: number; ssereadtimeout: number;
} }
export enum MCPSessionStatus {
CONNECTING = 'connecting',
CONNECTED = 'connected',
ERROR = 'error',
}
export interface MCPServerRuntimeInfo { export interface MCPServerRuntimeInfo {
connected: boolean; status: MCPSessionStatus;
error_message: string; error_message: string;
tool_count: number; tool_count: number;
tools: MCPTool[]; tools: MCPTool[];
+1
View File
@@ -311,6 +311,7 @@ const enUS = {
keyName: 'Key Name', keyName: 'Key Name',
value: 'Value', value: 'Value',
testing: 'Testing...', testing: 'Testing...',
connecting: 'Connecting...',
testSuccess: 'Connection test successful', testSuccess: 'Connection test successful',
testFailed: 'Connection test failed: ', testFailed: 'Connection test failed: ',
testError: 'Connection test error', testError: 'Connection test error',
+1
View File
@@ -313,6 +313,7 @@ const jaJP = {
keyName: 'キー名', keyName: 'キー名',
value: '値', value: '値',
testing: 'テスト中...', testing: 'テスト中...',
connecting: '接続中...',
testSuccess: '接続テストに成功しました', testSuccess: '接続テストに成功しました',
testFailed: '接続テストに失敗しました:', testFailed: '接続テストに失敗しました:',
testError: '接続テストエラー', testError: '接続テストエラー',
+1
View File
@@ -299,6 +299,7 @@ const zhHans = {
keyName: '键名', keyName: '键名',
value: '值', value: '值',
testing: '测试中...', testing: '测试中...',
connecting: '连接中...',
testSuccess: '连接测试成功', testSuccess: '连接测试成功',
testFailed: '连接测试失败:', testFailed: '连接测试失败:',
testError: '连接测试出错', testError: '连接测试出错',
+1
View File
@@ -297,6 +297,7 @@ const zhHant = {
keyName: '鍵名', keyName: '鍵名',
value: '值', value: '值',
testing: '測試中...', testing: '測試中...',
connecting: '連接中...',
testSuccess: '連接測試成功', testSuccess: '連接測試成功',
testFailed: '連接測試失敗:', testFailed: '連接測試失敗:',
testError: '連接測試出錯', testError: '連接測試出錯',