Merge pull request #1296 from AstrBotDevs/feat-mcp-servers-market

[WIP] MCP 服务器市场
This commit is contained in:
Soulter
2025-04-17 16:26:41 +08:00
committed by GitHub
7 changed files with 715 additions and 208 deletions
+4 -1
View File
@@ -25,6 +25,7 @@ import logging
import colorlog
import asyncio
import os
import sys
from collections import deque
from asyncio import Queue
from typing import List
@@ -171,7 +172,9 @@ class LogManager:
if logger.hasHandlers():
return logger
# 如果logger没有处理器
console_handler = logging.StreamHandler() # 创建一个StreamHandler用于控制台输出
console_handler = logging.StreamHandler(
sys.stdout
) # 创建一个StreamHandler用于控制台输出
console_handler.setLevel(
logging.DEBUG
) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG
+31 -13
View File
@@ -4,12 +4,14 @@ import textwrap
import os
import asyncio
import copy
import logging
from typing import Dict, List, Awaitable, Literal, Any
from dataclasses import dataclass
from typing import Optional
from contextlib import AsyncExitStack
from astrbot import logger
from astrbot.core.utils.log_pipe import LogPipe
try:
import mcp
@@ -87,8 +89,9 @@ class MCPClient:
self.name = None
self.active: bool = True
self.tools: List[mcp.Tool] = []
self.server_errlogs: List[str] = []
async def connect_to_server(self, mcp_server_config: dict):
async def connect_to_server(self, mcp_server_config: dict, name: str):
"""Connect to an MCP server
Args:
@@ -98,19 +101,30 @@ class MCPClient:
if "mcpServers" in cfg and len(cfg["mcpServers"]) > 0:
key_0 = list(cfg["mcpServers"].keys())[0]
cfg = cfg["mcpServers"][key_0]
cfg.pop("active", None) # Remove active flag from config
cfg.pop("active", None) # Remove active flag from config
server_params = mcp.StdioServerParameters(
**cfg,
)
def callback(msg: str):
# 处理 MCP 服务的错误日志
self.server_errlogs.append(msg)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(server_params)
mcp.stdio_client(
server_params,
errlog=LogPipe(
level=logging.ERROR,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
),
),
)
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(self.stdio, self.write)
)
await self.session.initialize()
async def list_tools_and_save(self) -> mcp.ListToolsResult:
@@ -266,7 +280,9 @@ class FuncCall:
self.func_list = [
f
for f in self.func_list
if not (f.origin == "mcp" and f.mcp_server_name == data["name"])
if not (
f.origin == "mcp" and f.mcp_server_name == data["name"]
)
]
else:
for name in self.mcp_client_dict.keys():
@@ -275,11 +291,7 @@ class FuncCall:
if name in self.mcp_client_event:
self.mcp_client_event[name].set()
self.mcp_client_event.pop(name, None)
self.func_list = [
f
for f in self.func_list
if f.origin != "mcp"
]
self.func_list = [f for f in self.func_list if f.origin != "mcp"]
async def _init_mcp_client_task_wrapper(
self, name: str, cfg: dict, event: asyncio.Event
@@ -291,6 +303,9 @@ class FuncCall:
logger.info(f"收到 MCP 客户端 {name} 终止信号")
await self._terminate_mcp_client(name)
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
async def _init_mcp_client(self, name: str, config: dict) -> None:
@@ -302,10 +317,10 @@ class FuncCall:
mcp_client = MCPClient()
mcp_client.name = name
await mcp_client.connect_to_server(config)
self.mcp_client_dict[name] = mcp_client
await mcp_client.connect_to_server(config, name)
tools_res = await mcp_client.list_tools_and_save()
tool_names = [tool.name for tool in tools_res.tools]
self.mcp_client_dict[name] = mcp_client
# 移除该MCP服务之前的工具(如有)
self.func_list = [
@@ -329,6 +344,9 @@ class FuncCall:
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
return True
except Exception as e:
import traceback
logger.error(traceback.format_exc())
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
# 发生错误时确保客户端被清理
if name in self.mcp_client_dict:
@@ -352,7 +370,7 @@ class FuncCall:
]
logger.info(f"已关闭 MCP 服务 {name}")
def get_func_desc_openai_style(self, omit_empty_parameter_field = False) -> list:
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
"""
获得 OpenAI API 风格的**已经激活**的工具描述
"""
+36
View File
@@ -0,0 +1,36 @@
import threading
import os
from logging import Logger
class LogPipe(threading.Thread):
def __init__(
self,
level,
logger: Logger,
identifier=None,
callback=None,
):
threading.Thread.__init__(self)
self.daemon = True
self.level = level
self.fd_read, self.fd_write = os.pipe()
self.identifier = identifier
self.logger = logger
self.callback = callback
self.reader = os.fdopen(self.fd_read)
self.start()
def fileno(self):
return self.fd_write
def run(self):
for line in iter(self.reader.readline, ""):
if self.callback:
self.callback(line.strip())
self.logger.log(self.level, f"[{self.identifier}] {line.strip()}")
self.reader.close()
def close(self):
os.close(self.fd_write)
+36 -6
View File
@@ -1,5 +1,6 @@
import os
import json
import aiohttp
import traceback
from .route import Route, Response, RouteContext
from quart import request
@@ -20,6 +21,7 @@ class ToolsRoute(Route):
"/tools/mcp/add": ("POST", self.add_mcp_server),
"/tools/mcp/update": ("POST", self.update_mcp_server),
"/tools/mcp/delete": ("POST", self.delete_mcp_server),
"/tools/mcp/market": ("GET", self.get_mcp_markets),
}
self.register_routes()
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
@@ -78,6 +80,7 @@ class ToolsRoute(Route):
) in self.tool_mgr.mcp_client_dict.items():
if name_key == name:
server_info["tools"] = [tool.name for tool in mcp_client.tools]
server_info["errlogs"] = mcp_client.server_errlogs
break
else:
server_info["tools"] = []
@@ -105,9 +108,11 @@ class ToolsRoute(Route):
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
if key == "mcpServers":
key_0 = list(server_data["mcpServers"].keys())[0] # 不考虑为空的情况
key_0 = list(server_data["mcpServers"].keys())[
0
] # 不考虑为空的情况
server_config = server_data["mcpServers"][key_0]
else:
server_config[key] = value
@@ -125,7 +130,7 @@ class ToolsRoute(Route):
if self.save_mcp_config(config):
# 动态初始化新MCP客户端
self.tool_mgr.mcp_service_queue.put_nowait(
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
@@ -166,9 +171,11 @@ class ToolsRoute(Route):
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
if key == "mcpServers":
key_0 = list(server_data["mcpServers"].keys())[0] # 不考虑为空的情况
key_0 = list(server_data["mcpServers"].keys())[
0
] # 不考虑为空的情况
server_config = server_data["mcpServers"][key_0]
else:
server_config[key] = value
@@ -202,7 +209,7 @@ class ToolsRoute(Route):
)
else:
# 客户端不存在,初始化
self.tool_mgr.mcp_service_queue.put_nowait(
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
@@ -258,3 +265,26 @@ class ToolsRoute(Route):
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__
async def get_mcp_markets(self):
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 10, type=int)
BASE_URL = "https://api.soulter.top/astrbot/mcpservers?page={}&page_size={}".format(
page,
page_size,
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"{BASE_URL}") as response:
if response.status == 200:
data = await response.json()
return Response().ok(data["data"]).__dict__
else:
return (
Response()
.error(f"获取市场数据失败: HTTP {response.status}")
.__dict__
)
except Exception as _:
logger.error(traceback.format_exc())
return Response().error("获取市场数据失败").__dict__
@@ -81,7 +81,7 @@ const viewReadme = () => {
<div class="flex-grow-1">
<div>{{ extension.author }} /</div>
<p class="text-h3 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
<p class="text-h4 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
{{ extension.name }}
<v-tooltip location="top" v-if="extension?.has_update && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
+1 -1
View File
@@ -402,7 +402,7 @@ onMounted(async () => {
已安装的插件
</v-card-title>
<v-card-subtitle class="text-subtitle-1 mt-1 text-medium-emphasis">
管理已经安装的所有机器人插件
管理已经安装的所有插件
</v-card-subtitle>
</v-card-item>
+606 -186
View File
@@ -27,198 +27,348 @@
</v-col>
</v-row>
<!-- MCP 服务器部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-server</v-icon>
<span class="text-h6">MCP 服务器</span>
<v-progress-circular indeterminate color="primary" size="24" style="margin-left: 16px;" v-show="loading"></v-progress-circular>
<v-spacer></v-spacer>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showMcpServerDialog = true">
新增服务器
</v-btn>
</v-card-title>
<!-- 标签页切换 -->
<v-tabs
v-model="activeTab"
color="primary"
class="mb-4"
show-arrows
>
<v-tab value="local" class="font-weight-medium">
<v-icon start>mdi-server</v-icon>
本地服务器
</v-tab>
<v-tab value="marketplace" class="font-weight-medium">
<v-icon start>mdi-store</v-icon>
MCP 市场
<v-tooltip location="top" activator="parent">
<span>浏览和安装来自社区的 MCP 服务器</span>
</v-tooltip>
</v-tab>
</v-tabs>
<v-divider></v-divider>
<v-window v-model="activeTab">
<!-- 本地服务器标签页内容 -->
<v-window-item value="local">
<!-- MCP 服务器部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-server</v-icon>
<span class="text-h6">MCP 服务器</span>
<v-spacer></v-spacer>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="getServers" :loading="loading">
刷新
</v-btn>
<v-btn color="primary" style="margin-left: 8px;" prepend-icon="mdi-plus" variant="tonal" @click="showMcpServerDialog = true">
新增服务器
</v-btn>
</v-card-title>
<v-card-text class="px-4 py-3">
<v-row v-if="mcpServers.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">暂无 MCP 服务器点击"新增服务器"添加</p>
</v-col>
</v-row>
<v-divider></v-divider>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers" :key="index" cols="12" md="6" lg="4" xl="3">
<v-card class="server-card hover-elevation" :color="server.active ? '' : 'grey-lighten-4'">
<div class="server-status-indicator" :class="{'active': server.active}"></div>
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h6 text-truncate" :title="server.name">{{ server.name }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-switch color="primary" hide-details density="compact" v-model="server.active"
v-bind="props" @update:modelValue="updateServerStatus(server)"></v-switch>
</template>
<span>{{ server.active ? '已启用' : '已禁用' }}</span>
</v-tooltip>
</v-card-title>
<v-card-text class="px-4 py-3">
<v-row v-if="mcpServers.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">暂无 MCP 服务器点击"新增服务器"添加</p>
</v-col>
</v-row>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers" :key="index" cols="12" md="6" lg="4" xl="3">
<v-card class="server-card hover-elevation" :color="server.active ? '' : 'grey-lighten-4'">
<div class="server-status-indicator" :class="{'active': server.active}"></div>
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<div>
<span class="text-h4 text-truncate" :title="server.name">{{ server.name }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<btn class="text-caption text-medium-emphasis" v-if="server.errlogs" v-bind="props">
<v-icon size="small" class="ms-1">mdi-information</v-icon>
日志
</btn>
</template>
<pre>{{ server.errlogs }}</pre>
</v-tooltip>
</div>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-switch color="primary" hide-details density="compact" v-model="server.active"
v-bind="props" @update:modelValue="updateServerStatus(server)"></v-switch>
</template>
<span>{{ server.active ? '已启用' : '已禁用' }}</span>
</v-tooltip>
</v-card-title>
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(server)">
{{ getServerConfigSummary(server) }}
</span>
</div>
<div v-if="server.tools && server.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<span class="text-caption text-medium-emphasis">可用工具 ({{ server.tools.length }})</span>
</div>
<v-chip-group class="tool-chips">
<v-chip v-for="(tool, idx) in server.tools" :key="idx" size="x-small"
density="compact" color="info" class="text-caption">
{{ tool }}
</v-chip>
</v-chip-group>
</div>
<div v-else class="text-caption text-medium-emphasis mt-2">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
无可用工具
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn variant="text" size="small" color="error" prepend-icon="mdi-delete"
@click="deleteServer(server.name)">
删除
</v-btn>
<v-btn variant="text" size="small" color="primary" prepend-icon="mdi-pencil"
@click="editServer(server)">
编辑
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 函数工具部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-function</v-icon>
<span class="text-h6">函数工具</span>
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showTools = !showTools">
{{ showTools ? '收起' : '展开' }}
<v-icon>{{ showTools ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-3" v-if="showTools">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">没有可用的函数工具</p>
</div>
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(server)">
{{ getServerConfigSummary(server) }}
</span>
</div>
<div v-if="server.tools && server.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<span class="text-caption text-medium-emphasis">可用工具 ({{ server.tools.length }})</span>
</div>
<v-chip-group class="tool-chips">
<v-chip v-for="(tool, idx) in server.tools" :key="idx" size="x-small"
density="compact" color="info" class="text-caption">
{{ tool }}
</v-chip>
</v-chip-group>
</div>
<div v-else class="text-caption text-medium-emphasis mt-2">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
无可用工具
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn variant="text" size="small" color="error" prepend-icon="mdi-delete"
@click="deleteServer(server.name)">
删除
</v-btn>
<v-btn variant="text" size="small" color="primary" prepend-icon="mdi-pencil"
@click="editServer(server)">
编辑
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
<div v-else>
<v-text-field
v-model="toolSearch"
prepend-inner-icon="mdi-magnify"
label="搜索函数工具"
variant="outlined"
density="compact"
class="mb-4"
hide-details
clearable
></v-text-field>
<!-- 函数工具部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-function</v-icon>
<span class="text-h6">函数工具</span>
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showTools = !showTools">
{{ showTools ? '收起' : '展开' }}
<v-icon>{{ showTools ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-expansion-panels v-model="openedPanel" multiple>
<v-expansion-panel
v-for="(tool, index) in filteredTools"
:key="index"
:value="index"
class="mb-2 tool-panel"
rounded="lg"
>
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.function.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.function.name">
{{ formatToolName(tool.function.name) }}
</span>
</div>
</v-col>
<v-col cols="9" class="text-grey">
{{ tool.function.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
功能描述
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.function.description }}</p>
<template v-if="tool.function.parameters && tool.function.parameters.properties">
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
参数列表
</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.function.parameters.properties" :key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>此工具没有参数</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
</v-expand-transition>
</v-card>
</v-window-item>
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-3" v-if="showTools">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">没有可用的函数工具</p>
</div>
<div v-else>
<!-- MCP市场标签页内容 -->
<v-window-item value="marketplace">
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-store</v-icon>
<span class="text-h6">MCP 服务器市场</span>
<v-spacer></v-spacer>
<v-text-field
v-model="toolSearch"
v-model="marketplaceSearch"
prepend-inner-icon="mdi-magnify"
label="搜索函数工具"
label="搜索服务器"
variant="outlined"
density="compact"
class="mb-4"
hide-details
class="mx-2"
style="max-width: 300px"
clearable
@update:model-value="searchMarketplaceServers"
></v-text-field>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="text" @click="fetchMarketplaceServers(1)" :loading="marketplaceLoading">
刷新
</v-btn>
</v-card-title>
<v-expansion-panels v-model="openedPanel" multiple>
<v-expansion-panel
v-for="(tool, index) in filteredTools"
:key="index"
:value="index"
class="mb-2 tool-panel"
rounded="lg"
>
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.function.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.function.name">
{{ formatToolName(tool.function.name) }}
</span>
</div>
</v-col>
<v-col cols="9" class="text-grey">
{{ tool.function.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
功能描述
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.function.description }}</p>
<template v-if="tool.function.parameters && tool.function.parameters.properties">
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
参数列表
</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.function.parameters.properties" :key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>此工具没有参数</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
</v-expand-transition>
</v-card>
<v-divider></v-divider>
<v-card-text class="pa-3">
<!-- 加载中 -->
<div v-if="marketplaceLoading" class="text-center pa-8">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
<p class="text-grey mt-4">正在加载 MCP 服务器市场...</p>
</div>
<!-- 无数据 -->
<div v-else-if="filteredMarketplaceServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-store-off</v-icon>
<p class="text-grey mt-4">暂无可用的 MCP 服务器</p>
</div>
<!-- 服务器列表 -->
<v-row v-else>
<v-col v-for="(server, index) in filteredMarketplaceServers" :key="index" cols="12" md="6" lg="4">
<v-card class="marketplace-card hover-elevation" height="100%">
<v-card-title class="d-flex align-center pb-1 pt-3">
<span class="text-h4 text-truncate" :title="server.name">
{{ server.name_h }}({{ server.name }})
</span>
<v-btn
icon="mdi-open-in-new"
variant="text"
color="primary"
class="ms-auto"
@click.stop="openurl(server.origin)"
></v-btn>
</v-card-title>
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<span class="text-caption text-medium-emphasis">
可用工具 ({{ server.tools ? server.tools.length : 0 }})
</span>
</div>
<v-chip-group class="tool-chips mb-2" v-if="server.tools && server.tools.length > 0">
<v-chip v-for="(tool, idx) in server.tools" :key="idx" size="x-small"
density="compact" color="info" class="text-caption">
{{ tool.name }}
</v-chip>
</v-chip-group>
<div v-else class="text-caption text-medium-emphasis mb-2">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
无可用工具信息
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn variant="text" size="small" color="info" prepend-icon="mdi-information-outline"
@click="showServerDetail(server)">
详情
</v-btn>
<v-btn variant="text" size="small" color="primary" prepend-icon="mdi-plus"
@click="importServerConfig(server)">
导入
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- 分页控件 -->
<div class="d-flex justify-center mt-4">
<v-pagination
v-if="!marketplaceLoading && totalMarketPages > 1"
v-model="currentMarketPage"
:length="totalMarketPages"
total-visible="7"
rounded
@update:model-value="changePage"
></v-pagination>
</div>
</v-card-text>
</v-card>
</v-window-item>
</v-window>
</v-container>
<!-- 添加/编辑 MCP 服务器对话框 -->
@@ -277,6 +427,7 @@
使用模板
</v-btn>
</div>
<small> 某些 MCP 服务器可能需要按照其要求在 `env` 中填充 `API_KEY` `TOKEN` 等信息请注意检查是否填写</small>
<div class="monaco-container">
<VueMonacoEditor
@@ -324,6 +475,119 @@
</v-card>
</v-dialog>
<!-- 服务器详情对话框 -->
<v-dialog v-model="showServerDetailDialog" max-width="800px">
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">mdi-information-outline</v-icon>
<span>服务器详情</span>
<v-spacer></v-spacer>
<v-btn icon variant="text" color="white" @click="showServerDetailDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text v-if="selectedMarketplaceServer" class="py-4">
<h2 class="text-h5 mb-3">{{ selectedMarketplaceServer.name }}</h2>
<!-- <v-alert color="info" variant="tonal" class="mb-3">
{{ selectedMarketplaceServer.AbstractCN || selectedMarketplaceServer.Abstract || '暂无描述' }}
</v-alert> -->
<div class="mb-4">
<h3 class="text-subtitle-1 font-weight-bold mb-2">安装配置</h3>
<div class="monaco-container" style="height: 200px">
<VueMonacoEditor
v-model:value="selectedServerConfigDisplay"
theme="vs-dark"
language="json"
:options="{
readOnly: true,
minimap: {
enabled: false
},
scrollBeyondLastLine: false,
automaticLayout: true,
lineNumbers: 'on',
tabSize: 2
}"
/>
</div>
</div>
<div v-if="selectedMarketplaceServer.tools && selectedMarketplaceServer.tools.length > 0">
<h3 class="text-subtitle-1 font-weight-bold mb-2">
可用工具
<v-chip color="info" size="small" class="ml-1">{{ selectedMarketplaceServer.tools.length }}</v-chip>
</h3>
<v-expansion-panels>
<v-expansion-panel
v-for="(tool, index) in selectedMarketplaceServer.tools"
:key="index"
class="mb-2"
>
<v-expansion-panel-title>
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">mdi-function-variant</v-icon>
<span class="font-weight-medium">{{ tool.name }}</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<p class="mb-3">{{ tool.description }}</p>
<template v-if="tool.inputSchema && tool.inputSchema.properties">
<h4 class="text-subtitle-2 mb-2">参数列表</h4>
<v-table density="compact">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>必填</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.inputSchema.properties" :key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text>
{{ param.type }}
</v-chip>
</td>
<td>
<v-icon v-if="tool.inputSchema.required && tool.inputSchema.required.includes(paramName)"
color="error" size="small">
mdi-check
</v-icon>
<span v-else></span>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showServerDetailDialog = false">
关闭
</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" @click="importServerConfig(selectedMarketplaceServer)">
导入配置
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
@@ -345,9 +609,11 @@ export default {
},
data() {
return {
activeTab: 'local', // 当前激活的标签页
mcpServers: [],
tools: [],
showMcpServerDialog: false,
showServerDetailDialog: false,
showTools: true,
loading: false,
isEditMode: false,
@@ -363,6 +629,19 @@ export default {
save_message_success: "success",
toolSearch: '',
openedPanel: [], // 存储打开的面板索引
// MCP 市场相关
marketplaceServers: [],
marketplaceLoading: false,
marketplaceSearch: '',
selectedMarketplaceServer: null,
selectedServerConfigDisplay: '',
// 分页相关
currentMarketPage: 1,
marketPageSize: 9, // 每页显示9个服务器,适合3列布局
totalMarketPages: 1,
totalMarketItems: 0,
}
},
@@ -399,23 +678,31 @@ export default {
return '未设置配置';
}
}
},
// 过滤后的市场服务器
filteredMarketplaceServers() {
return this.marketplaceServers;
},
},
mounted() {
this.getServers();
this.getTools();
this.fetchMarketplaceServers();
// 定期刷新本地服务器列表
setInterval(() => {
this.getServers();
this.getTools();
}, 5000); // 每 5 秒刷新一次服务器列表
}, 5000);
},
methods: {
openurl(url) {
window.open(url, '_blank');
},
formatToolName(name) {
if (name.includes(':')) {
// MCP 工具通常命名为 mcp:server:tool
@@ -430,11 +717,13 @@ export default {
axios.get('/api/tools/mcp/servers')
.then(response => {
this.mcpServers = response.data.data || [];
this.loading = false
})
.catch(error => {
this.showError("获取 MCP 服务器列表失败: " + error.message);
this.loading = false
}).finally(() => {
setTimeout(() => {
this.loading = false;
}, 500);
});
},
@@ -533,9 +822,14 @@ export default {
const configCopy = { ...server };
// 移除基本字段,只保留配置相关字段
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
try {
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
delete configCopy.errlogs;
} catch (e) {
console.error("Error removing basic fields: ", e);
}
// 设置当前服务器的基本信息
this.currentServer = {
@@ -590,6 +884,117 @@ export default {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
},
// MCP 市场相关方法
// 获取市场服务器列表
fetchMarketplaceServers(page = 1) {
this.marketplaceLoading = true;
// 构建请求参数
const params = {
page: page,
page_size: this.marketPageSize
};
// 如果有搜索关键词,添加到请求参数
if (this.marketplaceSearch.trim()) {
params.search = this.marketplaceSearch.trim();
}
axios.get('/api/tools/mcp/market', { params })
.then(response => {
this.marketplaceServers = response.data.data.mcpservers || [];
// 更新分页信息
if (response.data.data.pagination) {
this.totalMarketItems = response.data.data.pagination.total || 0;
this.totalMarketPages = response.data.data.pagination.totalPages || 1;
this.currentMarketPage = response.data.data.pagination.currentPage || 1;
} else {
// 如果后端没有返回分页信息,根据返回的数据量估算
this.totalMarketPages = Math.ceil(this.marketplaceServers.length / this.marketPageSize) || 1;
}
this.marketplaceLoading = false;
})
.catch(error => {
this.showError("获取 MCP 市场服务器列表失败: " + error.message);
this.marketplaceLoading = false;
});
},
// 搜索市场服务器
searchMarketplaceServers() {
// 重置到第一页,然后获取结果
this.currentMarketPage = 1;
this.fetchMarketplaceServers(1);
},
// 切换分页
changePage(page) {
this.fetchMarketplaceServers(page);
},
// 显示服务器详情
showServerDetail(server) {
this.selectedMarketplaceServer = server;
// 格式化服务器配置的显示
try {
if (server.config) {
const configs = JSON.parse(server.config);
this.selectedServerConfigDisplay = JSON.stringify(configs[0], null, 2);
} else {
this.selectedServerConfigDisplay = '// 无可用配置';
}
} catch (e) {
this.selectedServerConfigDisplay = '// 配置解析错误: ' + e.message;
}
this.showServerDetailDialog = true;
},
// 导入服务器配置
importServerConfig(server) {
try {
// 解析服务器配置
if (!server.config) {
this.showError('此服务器没有可用配置');
return;
}
const configs = JSON.parse(server.config);
if (!configs || !configs[0] || !configs[0].mcpServers) {
this.showError('服务器配置格式不正确');
return;
}
// 找到服务器名称和配置
const serverName = server.name;
const serverConfig = configs[0]
// 设置表单数据
this.currentServer = {
name: serverName,
active: true,
tools: []
};
// 设置配置JSON
this.serverConfigJson = JSON.stringify(serverConfig, null, 2);
// 关闭详情对话框(如果打开的话)
this.showServerDetailDialog = false;
// 打开添加服务器对话框
this.isEditMode = false;
this.showMcpServerDialog = true;
} catch (e) {
this.showError('导入配置失败: ' + e.message);
}
}
}
}
@@ -653,6 +1058,21 @@ export default {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 4px;
height: 300px;
margin-top: 4px;
overflow: hidden;
}
.marketplace-card {
position: relative;
border-radius: 8px;
transition: all 0.3s ease;
}
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
</style>