Compare commits
67 Commits
Author | SHA1 | Date |
---|---|---|
|
1dbff6da26 | |
|
f0dce963c2 | |
|
0b0befebd6 | |
|
79dd295d0c | |
|
2f937aee3e | |
|
a9012044a3 | |
|
770c52764b | |
|
d0c36fae5f | |
|
c29eb85f0c | |
|
b66cbfc6dc | |
|
e5006838ed | |
|
fbb868b244 | |
|
6b2a48b17d | |
|
f323d1c2ac | |
|
d5b80a4a94 | |
|
439c7a2063 | |
|
b7633d2f1e | |
|
110a64fb6a | |
|
3fce96d314 | |
|
869c54ec6e | |
|
acfc07b3fe | |
|
2fbd89050e | |
|
f161f19601 | |
|
7829d997cc | |
|
c4ec9789e1 | |
|
6268e06503 | |
|
f888a96294 | |
|
5f727825d0 | |
|
06d4429c38 | |
|
2ee3aff93e | |
|
b563530670 | |
|
0271cfe4ba | |
|
548d4aeee8 | |
|
f4b582bce9 | |
|
01872d160f | |
|
364082b1bd | |
|
bc73912973 | |
|
c933b80408 | |
|
2edd0d91b9 | |
|
ac779284e9 | |
|
bd5f382ce7 | |
|
7f5a3b4da6 | |
|
34fd8fb020 | |
|
fae1d3daed | |
|
3ecb0726c2 | |
|
20b56e3749 | |
|
f5de0603ba | |
|
c258761ab0 | |
|
05a42870c6 | |
|
8cb2e5a522 | |
|
d5cf356f1e | |
|
ae4c1cc798 | |
|
a59280a643 | |
|
dc31c6d206 | |
|
2c3f772049 | |
|
77a7d30237 | |
|
1880ad93ab | |
|
2faf2eb949 | |
|
724ecacebf | |
|
ef8700cc9e | |
|
4c72fefd09 | |
|
12ba370a49 | |
|
1ee16ad9e9 | |
|
4a91b528f9 | |
|
485f70e0a0 | |
|
90eb1eae8e | |
|
988d341880 |
|
@ -0,0 +1,55 @@
|
|||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ '*.*.*' ]
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Extract Version
|
||||
id: version_step
|
||||
run: |
|
||||
echo "##[set-output name=version;]NACOS_VERSION=${GITHUB_REF#$"refs/tags/v"}"
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: nacos/nacos-mcp-router
|
||||
- name: show image tags
|
||||
run: |
|
||||
echo "tags: ${{ steps.meta.outputs.tags }}, labels: ${{ steps.meta.outputs.labels }}, build-args: ${{steps.version_step.outputs.version}}"
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: src/python/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: ${{steps.version_step.outputs.version}}
|
|
@ -176,4 +176,10 @@ src/typescript/node_modules
|
|||
.vscode/
|
||||
getting-started/
|
||||
my_hnsw_*.*
|
||||
.DS_Store
|
||||
|
||||
# TypeScript Test
|
||||
src/typescript/coverage
|
||||
src/typescript/test-results
|
||||
src/typescript/playwright-report
|
||||
|
||||
package-lock.json
|
231
README.md
231
README.md
|
@ -1,43 +1,103 @@
|
|||
# nacos-mcp-router: 一个提供MCP Server推荐、分发、安装及代理功能的MCP Server.
|
||||
|
||||
## 概述
|
||||
# nacos-mcp-router: A MCP server that provides functionalities such as search, installation, proxy, and more.
|
||||
[](https://modelcontextprotocol.org)
|
||||
[](https://deepwiki.com/nacos-group/nacos-mcp-router)
|
||||
 
|
||||
|
||||
[Nacos](https://nacos.io) 一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。Nacos提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理
|
||||
<p>
|
||||
<a href="./README.md">English</a> | <a href="./README_cn.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
Nacos-MCP-Router是一个基于MCP官方标准SDK实现的的MCP Server。它提供了一组工具,提供MCP Server推荐、分发、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
|
||||
## Overview
|
||||
|
||||
[Nacos](https://nacos.io) is an easy-to-use platform designed for dynamic service discovery and configuration and service management. It helps you to build cloud native applications and microservices platform easily.
|
||||
|
||||
This MCP(Model Context Protocol) Server provides tools to search, install, proxy other MCP servers, with advanced search capabilities including vector similarity search and multi-provider result aggregation.
|
||||
|
||||
## Python版接入
|
||||
Nacos-MCP-Routery有两种工作模式:
|
||||
1. router模式:默认模式,通过MCP Server推荐、分发、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
|
||||
2. prroxy模式:使用环境变量MODE=proxy指定,通过简单配置可以把sse、stdio协议MCP Server转换为streamableHTTP协议MCP Server。
|
||||
### router模式
|
||||
#### Tools
|
||||
Nacos-MCP-Router has two working modes:
|
||||
|
||||
Router mode: The default mode, which recommends, distributes, installs, and proxies the functions of other MCP Servers through the MCP Server, helping users more conveniently utilize MCP Server services.
|
||||
|
||||
Proxy mode: Specified by the environment variable MODE=proxy, it can convert SSE and stdio protocol MCP Servers into streamable HTTP protocol MCP Servers through simple configuration.
|
||||
|
||||
## Search Features
|
||||
|
||||
Nacos-MCP-Router provides powerful search capabilities through multiple providers:
|
||||
|
||||
### Search Providers
|
||||
|
||||
1. **Nacos Provider**
|
||||
- Searches MCP servers using Nacos service discovery
|
||||
- Supports keyword matching and vector similarity search
|
||||
- Integrated with the local Nacos instance
|
||||
|
||||
2. **Compass Provider**
|
||||
- Connects to a COMPASS API endpoint for enhanced search
|
||||
- Supports semantic search and relevance scoring
|
||||
- Configurable API endpoint (default: https://registry.mcphub.io)
|
||||
|
||||
### Search Configuration
|
||||
|
||||
Configure search behavior using environment variables:
|
||||
|
||||
```bash
|
||||
# YOUR COMPASS API endpoint (for Outer Provider called Compass Provider)
|
||||
COMPASS_API_BASE=https://registry.mcphub.io
|
||||
|
||||
# Minimum similarity score for results (0.0 to 1.0)
|
||||
SEARCH_MIN_SIMILARITY=0.5
|
||||
|
||||
# Maximum number of results to return
|
||||
SEARCH_RESULT_LIMIT=10
|
||||
```
|
||||
|
||||
### Search API
|
||||
|
||||
The search functionality is available through the MCP interface:
|
||||
|
||||
```typescript
|
||||
// Search for MCP servers
|
||||
const results = await searchMcpServer(
|
||||
"Find MCP servers for natural language processing",
|
||||
["nlp", "language"]
|
||||
);
|
||||
```
|
||||
|
||||
Results include:
|
||||
- Server name and description
|
||||
- Provider information
|
||||
- Relevance score
|
||||
- Additional metadata
|
||||
|
||||
## Quick Start
|
||||
### Python
|
||||
#### router mode
|
||||
##### Tools
|
||||
|
||||
1. `search_mcp_server`
|
||||
- 根据任务描述及关键字从MCP注册中心(Nacos)中搜索相关的MCP Server列表
|
||||
- 输入:
|
||||
- `task_description`(string): 任务描述,示例:今天杭州天气如何
|
||||
- `key_words`(string): 任务关键字,示例:天气、杭州
|
||||
- 输出: list of MCP servers and instructions to complete the task.
|
||||
- Search MCP servers by task and keywords.
|
||||
- Input:
|
||||
- `task_description`(string): Task description
|
||||
- `key_words`(string): Keywords of task
|
||||
- Returns: list of MCP servers and instructions to complete the task.
|
||||
2. `add_mcp_server`
|
||||
- 添加并初始化一个MCP Server,根据Nacos中的配置与该MCP Server建立连接,等待调用。
|
||||
- 输入:
|
||||
- `mcp_server_name`(string): 需要添加的MCP Server名字
|
||||
- 输出: MCP Server工具列表及使用方法
|
||||
- Add a MCP server. If the MCP server is a stdio server, this tool will install it and establish connection to it. If the MCP server is a sse server, this tool will establish connection to it
|
||||
- Input:
|
||||
- `mcp_server_name`(string): The name of MCP server.
|
||||
- Returns: tool list of the MCP server and how to use these tools.
|
||||
3. `use_tool`
|
||||
- 代理其他MCP Server的工具
|
||||
- 输入:
|
||||
- `mcp_server_name`(string): 被调的目标MCP Server名称.
|
||||
- `mcp_tool_name`(string): 被调的目标MCP Server的工具名称
|
||||
- `params`(map): 被调的目标MCP Server的工具的参数
|
||||
- 输出: 被调的目标MCP Server的工具的输出结果
|
||||
- This tool helps LLM to use the tool of some MCP server. It will proxy requests to the target MCP server.
|
||||
- Input:
|
||||
- `mcp_server_name`(string): The target MCP server name that LLM wants to call.
|
||||
- `mcp_tool_name`(string): The tool name of target MCP server that LLM wants to call.
|
||||
- `params`(map): The parameters of the MCP tool.
|
||||
- Returns: Result returned from the target MCP server.
|
||||
|
||||
#### 使用
|
||||
##### 使用 uv
|
||||
如果使用 [`uv`](https://docs.astral.sh/uv/) 无须安装额外的依赖, 使用
|
||||
use [`uvx`](https://docs.astral.sh/uv/guides/tools/) 直接运行 *nacos-mcp-router*。
|
||||
##### Usage
|
||||
###### Using uv (recommended)
|
||||
|
||||
When using [`uv`](https://docs.astral.sh/uv/) no specific installation is needed. We will
|
||||
use [`uvx`](https://docs.astral.sh/uv/guides/tools/) to directly run *nacos-mcp-router*.
|
||||
```
|
||||
export NACOS_ADDR=127.0.0.1:8848
|
||||
export NACOS_USERNAME=nacos
|
||||
|
@ -45,33 +105,33 @@ export NACOS_PASSWORD=$PASSWORD
|
|||
uvx nacos-mcp-router@latest
|
||||
```
|
||||
|
||||
##### 使用 PIP
|
||||
###### Using PIP
|
||||
|
||||
此外,你也可以通过pip安装 `nacos-mcp-router` :
|
||||
Alternatively you can install `nacos-mcp-router` via pip:
|
||||
|
||||
```
|
||||
pip install nacos-mcp-router
|
||||
```
|
||||
|
||||
安装完成后,使用如下命令运行(以Nacos本地standalone模式部署为例):
|
||||
After installation, you can run it as a script using(As an example,Nacos is deployed in standalone mode on the local machine):
|
||||
|
||||
```
|
||||
export NACOS_ADDR=127.0.0.1:8848
|
||||
export NACOS_USERNAME=nacos
|
||||
export NACOS_PASSWORD=$PASSWORD
|
||||
python -m nacos-mcp-router
|
||||
python -m nacos_mcp_router
|
||||
```
|
||||
|
||||
##### 使用docker
|
||||
###### Using Docker
|
||||
```
|
||||
docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$NACOS_USERNAME -e NACOS_PASSWORD=$NACOS_PASSWORD -e TRANSPORT_TYPE=$TRANSPORT_TYPE nacos-mcp-router:latest
|
||||
docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$NACOS_USERNAME -e NACOS_PASSWORD=$NACOS_PASSWORD -e TRANSPORT_TYPE=$TRANSPORT_TYPE nacos/nacos-mcp-router:latest
|
||||
```
|
||||
|
||||
##### 使用Cline、Cursor、Claude等
|
||||
###### Usage with Cline、Cursor、Claude and other applications
|
||||
|
||||
添加MCP Server配置如下:
|
||||
Add this to MCP settings of your application:
|
||||
|
||||
###### 使用 uvx
|
||||
* Using uvx
|
||||
|
||||
```json
|
||||
{
|
||||
|
@ -86,84 +146,60 @@ docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$N
|
|||
],
|
||||
"env":
|
||||
{
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, 选填,默认为127.0.0.1:8848",
|
||||
"NACOS_USERNAME": "<NACOS-USERNAME>, 选填,默认为nacos",
|
||||
"NACOS_PASSWORD": "<NACOS-PASSWORD>, 必填"
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, optional, default is 127.0.0.1:8848",
|
||||
"NACOS_USERNAME": "<NACOS-USERNAME>, optional, default is nacos",
|
||||
"NACOS_PASSWORD": "<NACOS-PASSWORD>, required"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 如果启动失败,你需要把`command`字段里的`uvx`替换为命令的全路径。`uvx`命令全路径查找方法为:MacOS或Linux系统下使用`which uvx`,Windows系统使用`where uvx`。
|
||||
> You may need to put the full path to the `uvx` executable in the `command` field. You can get this by running `which uvx` on MacOS/Linux or `where uvx` on Windows.
|
||||
|
||||
###### 使用 docker
|
||||
* Using docker
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nacos-mcp-router": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run", "-i", "--rm", "--network", "host", "-e", "NACOS_ADDR=<NACOS-ADDR>", "-e", "NACOS_USERNAME=<NACOS-USERNAME>", "-e", "NACOS_PASSWORD=<NACOS-PASSWORD>" ,"-e", "TRANSPORT_TYPE=stdio", "nacos-mcp-router:latest"
|
||||
"run", "-i", "--rm", "--network", "host", "-e", "NACOS_ADDR=<NACOS-ADDR>", "-e", "NACOS_USERNAME=<NACOS-USERNAME>", "-e", "NACOS_PASSWORD=<NACOS-PASSWORD>" ,"-e", "TRANSPORT_TYPE=stdio", "nacos/nacos-mcp-router:latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### proxy模式
|
||||
proxy模式支持把sse、stdio协议MCP Server转换为streamableHTTP协议MCP Server。
|
||||
#### 使用
|
||||
proxy模式的使用与router类似,参数略有不同,增加环境变量:`MODE=proxy, PROXIED_MCP_NAME=$PROXIED_MCP_NAME`, 建议使用docker部署。
|
||||
#### Proxy Mode
|
||||
The proxy mode supports converting SSE and stdio protocol MCP Servers into streamable HTTP protocol MCP Servers.
|
||||
|
||||
##### Usage
|
||||
The usage of proxy mode is similar to that of router mode, with slightly different parameters. Docker deployment is recommended.
|
||||
```
|
||||
docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$NACOS_USERNAME -e NACOS_PASSWORD=$NACOS_PASSWORD -e TRANSPORT_TYPE=streamable_http -e MODE=proxy -e PROXIED_MCP_NAME=$PROXIED_MCP_NAME nacos-mcp-router:latest
|
||||
docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$NACOS_USERNAME -e NACOS_PASSWORD=$NACOS_PASSWORD -e TRANSPORT_TYPE=streamable_http -e MODE=proxy -e PROXIED_MCP_NAME=$PROXIED_MCP_NAME nacos/nacos-mcp-router:latest
|
||||
```
|
||||
|
||||
### 开发
|
||||
#### Environment Variable Settings
|
||||
|
||||
本地开发步骤如下:
|
||||
| Parameter | Description | Default Value | Required | Remarks |
|
||||
|-----------|------------------------------------------------------------|---------------|----------|-----------------------------------------------------------------------------------------------|
|
||||
| NACOS_ADDR | Nacos server address | 127.0.0.1:8848 | No | the Nacos server address, e.g., 192.168.1.1:8848. Note: Include the port. |
|
||||
| NACOS_USERNAME | Nacos username | nacos | No | the Nacos username, e.g., nacos. |
|
||||
| NACOS_PASSWORD | Nacos password | - | Yes | the Nacos password, e.g., nacos. |
|
||||
| COMPASS_API_BASE | COMPASS API endpoint for enhanced search | https://registry.mcphub.io | No | Override the default COMPASS API endpoint |
|
||||
| SEARCH_MIN_SIMILARITY | Minimum similarity score (0.0-1.0) | 0.5 | No | Filter search results by minimum similarity score |
|
||||
| SEARCH_RESULT_LIMIT | Maximum number of results to return | 10 | No | Limit the number of search results |
|
||||
|NACOS_NAMESPACE| Nacos Namespace | public | No | Nacos namespace, e.g. public |
|
||||
| TRANSPORT_TYPE | Transport protocol type | stdio | No | transport protocol type. Options: stdio, sse, streamable_http. |
|
||||
| PROXIED_MCP_NAME | Proxied MCP server name | - | No | In proxy mode, specify the MCP server name to be converted. Must be registered in Nacos first. |
|
||||
| MODE | Working mode | router | No | Available options: router, proxy. |
|
||||
| PORT| Service port when TRANSPORT_TYPE is sse or streamable_http | 8000| No | |
|
||||
|ACCESS_KEY_ID | Aliyun ram access key id| - | No | |
|
||||
|ACCESS_KEY_SECRET | Aliyun ram access key secret | - | No | |
|
||||
|
||||
1. 克隆仓库;
|
||||
2. 修改代码;
|
||||
3. 在Cline等工具中测试功能:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nacos-mcp-router": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory","PATH-TO-PROJECT","run","nacos-mcp-router"
|
||||
],
|
||||
"env": {
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, 选填,默认为127.0.0.1:8848",
|
||||
"NACOS_USERNAME": "<NACOS-USERNAME>, 选填,默认为nacos",
|
||||
"NACOS_PASSWORD": "<NACOS-PASSWORD>, 必填"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量设置
|
||||
| | | | | |
|
||||
|----|----|----|----|----|
|
||||
| 参数 | 描述 | 默认值 | 是否必填 | 备注 |
|
||||
| NACOS_ADDR | Nacos 服务器地址 | 127.0.0.1:8848 | 否 | 填写 Nacos 服务器的地址,如 192.168.1.1:8848,注意要写端口
|
||||
| NACOS_USERNAME | Nacos 用户名 | nacos | 否 | 填写 Nacos 用户名,如 nacos
|
||||
| NACOS_PASSWORD | Nacos 密码 | 密码 | 是 | 填写 Nacos 密码,如 nacos
|
||||
| TRANSPORT_TYPE | 传输协议类型 | stdio | 否 | 填写传输协议类型,可选值:stdio、sse、streamable_http
|
||||
| PROXIED_MCP_NAME | 代理的 MCP 服务器名称 | - | 否 | proxy模式下需要被转换的 MCP 服务器名称,需要先注册到Nacos
|
||||
| MODE | 工作模式 | router | 否 |可选的值:router、proxy |
|
||||
|
||||
### [常见问题](./src/python/docs/troubleshooting.md)
|
||||
|
||||
|
||||
## Typescript接入
|
||||
|
||||
### 配置
|
||||
|
||||
在 MCP 客户端(如 Cursor、Cline 等)中添加如下配置:
|
||||
### typescript
|
||||
#### Usage with Cline、Cursor、Claude and other applications
|
||||
|
||||
```json
|
||||
{
|
||||
|
@ -174,14 +210,15 @@ docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$N
|
|||
"nacos-mcp-router@latest"
|
||||
],
|
||||
"env": {
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, 选填,默认为127.0.0.1:8848",
|
||||
"NACOS_USERNAME": "<NACOS-USERNAME>, 选填,默认为nacos",
|
||||
"NACOS_PASSWORD": "<NACOS-PASSWORD>, 必填"
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, optional, default is 127.0.0.1:8848",
|
||||
"NACOS_USERNAME": "<NACOS-USERNAME>, optional, default is nacos",
|
||||
"NACOS_PASSWORD": "<NACOS-PASSWORD>, required"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 许可证
|
||||
nacos-mcp-router 使用 Apache 2.0 许可证. 这意味着您可以自由地使用、修改和分发该软件,但需遵守 Apache 2.0 许可证的条款和条件。更多详细信息,请参阅项目仓库中的 LICENSE 文件
|
||||
## License
|
||||
|
||||
nacos-mcp-router is licensed under the Apache 2.0 License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the Apache 2.0 License. For more details, please see the `LICENSE` file in the project repository.
|
||||
|
|
|
@ -0,0 +1,199 @@
|
|||
# nacos-mcp-router: 一个提供MCP Server推荐、分发、安装及代理功能的MCP Server.
|
||||
|
||||
[](https://modelcontextprotocol.org)
|
||||
[](https://deepwiki.com/nacos-group/nacos-mcp-router)
|
||||
 
|
||||
|
||||
<p>
|
||||
<a href="./README.md">English</a> | <a href="./README_cn.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## 概述
|
||||
|
||||
[Nacos](https://nacos.io) 一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。Nacos提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理
|
||||
|
||||
Nacos-MCP-Router是一个基于MCP官方标准SDK实现的的MCP Server。它提供了一组工具,提供MCP Server推荐、分发、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
|
||||
|
||||
|
||||
|
||||
## Python版接入
|
||||
Nacos-MCP-Router有两种工作模式:
|
||||
1. router模式:默认模式,通过MCP Server推荐、分发、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
|
||||
2. prroxy模式:使用环境变量MODE=proxy指定,通过简单配置可以把sse、stdio协议MCP Server转换为streamableHTTP协议MCP Server。
|
||||
### router模式
|
||||
#### Tools
|
||||
|
||||
1. `search_mcp_server`
|
||||
- 根据任务描述及关键字从MCP注册中心(Nacos)中搜索相关的MCP Server列表
|
||||
- 输入:
|
||||
- `task_description`(string): 任务描述,示例:今天杭州天气如何
|
||||
- `key_words`(string): 任务关键字,示例:天气、杭州
|
||||
- 输出: list of MCP servers and instructions to complete the task.
|
||||
2. `add_mcp_server`
|
||||
- 添加并初始化一个MCP Server,根据Nacos中的配置与该MCP Server建立连接,等待调用。
|
||||
- 输入:
|
||||
- `mcp_server_name`(string): 需要添加的MCP Server名字
|
||||
- 输出: MCP Server工具列表及使用方法
|
||||
3. `use_tool`
|
||||
- 代理其他MCP Server的工具
|
||||
- 输入:
|
||||
- `mcp_server_name`(string): 被调的目标MCP Server名称.
|
||||
- `mcp_tool_name`(string): 被调的目标MCP Server的工具名称
|
||||
- `params`(map): 被调的目标MCP Server的工具的参数
|
||||
- 输出: 被调的目标MCP Server的工具的输出结果
|
||||
|
||||
#### 使用
|
||||
##### 使用 uv
|
||||
如果使用 [`uv`](https://docs.astral.sh/uv/) 无须安装额外的依赖, 使用
|
||||
use [`uvx`](https://docs.astral.sh/uv/guides/tools/) 直接运行 *nacos-mcp-router*。
|
||||
```
|
||||
export NACOS_ADDR=127.0.0.1:8848
|
||||
export NACOS_USERNAME=nacos
|
||||
export NACOS_PASSWORD=$PASSWORD
|
||||
uvx nacos-mcp-router@latest
|
||||
```
|
||||
|
||||
##### 使用 PIP
|
||||
|
||||
此外,你也可以通过pip安装 `nacos-mcp-router` :
|
||||
|
||||
```
|
||||
pip install nacos-mcp-router
|
||||
```
|
||||
|
||||
安装完成后,使用如下命令运行(以Nacos本地standalone模式部署为例):
|
||||
|
||||
```
|
||||
export NACOS_ADDR=127.0.0.1:8848
|
||||
export NACOS_USERNAME=nacos
|
||||
export NACOS_PASSWORD=$PASSWORD
|
||||
python -m nacos_mcp_router
|
||||
```
|
||||
|
||||
##### 使用docker
|
||||
```
|
||||
docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$NACOS_USERNAME -e NACOS_PASSWORD=$NACOS_PASSWORD -e TRANSPORT_TYPE=$TRANSPORT_TYPE nacos-mcp-router:latest
|
||||
```
|
||||
|
||||
##### 使用Cline、Cursor、Claude等
|
||||
|
||||
添加MCP Server配置如下:
|
||||
|
||||
###### 使用 uvx
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers":
|
||||
{
|
||||
"nacos-mcp-router":
|
||||
{
|
||||
"command": "uvx",
|
||||
"args":
|
||||
[
|
||||
"nacos-mcp-router@latest"
|
||||
],
|
||||
"env":
|
||||
{
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, 选填,默认为127.0.0.1:8848",
|
||||
"NACOS_USERNAME": "<NACOS-USERNAME>, 选填,默认为nacos",
|
||||
"NACOS_PASSWORD": "<NACOS-PASSWORD>, 必填"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 如果启动失败,你需要把`command`字段里的`uvx`替换为命令的全路径。`uvx`命令全路径查找方法为:MacOS或Linux系统下使用`which uvx`,Windows系统使用`where uvx`。
|
||||
|
||||
###### 使用 docker
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nacos-mcp-router": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run", "-i", "--rm", "--network", "host", "-e", "NACOS_ADDR=<NACOS-ADDR>", "-e", "NACOS_USERNAME=<NACOS-USERNAME>", "-e", "NACOS_PASSWORD=<NACOS-PASSWORD>" ,"-e", "TRANSPORT_TYPE=stdio", "nacos-mcp-router:latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### proxy模式
|
||||
proxy模式支持把sse、stdio协议MCP Server转换为streamableHTTP协议MCP Server。
|
||||
#### 使用
|
||||
proxy模式的使用与router类似,参数略有不同,增加环境变量:`MODE=proxy, PROXIED_MCP_NAME=$PROXIED_MCP_NAME`, 建议使用docker部署。
|
||||
```
|
||||
docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$NACOS_USERNAME -e NACOS_PASSWORD=$NACOS_PASSWORD -e TRANSPORT_TYPE=streamable_http -e MODE=proxy -e PROXIED_MCP_NAME=$PROXIED_MCP_NAME nacos-mcp-router:latest
|
||||
```
|
||||
|
||||
### 开发
|
||||
|
||||
本地开发步骤如下:
|
||||
|
||||
1. 克隆仓库;
|
||||
2. 修改代码;
|
||||
3. 在Cline等工具中测试功能:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nacos-mcp-router": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory","PATH-TO-PROJECT","run","nacos-mcp-router"
|
||||
],
|
||||
"env": {
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, 选填,默认为127.0.0.1:8848",
|
||||
"NACOS_USERNAME": "<NACOS-USERNAME>, 选填,默认为nacos",
|
||||
"NACOS_PASSWORD": "<NACOS-PASSWORD>, 必填"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量设置
|
||||
| | | | | |
|
||||
|----|---------------|----------------|------|-------------------------------------------|
|
||||
| 参数 | 描述 | 默认值 | 是否必填 | 备注 |
|
||||
| NACOS_ADDR | Nacos 服务器地址 | 127.0.0.1:8848 | 否 | 填写 Nacos 服务器的地址,如 192.168.1.1:8848,注意要写端口 |
|
||||
| NACOS_USERNAME | Nacos 用户名 | nacos | 否 | 填写 Nacos 用户名,如 nacos |
|
||||
| NACOS_PASSWORD | Nacos 密码 | 密码 | 是 | 填写 Nacos 密码,如 nacos |
|
||||
|NACOS_NAMESPACE| Nacos命名空间 | public | 否 | Nacos命名空间,如 public |
|
||||
| TRANSPORT_TYPE | 传输协议类型 | stdio | 否 | 填写传输协议类型,可选值:stdio、sse、streamable_http |
|
||||
| PROXIED_MCP_NAME | 代理的 MCP 服务器名称 | - | 否 | proxy模式下需要被转换的 MCP 服务器名称,需要先注册到Nacos |
|
||||
| MODE | 工作模式 | router | 否 | 可选的值:router、proxy |
|
||||
| PORT | 服务端口 | 8000 | 否 | 协议类型为sse或streamable时使用 |
|
||||
|ACCESS_KEY_ID | Aliyun ram access key id| - | 否 | |
|
||||
|ACCESS_KEY_SECRET | Aliyun ram access key secret | - | 否 | |
|
||||
|
||||
### [常见问题](./src/python/docs/troubleshooting.md)
|
||||
|
||||
|
||||
## Typescript接入
|
||||
|
||||
### 配置
|
||||
|
||||
在 MCP 客户端(如 Cursor、Cline 等)中添加如下配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nacos-mcp-router": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"nacos-mcp-router@latest"
|
||||
],
|
||||
"env": {
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, 选填,默认为127.0.0.1:8848",
|
||||
"NACOS_USERNAME": "<NACOS-USERNAME>, 选填,默认为nacos",
|
||||
"NACOS_PASSWORD": "<NACOS-PASSWORD>, 必填"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 许可证
|
||||
nacos-mcp-router 使用 Apache 2.0 许可证. 这意味着您可以自由地使用、修改和分发该软件,但需遵守 Apache 2.0 许可证的条款和条件。更多详细信息,请参阅项目仓库中的 LICENSE 文件
|
|
@ -1,20 +1,19 @@
|
|||
FROM python:3.12-slim
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y build-essential curl && apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install uv
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
RUN apt-get install -y nodejs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
curl nodejs npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# 复制项目文件
|
||||
COPY pyproject.toml .
|
||||
COPY README.md .
|
||||
COPY src/ src/
|
||||
COPY src/python .
|
||||
|
||||
RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2/
|
||||
RUN curl -v https://chroma-onnx-models.s3.amazonaws.com/all-MiniLM-L6-v2/onnx.tar.gz -o /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2/onnx.tar.gz
|
||||
|
||||
# 安装 Python 依赖
|
||||
# RUN pip install nacos-mcp-router==${ROUTER_VERSION}
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# 启动服务
|
||||
|
|
|
@ -154,14 +154,17 @@ If you are doing local development, simply follow the steps:
|
|||
|
||||
## Environment Variable Settings
|
||||
|
||||
| Parameter | Description | Default Value | Required | Remarks |
|
||||
|-----------|-------------|---------------|----------|---------|
|
||||
| NACOS_ADDR | Nacos server address | 127.0.0.1:8848 | No | the Nacos server address, e.g., 192.168.1.1:8848. Note: Include the port. |
|
||||
| NACOS_USERNAME | Nacos username | nacos | No | the Nacos username, e.g., nacos. |
|
||||
| NACOS_PASSWORD | Nacos password | - | Yes | the Nacos password, e.g., nacos. |
|
||||
| TRANSPORT_TYPE | Transport protocol type | stdio | No | transport protocol type. Options: stdio, sse, streamable_http. |
|
||||
| PROXIED_MCP_NAME | Proxied MCP server name | - | No | In proxy mode, specify the MCP server name to be converted. Must be registered in Nacos first. |
|
||||
| MODE | Working mode | router | No | Available options: router, proxy. |
|
||||
| Parameter | Description | Default Value | Required | Remarks |
|
||||
|-----------|-------------------------|---------------|----------|------------------------------------------------------------------------------------------------|
|
||||
| NACOS_ADDR | Nacos server address | 127.0.0.1:8848 | No | the Nacos server address, e.g., 192.168.1.1:8848. Note: Include the port. |
|
||||
| NACOS_USERNAME | Nacos username | nacos | No | the Nacos username, e.g., nacos. |
|
||||
| NACOS_PASSWORD | Nacos password | - | Yes | the Nacos password, e.g., nacos. |
|
||||
|NACOS_NAMESPACE| Nacos Namespace | public | No | Nacos namespace, e.g. public |
|
||||
| TRANSPORT_TYPE | Transport protocol type | stdio | No | transport protocol type. Options: stdio, sse, streamable_http. |
|
||||
| PROXIED_MCP_NAME | Proxied MCP server name | - | No | In proxy mode, specify the MCP server name to be converted. Must be registered in Nacos first. |
|
||||
| MODE | Working mode | router | No | Available options: router, proxy. |
|
||||
|ACCESS_KEY_ID | Aliyun ram access key id| - | No | |
|
||||
|ACCESS_KEY_SECRET | Aliyun ram access key secret | - | No | |
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
Nacos-MCP-Router是一个基于MCP官方标准SDK实现的的MCP Server。它提供了一组工具,提供MCP Server推荐、分发、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
|
||||
|
||||
Nacos-MCP-Routery有两种工作模式:
|
||||
Nacos-MCP-Router有两种工作模式:
|
||||
1. router模式:默认模式,通过MCP Server推荐、分发、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
|
||||
2. prroxy模式:使用环境变量MODE=proxy指定,通过简单配置可以把sse、stdio协议MCP Server转换为streamableHTTP协议MCP Server。
|
||||
|
||||
|
@ -145,15 +145,20 @@ docker run -i --rm --network host -e NACOS_ADDR=$NACOS_ADDR -e NACOS_USERNAME=$N
|
|||
```
|
||||
|
||||
## 环境变量设置
|
||||
| | | | | |
|
||||
|----|----|----|----|----|
|
||||
| 参数 | 描述 | 默认值 | 是否必填 | 备注 |
|
||||
| NACOS_ADDR | Nacos 服务器地址 | 127.0.0.1:8848 | 否 | 填写 Nacos 服务器的地址,如 192.168.1.1:8848,注意要写端口
|
||||
| NACOS_USERNAME | Nacos 用户名 | nacos | 否 | 填写 Nacos 用户名,如 nacos
|
||||
| NACOS_PASSWORD | Nacos 密码 | 密码 | 是 | 填写 Nacos 密码,如 nacos
|
||||
| TRANSPORT_TYPE | 传输协议类型 | stdio | 否 | 填写传输协议类型,可选值:stdio、sse、streamable_http
|
||||
| PROXIED_MCP_NAME | 代理的 MCP 服务器名称 | - | 否 | proxy模式下需要被转换的 MCP 服务器名称,需要先注册到Nacos
|
||||
| MODE | 工作模式 | router | 否 |可选的值:router、proxy |
|
||||
### 环境变量设置
|
||||
| | | | | |
|
||||
|----|---------------|----|----|-------------------------------------------|
|
||||
| 参数 | 描述 | 默认值 | 是否必填 | 备注 |
|
||||
| NACOS_ADDR | Nacos 服务器地址 | 127.0.0.1:8848 | 否 | 填写 Nacos 服务器的地址,如 192.168.1.1:8848,注意要写端口 |
|
||||
| NACOS_USERNAME | Nacos 用户名 | nacos | 否 | 填写 Nacos 用户名,如 nacos |
|
||||
| NACOS_PASSWORD | Nacos 密码 | 密码 | 是 | 填写 Nacos 密码,如 nacos |
|
||||
|NACOS_NAMESPACE| Nacos命名空间 | public | 否 | Nacos命名空间,如 public |
|
||||
| TRANSPORT_TYPE | 传输协议类型 | stdio | 否 | 填写传输协议类型,可选值:stdio、sse、streamable_http |
|
||||
| PROXIED_MCP_NAME | 代理的 MCP 服务器名称 | - | 否 | proxy模式下需要被转换的 MCP 服务器名称,需要先注册到Nacos |
|
||||
| MODE | 工作模式 | router | 否 | 可选的值:router、proxy |
|
||||
| PORT | 服务端口 | 8000| 否| 协议类型为sse或streamable时使用 |
|
||||
|ACCESS_KEY_ID | Aliyun ram access key id| - | 否 | |
|
||||
|ACCESS_KEY_SECRET | Aliyun ram access key secret | - | 否 | |
|
||||
|
||||
|
||||
## 常见问题
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
[project]
|
||||
name = "nacos_mcp_router"
|
||||
version = "0.1.9"
|
||||
version = "0.2.2"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"chromadb>=1.0.5",
|
||||
"mcp>=1.8.0",
|
||||
"mcp>=1.9.4",
|
||||
"requests>=2.32.3",
|
||||
]
|
||||
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
class Credentials(object):
|
||||
def __init__(self, access_key_id, access_key_secret, security_token=None):
|
||||
self.access_key_id = access_key_id
|
||||
self.access_key_secret = access_key_secret
|
||||
self.security_token = security_token
|
||||
|
||||
def get_access_key_id(self):
|
||||
return self.access_key_id
|
||||
|
||||
def get_access_key_secret(self):
|
||||
return self.access_key_secret
|
||||
|
||||
def get_security_token(self):
|
||||
return self.security_token
|
||||
|
||||
class CredentialsProvider(object):
|
||||
def get_credentials(self) -> Credentials:
|
||||
return Credentials("", "", "")
|
||||
|
||||
|
||||
class StaticCredentialsProvider(CredentialsProvider):
|
||||
def __init__(self, access_key_id="", access_key_secret="", security_token=""):
|
||||
self.credentials = Credentials(access_key_id, access_key_secret, security_token)
|
||||
|
||||
def get_credentials(self) -> Credentials:
|
||||
return self.credentials
|
|
@ -11,24 +11,34 @@ class NacosMcpRouteLogger:
|
|||
def setup_logger(cls):
|
||||
NacosMcpRouteLogger.logger = logging.getLogger("nacos_mcp_router")
|
||||
NacosMcpRouteLogger.logger.setLevel(logging.INFO)
|
||||
|
||||
# 防止重复添加处理器
|
||||
if NacosMcpRouteLogger.logger.handlers:
|
||||
return
|
||||
|
||||
log_file = os.path.expanduser("~") + "/logs/nacos_mcp_router/router.log"
|
||||
log_dir = os.path.dirname(log_file)
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s | %(name)-15s | %(levelname)-8s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# 只添加文件处理器
|
||||
file_handler = RotatingFileHandler(
|
||||
filename=log_file,
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5, # 保留5个备份文件
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setLevel(logging.INFO) # 文件记录所有级别
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
NacosMcpRouteLogger.logger.addHandler(file_handler)
|
||||
|
||||
# 关键修复:防止日志向父logger传播
|
||||
NacosMcpRouteLogger.logger.propagate = False
|
||||
@classmethod
|
||||
def get_logger(cls) -> logging.Logger:
|
||||
if NacosMcpRouteLogger.logger is None:
|
||||
|
|
|
@ -1,58 +1,153 @@
|
|||
#-*- coding: utf-8 -*-
|
||||
import functools
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import itertools
|
||||
import asyncio
|
||||
from typing import Optional, List
|
||||
|
||||
from chromadb.api.types import ID
|
||||
|
||||
from .md5_util import get_md5
|
||||
from .nacos_http_client import NacosHttpClient
|
||||
from .router_types import ChromaDb, McpServer
|
||||
from .logger import NacosMcpRouteLogger
|
||||
from .constants import MODE_ROUTER
|
||||
import threading
|
||||
import unicodedata
|
||||
import re
|
||||
|
||||
logger = NacosMcpRouteLogger.get_logger()
|
||||
|
||||
class McpUpdater:
|
||||
def __init__(self,
|
||||
nacosHttpClient: NacosHttpClient,
|
||||
chromaDbService: ChromaDb,
|
||||
update_interval: float) -> None:
|
||||
chromaDbService: ChromaDb | None = None,
|
||||
update_interval: float = 60,
|
||||
enable_vector_db: bool = True,
|
||||
mode: str = MODE_ROUTER,
|
||||
proxy_mcp_name: str = "",
|
||||
enable_auto_refresh: bool = True):
|
||||
self.nacosHttpClient = nacosHttpClient
|
||||
self.chromaDbService = chromaDbService
|
||||
self.interval = update_interval
|
||||
self._loop = None
|
||||
self._thread = None
|
||||
self._running = False
|
||||
self.mcp_server_config_version={}
|
||||
self._update_task: Optional[asyncio.Task] = None
|
||||
self.mcp_server_config_version = {}
|
||||
self._cache = dict[str, McpServer]()
|
||||
self._chromaDbId = f"nacos_mcp_router_collection"
|
||||
self.enable_vector_db = enable_vector_db
|
||||
self.lock = threading.Lock()
|
||||
self.mode = mode
|
||||
self.proxy_mcp_name = proxy_mcp_name
|
||||
self.enable_auto_refresh = enable_auto_refresh
|
||||
self._thread = None
|
||||
|
||||
@classmethod
|
||||
async def create(cls,
|
||||
def create(cls,
|
||||
nacos_client: NacosHttpClient,
|
||||
chroma_db: ChromaDb,
|
||||
update_interval: float):
|
||||
|
||||
updater = cls(nacos_client, chroma_db, update_interval)
|
||||
await updater.refresh()
|
||||
chroma_db: ChromaDb | None = None,
|
||||
update_interval: float = 30,
|
||||
enable_vector_db: bool = False,
|
||||
mode: str = MODE_ROUTER,
|
||||
proxy_mcp_name: str = "",
|
||||
enable_auto_refresh: bool = True):
|
||||
"""创建 McpUpdater 实例并启动后台任务"""
|
||||
updater = cls(nacos_client, chroma_db, update_interval, enable_vector_db, mode, proxy_mcp_name, enable_auto_refresh)
|
||||
|
||||
updater._thread = threading.Thread(target=functools.partial(updater.asyncUpdater))
|
||||
updater._thread.daemon = False
|
||||
updater._thread.start()
|
||||
|
||||
updater._thread.daemon = True
|
||||
|
||||
if enable_auto_refresh:
|
||||
updater._thread.start()
|
||||
|
||||
return updater
|
||||
|
||||
async def refresh(self)-> None:
|
||||
mcpServers = await self.nacosHttpClient.get_mcp_servers()
|
||||
logger.info(f"get mcp server list from nacos, size: {len(mcpServers)}")
|
||||
if not mcpServers:
|
||||
def asyncUpdater(self) -> None:
|
||||
debug_mode = os.getenv('DEBUG_MODE')
|
||||
if debug_mode is not None:
|
||||
logger.info("debug mode is enabled")
|
||||
return
|
||||
|
||||
docs = []
|
||||
ids = []
|
||||
cache = {}
|
||||
while True:
|
||||
try:
|
||||
if self.mode == MODE_ROUTER:
|
||||
asyncio.run(self.refresh())
|
||||
else:
|
||||
asyncio.run(self.refreshOne())
|
||||
time.sleep(self.interval)
|
||||
except Exception as e:
|
||||
logger.warning("exception while updating mcp servers: " , exc_info=e)
|
||||
|
||||
for mcpServer in mcpServers:
|
||||
def get_deleted_ids(self) -> List[str]:
|
||||
if self.chromaDbService is None:
|
||||
return []
|
||||
|
||||
all_ids_in_chromadb = self.chromaDbService.get_all_ids()
|
||||
if all_ids_in_chromadb is None:
|
||||
return []
|
||||
|
||||
deleted_id = []
|
||||
for id in all_ids_in_chromadb:
|
||||
if id not in self._cache:
|
||||
deleted_id.append(id)
|
||||
return deleted_id
|
||||
|
||||
async def refresh(self) -> None:
|
||||
"""刷新所有 MCP 服务器"""
|
||||
if not self.enable_auto_refresh:
|
||||
return
|
||||
|
||||
try:
|
||||
mcpServers = await self.nacosHttpClient.get_mcp_servers()
|
||||
logger.info(f"get mcp server list from nacos, size: {len(mcpServers)}")
|
||||
if not mcpServers:
|
||||
return
|
||||
|
||||
docs = []
|
||||
ids = []
|
||||
cache = {}
|
||||
for mcpServer in mcpServers:
|
||||
des = mcpServer.description
|
||||
detail = mcpServer.mcp_config_detail
|
||||
if detail is not None:
|
||||
des = detail.get_tool_description()
|
||||
|
||||
name = mcpServer.get_name()
|
||||
sname = str(name)
|
||||
|
||||
cache[sname] = mcpServer
|
||||
|
||||
md5_str = get_md5(des)
|
||||
version = self.mcp_server_config_version.get(sname, '')
|
||||
|
||||
if version != md5_str:
|
||||
self.mcp_server_config_version[sname] = md5_str
|
||||
ids.append(sname)
|
||||
if self.enable_vector_db:
|
||||
docs.append(des)
|
||||
with self.lock:
|
||||
self._cache = cache
|
||||
|
||||
if not ids:
|
||||
return
|
||||
if self.enable_vector_db and self.chromaDbService is not None:
|
||||
self.chromaDbService.update_data(documents=docs, ids=ids)
|
||||
deleted_id = self.get_deleted_ids()
|
||||
if len(deleted_id) > 0 and len(mcpServers) > 0:
|
||||
self.chromaDbService.delete_data(ids=deleted_id)
|
||||
except Exception as e:
|
||||
logger.warning("exception while refreshing mcp servers: ", exc_info=e)
|
||||
|
||||
async def refreshOne(self) -> None:
|
||||
"""刷新单个 MCP 服务器"""
|
||||
try:
|
||||
mcpServer = await self.nacosHttpClient.get_mcp_server(id='', name=self.proxy_mcp_name)
|
||||
if mcpServer is None:
|
||||
return
|
||||
docs = []
|
||||
ids = []
|
||||
cache = {}
|
||||
des = mcpServer.description
|
||||
detail = mcpServer.mcp_config_detail
|
||||
if detail is not None:
|
||||
|
@ -62,66 +157,100 @@ class McpUpdater:
|
|||
sname = str(name)
|
||||
|
||||
cache[sname] = mcpServer
|
||||
|
||||
md5_str = get_md5(des)
|
||||
version = self.mcp_server_config_version.get(sname, '')
|
||||
|
||||
if version != md5_str:
|
||||
self.mcp_server_config_version[sname] = md5_str
|
||||
ids.append(sname)
|
||||
docs.append(des)
|
||||
with self.lock:
|
||||
self._cache = cache
|
||||
except Exception as e:
|
||||
logger.warning("exception while updating mcp server: ", exc_info=e)
|
||||
|
||||
self._cache = cache
|
||||
async def _get_from_cache(self, id: str) -> Optional[McpServer]:
|
||||
"""从缓存中获取 MCP 服务器"""
|
||||
with self.lock:
|
||||
return self._cache.get(id)
|
||||
|
||||
if not ids:
|
||||
return
|
||||
|
||||
self.chromaDbService.update_data(documents=docs, ids=ids)
|
||||
|
||||
def asyncUpdater(self) -> None:
|
||||
debug_mode = os.getenv('DEBUG_MODE')
|
||||
if debug_mode is not None:
|
||||
logger.info("debug mode is enabled")
|
||||
return
|
||||
async def _cache_values(self) -> List[McpServer]:
|
||||
"""获取缓存中的所有值"""
|
||||
with self.lock:
|
||||
return list(self._cache.values())
|
||||
|
||||
while True:
|
||||
try:
|
||||
time.sleep(self.interval)
|
||||
asyncio.run(self.refresh())
|
||||
except Exception as e:
|
||||
logger.warning("exception while updating mcp servers: " , exc_info=e)
|
||||
|
||||
def getMcpServer(self, query: str, count: int) -> list[McpServer | None]:
|
||||
result = self.chromaDbService.query(query, count)
|
||||
if result is None:
|
||||
async def getMcpServer(self, query: str, count: int) -> List[McpServer]:
|
||||
"""通过查询获取 MCP 服务器"""
|
||||
if not self.enable_vector_db or self.chromaDbService is None:
|
||||
return []
|
||||
|
||||
ids = result.get('ids')
|
||||
if ids is None:
|
||||
|
||||
try:
|
||||
result = self.chromaDbService.query(query, count)
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
ids = result.get('ids')
|
||||
logger.info("find mcps in vector db, query: " + query + ",ids: " + str(ids))
|
||||
if ids is None:
|
||||
return []
|
||||
|
||||
mcp_servers = []
|
||||
for id1 in itertools.chain.from_iterable(ids):
|
||||
server = await self._get_from_cache(id1)
|
||||
if server is not None:
|
||||
mcp_servers.append(server)
|
||||
|
||||
return mcp_servers
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"exception while getting mcp server by query: {query}", exc_info=e)
|
||||
return []
|
||||
|
||||
def _normalize_chinese_text(self, text: str) -> str:
|
||||
"""标准化中文文本,处理编码、空格、全角半角等问题"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
mcp_servers = [self._cache.get(id1)
|
||||
for id1
|
||||
in itertools.chain.from_iterable(ids)]
|
||||
# 统一编码为 UTF-8
|
||||
if isinstance(text, bytes):
|
||||
text = text.decode('utf-8', errors='ignore')
|
||||
|
||||
mcp_servers = [s for s in mcp_servers if s is not None]
|
||||
|
||||
return list(mcp_servers)
|
||||
|
||||
def search_mcp_by_keyword(self, keyword: str) -> list[McpServer]:
|
||||
servers = list[McpServer]()
|
||||
logger.info("cache size: " + str(len(self._cache.values())))
|
||||
|
||||
for mcp_server in self._cache.values():
|
||||
if mcp_server.description is None:
|
||||
continue
|
||||
|
||||
if keyword in mcp_server.description:
|
||||
servers.append(mcp_server)
|
||||
|
||||
logger.info(f"result mcp servers search by keywords: {len(servers)}")
|
||||
return servers
|
||||
|
||||
def get_mcp_server_by_name(self, mcp_name: str) -> McpServer | None:
|
||||
return self._cache.get(mcp_name)
|
||||
# 移除不可见字符和多余空格
|
||||
text = re.sub(r'\s+', ' ', text.strip())
|
||||
|
||||
# 全角转半角
|
||||
text = unicodedata.normalize('NFKC', text)
|
||||
|
||||
# 移除所有空格
|
||||
text = text.replace(' ', '').replace(' ', '')
|
||||
|
||||
return text.lower()
|
||||
|
||||
async def search_mcp_by_keyword(self, keyword: str) -> List[McpServer]:
|
||||
"""通过关键词搜索 MCP 服务器"""
|
||||
try:
|
||||
servers = []
|
||||
cache_values = await self._cache_values()
|
||||
logger.info("cache size: " + str(len(cache_values)))
|
||||
|
||||
# 标准化关键词
|
||||
normalized_keyword = self._normalize_chinese_text(keyword)
|
||||
|
||||
for mcp_server in cache_values:
|
||||
if mcp_server.description is None:
|
||||
logger.info(f"mcp server {mcp_server.name} description is None")
|
||||
continue
|
||||
|
||||
# 标准化描述文本
|
||||
normalized_description = self._normalize_chinese_text(mcp_server.description)
|
||||
|
||||
if normalized_keyword in normalized_description:
|
||||
servers.append(mcp_server)
|
||||
|
||||
logger.info(f"result mcp servers search by keywords: {len(servers)}, key: {keyword}")
|
||||
return servers
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"exception while searching mcp by keyword: {keyword}", exc_info=e)
|
||||
return []
|
||||
|
||||
async def get_mcp_server_by_name(self, mcp_name: str) -> Optional[McpServer]:
|
||||
"""通过名称获取 MCP 服务器"""
|
||||
return await self._get_from_cache(mcp_name)
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
from typing import Any
|
||||
from mcp.types import Tool
|
||||
from mcp.types import CallToolResult
|
||||
from mcp.types import InitializeResult
|
||||
from mcp.types import ListToolsResult
|
||||
|
||||
class McpTransport:
|
||||
def __init__(self, url: str, headers: dict[str, str]):
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
|
||||
async def handle_tool_call(self, args: dict[str, Any], client_headers: dict[str, str], name: str) -> Any:
|
||||
pass
|
||||
|
||||
async def handle_list_tools(self, client_headers: dict[str, str]) -> Any:
|
||||
pass
|
||||
|
||||
async def handle_initialize(self, client_headers: dict[str, str]) -> Any:
|
||||
pass
|
||||
|
||||
def clean_headers(self, client_headers: dict[str, str]) -> dict[str, str]:
|
||||
return {k: v for k, v in client_headers.items() if k != 'Content-Length' and k != 'content-length' and k != 'host' and k != 'Host'}
|
|
@ -1,11 +1,16 @@
|
|||
#-*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import urllib.parse
|
||||
import httpx
|
||||
import asyncio
|
||||
import os
|
||||
from mcp import Tool
|
||||
from packaging import version
|
||||
|
||||
from .router_types import McpServer
|
||||
from .nacos_mcp_server_config import NacosMcpServerConfig
|
||||
|
@ -23,19 +28,45 @@ _SCHEMA_HTTP = "http"
|
|||
_SCHEMA = os.getenv("NACOS_SERVER_SCHEMA", _SCHEMA_HTTP)
|
||||
|
||||
class NacosHttpClient:
|
||||
def __init__(self, nacosAddr: str, userName: str, passwd: str, namespaceId: str) -> None:
|
||||
if not isinstance(nacosAddr, str) or not nacosAddr.strip():
|
||||
raise ValueError("nacosAddr must be a non-empty string")
|
||||
if not isinstance(userName, str) or not userName.strip():
|
||||
raise ValueError("userName must be a non-empty string")
|
||||
if not isinstance(passwd, str) or not passwd.strip():
|
||||
raise ValueError("passwd must be a non-empty string")
|
||||
|
||||
def __init__(self, params: dict[str,str]) -> None:
|
||||
nacosAddr = params["nacosAddr"]
|
||||
userName = params["userName"]
|
||||
passwd = params["password"]
|
||||
|
||||
self.nacosAddr = nacosAddr
|
||||
self.userName = userName
|
||||
self.passwd = passwd
|
||||
self.schema = _SCHEMA
|
||||
self.namespaceId = namespaceId
|
||||
self.namespaceId = params["namespaceId"] if params["namespaceId"] else ""
|
||||
self.ak = params["ak"] if params["ak"] else ""
|
||||
self.sk = params["sk"] if params["sk"] else ""
|
||||
|
||||
if self.ak and not self.sk:
|
||||
raise ValueError("ak and sk are required when using nacos http client")
|
||||
if self.sk and not self.ak:
|
||||
raise ValueError("ak and sk are required when using nacos http client")
|
||||
|
||||
from .auth import StaticCredentialsProvider
|
||||
self.credentials_provider = StaticCredentialsProvider(self.ak, self.sk)
|
||||
|
||||
def __do_sign(self, sign_str, sk):
|
||||
return base64.encodebytes(
|
||||
hmac.new(sk.encode(), sign_str.encode(), digestmod=hashlib.sha1).digest()).decode().strip()
|
||||
|
||||
def _inject_auth_info(self, headers: dict[str, str]) -> None:
|
||||
credentials = self.credentials_provider.get_credentials()
|
||||
|
||||
if not str.strip(credentials.get_access_key_id()) or not str.strip(credentials.get_access_key_secret()):
|
||||
return
|
||||
|
||||
ts = str(int(round(time.time() * 1000)))
|
||||
sign_str = self.namespaceId if self.namespaceId else "public" + "+" + "DEFAULT_GROUP" + "+"+ ts
|
||||
|
||||
headers.update({
|
||||
"Spas-AccessKey": credentials.get_access_key_id(),
|
||||
"timeStamp": ts,
|
||||
})
|
||||
headers["Spas-Signature"] = self.__do_sign(sign_str, credentials.get_access_key_secret())
|
||||
|
||||
async def get_mcp_server(self, id: str, name:str) -> McpServer:
|
||||
"""
|
||||
|
@ -63,27 +94,27 @@ class NacosHttpClient:
|
|||
params['mcpName'] = name
|
||||
|
||||
uri = f'/nacos/v3/admin/ai/mcp?' + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
success, data = await self.request_nacos(uri)
|
||||
if not success:
|
||||
logger.warning(f"failed to get mcp server, name {name}, id {id}")
|
||||
return McpServer(name=name, description="", agentConfig={}, id=id)
|
||||
return McpServer(name=name, description="", agentConfig={}, id=id, version="0.0.0")
|
||||
|
||||
data['id'] = id
|
||||
config = NacosMcpServerConfig.from_dict(data)
|
||||
config.local_server_config['protocol'] = config.protocol
|
||||
mcp_server = McpServer(name=config.name,
|
||||
description=config.description or "",
|
||||
agentConfig=config.local_server_config,
|
||||
id=id)
|
||||
id=id,
|
||||
version=config.version)
|
||||
|
||||
mcp_server.mcp_config_detail = config
|
||||
|
||||
if config.protocol == "stdio" or len(config.backend_endpoints) == 0:
|
||||
protocol = config.protocol
|
||||
if protocol == "stdio" or len(config.backend_endpoints) == 0:
|
||||
return mcp_server
|
||||
|
||||
_parse_mcp_detail(mcp_server, config, name)
|
||||
|
||||
return mcp_server
|
||||
|
||||
async def get_mcp_servers_by_page(self, page_no: int, page_size: int):
|
||||
|
@ -115,7 +146,7 @@ class NacosHttpClient:
|
|||
if not m["enabled"]:
|
||||
return None
|
||||
name = m["name"]
|
||||
if (m["protocol"] == "mcp-sse" or m["protocol"] == "stdio") :
|
||||
if (m["protocol"] == "mcp-sse" or m["protocol"] == "stdio") or m["protocol"] == "mcp-streamable" :
|
||||
id = ""
|
||||
if "id" in m and m["id"] is not None:
|
||||
id = m["id"]
|
||||
|
@ -169,7 +200,7 @@ class NacosHttpClient:
|
|||
logger.info(f"get mcp server list, total count {len(mcp_servers)}")
|
||||
return mcp_servers
|
||||
|
||||
async def update_mcp_tools(self, mcp_name:str, tools: list[Tool], version: str, id: str) -> bool:
|
||||
async def update_mcp_tools(self, mcp_name:str, tools: list[Tool], mcp_version: str, id: str) -> bool:
|
||||
"""
|
||||
Update the tools list for a specified MCP.
|
||||
|
||||
|
@ -199,10 +230,17 @@ class NacosHttpClient:
|
|||
|
||||
# get original server config
|
||||
success, data = await self.request_nacos(uri)
|
||||
if 'version' in data and data['version'] is not None \
|
||||
and isinstance(data['version'], str) and version.parse(data['version']) > version.parse(mcp_version) \
|
||||
and 'toolSpec' in data and data['toolSpec'] is not None \
|
||||
and 'tools' in data['toolSpec'] and data['toolSpec']['tools'] is not None \
|
||||
and len(data['toolSpec']['tools']) > 0:
|
||||
return True
|
||||
|
||||
if not success:
|
||||
logger.warning(f"failed to update mcp tools list, uri {uri}")
|
||||
return False
|
||||
data["versionDetail"] = {"version": version}
|
||||
data["versionDetail"] = {"version": mcp_version}
|
||||
|
||||
if self.namespaceId != "":
|
||||
data["namespaceId"] = self.namespaceId
|
||||
|
@ -216,7 +254,7 @@ class NacosHttpClient:
|
|||
data=params,
|
||||
content_type=CONTENT_TYPE_URLENCODED)
|
||||
|
||||
logger.warning(f"Update mcp tools, name: {mcp_name}, result: {success}")
|
||||
logger.info(f"Update mcp tools, name: {mcp_name}, result: {success}")
|
||||
|
||||
return success
|
||||
|
||||
|
@ -252,6 +290,7 @@ class NacosHttpClient:
|
|||
"charset": "utf-8",
|
||||
"userName": self.userName,
|
||||
"password": self.passwd}
|
||||
self._inject_auth_info(headers)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
if method == "GET":
|
||||
|
@ -311,7 +350,7 @@ def _parse_tool_params(data, mcp_name, tools) -> dict[str, str]:
|
|||
}
|
||||
|
||||
def _parse_mcp_detail(mcp_server, config, searching_name):
|
||||
endpoint = config.backend_endpoints[0]
|
||||
endpoint = random.choice(config.backend_endpoints)
|
||||
http_schema = "https" if endpoint.port == 443 else "http"
|
||||
path = config.remote_server_config.export_path
|
||||
if not path.startswith("/"):
|
||||
|
@ -322,7 +361,8 @@ def _parse_mcp_detail(mcp_server, config, searching_name):
|
|||
dct = {
|
||||
"name": searching_name,
|
||||
"description": '',
|
||||
"url": url
|
||||
"url": url,
|
||||
"protocol": mcp_server.agentConfig["protocol"],
|
||||
}
|
||||
|
||||
mcp_servers[searching_name] = dct
|
||||
|
|
|
@ -65,13 +65,19 @@ class ToolMeta:
|
|||
class ToolSpec:
|
||||
tools: List[Tool]
|
||||
tools_meta: Dict[str, ToolMeta]
|
||||
tools_dict: Dict[str, Tool]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ToolSpec":
|
||||
return cls(
|
||||
tool_spec = cls(
|
||||
tools=[Tool.from_dict(t) for t in data.get("tools", [])],
|
||||
tools_meta={k: ToolMeta.from_dict(v) for k, v in data.get("toolsMeta", {}).items()}
|
||||
tools_meta={k: ToolMeta.from_dict(v) for k, v in data.get("toolsMeta", {}).items()},
|
||||
tools_dict={}
|
||||
)
|
||||
for tool in tool_spec.tools:
|
||||
tool_spec.tools_dict[tool.name] = tool
|
||||
|
||||
return tool_spec
|
||||
|
||||
# ------------------ 主结构 ------------------
|
||||
@dataclass
|
||||
|
@ -132,8 +138,8 @@ class NacosMcpServerConfig:
|
|||
enabled: bool = True
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
backend_endpoints: List[BackendEndpoint] = field(default_factory=list)
|
||||
tool_spec: ToolSpec = field(default_factory=lambda: ToolSpec(tools=[], tools_meta={}))
|
||||
|
||||
tool_spec: ToolSpec = field(default_factory=lambda: ToolSpec(tools=[], tools_meta={}, tools_dict={}))
|
||||
front_protocol: str | None = None
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "NacosMcpServerConfig":
|
||||
tool_spec_data = data.get("toolSpec")
|
||||
|
@ -142,6 +148,7 @@ class NacosMcpServerConfig:
|
|||
return cls(
|
||||
name=data["name"],
|
||||
protocol=data["protocol"],
|
||||
front_protocol=data.get("frontProtocol"),
|
||||
description=data["description"],
|
||||
version=data["version"],
|
||||
remote_server_config=RemoteServerConfig.from_dict(data["remoteServerConfig"]),
|
||||
|
@ -149,7 +156,7 @@ class NacosMcpServerConfig:
|
|||
enabled=data.get("enabled", True),
|
||||
capabilities=data.get("capabilities", []),
|
||||
backend_endpoints=[BackendEndpoint.from_dict(e) for e in data.get("backendEndpoints", [])] if backend_endpoints_data else [],
|
||||
tool_spec=ToolSpec.from_dict(tool_spec_data) if tool_spec_data else ToolSpec(tools=[], tools_meta={}),
|
||||
tool_spec=ToolSpec.from_dict(tool_spec_data) if tool_spec_data else ToolSpec(tools=[], tools_meta={}, tools_dict={}),
|
||||
id=data["id"] if data.get("id") else None
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
@ -4,7 +4,7 @@ import asyncio
|
|||
import json
|
||||
import os
|
||||
import typing
|
||||
from typing import Optional
|
||||
from importlib.metadata import version as get_version
|
||||
|
||||
import anyio
|
||||
from mcp import types
|
||||
|
@ -16,37 +16,38 @@ from .logger import NacosMcpRouteLogger
|
|||
from .mcp_manager import McpUpdater
|
||||
from .nacos_http_client import NacosHttpClient
|
||||
from .router_exceptions import NacosMcpRouterException
|
||||
from .router_types import ChromaDb
|
||||
from .router_types import ChromaDb, McpServer
|
||||
from .router_types import CustomServer
|
||||
|
||||
version_number = f"nacos-mcp-router:v{get_version('nacos-mcp-router')}"
|
||||
router_logger = NacosMcpRouteLogger.get_logger()
|
||||
mcp_servers_dict: dict[str, CustomServer] = {}
|
||||
|
||||
mcp_updater: Optional[McpUpdater] = None
|
||||
nacos_http_client: Optional[NacosHttpClient] = None
|
||||
mcp_updater: McpUpdater
|
||||
nacos_http_client: NacosHttpClient
|
||||
proxied_mcp_name: str = ""
|
||||
mode: str = MODE_ROUTER
|
||||
proxied_mcp_server_config: dict = {}
|
||||
transport_type: str = TRANSPORT_TYPE_STDIO
|
||||
auto_register_tools: bool = True
|
||||
|
||||
|
||||
proxied_mcp_version: str = ''
|
||||
mcp_app: Server
|
||||
def router_tools() -> list[types.Tool]:
|
||||
return [
|
||||
types.Tool(
|
||||
name="search_mcp_server",
|
||||
description="执行任务前首先使用本工具。根据任务描述及关键字搜索mcp server, 制定完成任务的步骤。",
|
||||
description="执行任务前首先使用本工具。根据任务描述及关键字搜索mcp server, 制定完成任务的步骤。注意:任务描述及关键字需要同时包含中文和英文。",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["task_description", "key_words"],
|
||||
"properties": {
|
||||
"task_description": {
|
||||
"type": "string",
|
||||
"description": "用户任务描述 ",
|
||||
"description": "用户中文和英文任务描述,中英文描述各占单独一行。如果任务描述只包含中文,请同时输入英文描述,反之亦然。",
|
||||
},
|
||||
"key_words": {
|
||||
"type": "string",
|
||||
"description": "用户任务关键字,可以为多个,英文逗号分隔,最多为2个"
|
||||
"description": "用户任务关键字,可以为多个,最多为4个,包含中英文关键字,英文逗号分隔"
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -91,32 +92,88 @@ def router_tools() -> list[types.Tool]:
|
|||
|
||||
|
||||
async def init_proxied_mcp() -> bool:
|
||||
global proxied_mcp_server_config
|
||||
if proxied_mcp_name in mcp_servers_dict:
|
||||
return True
|
||||
|
||||
proxied_mcp_server_config_str = os.getenv("PROXIED_MCP_SERVER_CONFIG", "")
|
||||
|
||||
if mode == MODE_PROXY and (proxied_mcp_server_config_str == "" or proxied_mcp_server_config_str is None):
|
||||
router_logger.info(f"proxied_mcp_server_config_str is empty, get mcp server from nacos, proxied_mcp_name: {proxied_mcp_name}")
|
||||
mcp_server = await nacos_http_client.get_mcp_server(id="", name=proxied_mcp_name)
|
||||
router_logger.info(f"proxied_mcp_server_config: {mcp_server.agent_config()}")
|
||||
proxied_mcp_server_config = mcp_server.agent_config()
|
||||
|
||||
mcp_server = CustomServer(name=proxied_mcp_name, config=proxied_mcp_server_config)
|
||||
await mcp_server.wait_for_initialization()
|
||||
|
||||
if mcp_server.healthy():
|
||||
if mcp_server._protocol == 'stdio':
|
||||
await mcp_server.wait_for_initialization()
|
||||
|
||||
if await mcp_server.healthy():
|
||||
mcp_servers_dict[proxied_mcp_name] = mcp_server
|
||||
init_result = await mcp_server.get_initialized_response(client_headers={})
|
||||
version = getattr(getattr(init_result, 'serverInfo', None), 'version', "1.0.0")
|
||||
mcp_app.version = version
|
||||
|
||||
if auto_register_tools and nacos_http_client is not None:
|
||||
tools = await mcp_server.list_tools()
|
||||
init_result = mcp_server.get_initialized_response()
|
||||
version = init_result.serverInfo.version if init_result else "1.0.0"
|
||||
await nacos_http_client.update_mcp_tools(proxied_mcp_name, tools, version, "")
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def filter_tools(tools:list[types.Tool], mcp_server_from_registry:McpServer) -> list[types.Tool]:
|
||||
if mcp_server_from_registry is None:
|
||||
return tools
|
||||
|
||||
async def proxied_mcp_tools() -> list[typing.Any]:
|
||||
disenabled_tools = {}
|
||||
tools_meta = {}
|
||||
if hasattr(mcp_server_from_registry, 'mcp_config_detail') and mcp_server_from_registry.mcp_config_detail is not None:
|
||||
if hasattr(mcp_server_from_registry.mcp_config_detail, 'tool_spec') and mcp_server_from_registry.mcp_config_detail.tool_spec is not None:
|
||||
if hasattr(mcp_server_from_registry.mcp_config_detail.tool_spec, 'tools_meta') and mcp_server_from_registry.mcp_config_detail.tool_spec.tools_meta is not None:
|
||||
tools_meta = mcp_server_from_registry.mcp_config_detail.tool_spec.tools_meta
|
||||
|
||||
for tool_name in tools_meta:
|
||||
meta = tools_meta[tool_name]
|
||||
if not meta.enabled:
|
||||
disenabled_tools[tool_name] = True
|
||||
|
||||
from mcp import types
|
||||
tool_list = list[types.Tool]()
|
||||
for tool in tools:
|
||||
if tool.name in disenabled_tools:
|
||||
continue
|
||||
dct = {}
|
||||
|
||||
dct['name'] = tool.name
|
||||
if hasattr(mcp_server_from_registry, 'mcp_config_detail') and mcp_server_from_registry.mcp_config_detail is not None:
|
||||
if hasattr(mcp_server_from_registry.mcp_config_detail, 'tool_spec') and mcp_server_from_registry.mcp_config_detail.tool_spec is not None:
|
||||
if hasattr(mcp_server_from_registry.mcp_config_detail.tool_spec, 'tools_dict') and mcp_server_from_registry.mcp_config_detail.tool_spec.tools_dict is not None:
|
||||
if tool.name in mcp_server_from_registry.mcp_config_detail.tool_spec.tools_dict:
|
||||
tool.description = mcp_server_from_registry.mcp_config_detail.tool_spec.tools_dict[tool.name].description
|
||||
tool.inputSchema = mcp_server_from_registry.mcp_config_detail.tool_spec.tools_dict[tool.name].input_schema
|
||||
|
||||
tool_list.append(tool)
|
||||
return tool_list
|
||||
|
||||
|
||||
async def proxied_mcp_tools(client_headers: dict[str, str] = {}) -> list[types.Tool]:
|
||||
if await init_proxied_mcp():
|
||||
return await mcp_servers_dict[proxied_mcp_name].list_tools()
|
||||
try:
|
||||
tool_list = await mcp_servers_dict[proxied_mcp_name].list_tools_with_headers(client_headers=client_headers)
|
||||
mcp_server_from_registry = await mcp_updater.get_mcp_server_by_name(proxied_mcp_name)
|
||||
if mcp_server_from_registry is not None:
|
||||
result = await filter_tools(tool_list, mcp_server_from_registry)
|
||||
return result
|
||||
return tool_list
|
||||
except (KeyError, Exception) as e:
|
||||
router_logger.warning("failed to list tools for proxied mcp server: " + proxied_mcp_name, exc_info=e)
|
||||
return []
|
||||
else:
|
||||
raise NacosMcpRouterException(msg=f"failed to initialize proxied MCP server {proxied_mcp_name}")
|
||||
|
||||
|
||||
def search_mcp_server(task_description: str, key_words: str) -> str:
|
||||
async def search_mcp_server(task_description: str, key_words: str) -> str:
|
||||
"""
|
||||
Name:
|
||||
search_mcp_server
|
||||
|
@ -136,12 +193,11 @@ def search_mcp_server(task_description: str, key_words: str) -> str:
|
|||
mcp_servers1 = []
|
||||
keywords = key_words.split(",")
|
||||
for key_word in keywords:
|
||||
mcps = mcp_updater.search_mcp_by_keyword(key_word)
|
||||
mcps = await mcp_updater.search_mcp_by_keyword(key_word)
|
||||
mcp_servers1.extend(mcps or [])
|
||||
|
||||
router_logger.info("mcp size searched by keywords is " + str(len(mcp_servers1)))
|
||||
if len(mcp_servers1) < 5:
|
||||
keywords.append(task_description)
|
||||
mcp_servers2 = mcp_updater.getMcpServer(task_description, 5 - len(mcp_servers1))
|
||||
mcp_servers2 = await mcp_updater.getMcpServer(task_description, 5 - len(mcp_servers1))
|
||||
mcp_servers1.extend(mcp_servers2 or [])
|
||||
|
||||
result = {}
|
||||
|
@ -166,26 +222,21 @@ def search_mcp_server(task_description: str, key_words: str) -> str:
|
|||
return f"Error: {msg}"
|
||||
|
||||
|
||||
async def use_tool(mcp_server_name: str, mcp_tool_name: str, params: dict) -> str:
|
||||
async def use_tool(mcp_server_name: str, mcp_tool_name: str, params: dict, client_headers: dict[str, str] = {}) -> str:
|
||||
try:
|
||||
if mcp_server_name not in mcp_servers_dict:
|
||||
if mcp_server_name not in mcp_servers_dict or mcp_servers_dict[mcp_server_name] is None :
|
||||
router_logger.warning(f"mcp server {mcp_server_name} not found, "
|
||||
f"use search_mcp_server to get mcp servers")
|
||||
return "mcp server not found, use search_mcp_server to get mcp servers"
|
||||
|
||||
mcp_server = mcp_servers_dict[mcp_server_name]
|
||||
if mcp_server.healthy():
|
||||
response = await mcp_server.execute_tool(mcp_tool_name, params)
|
||||
else:
|
||||
del mcp_servers_dict[mcp_server_name]
|
||||
return "mcp server is not healthy, use search_mcp_server to get mcp servers"
|
||||
response = await mcp_server.execute_tool(mcp_tool_name, params, client_headers=client_headers)
|
||||
return str(response.content)
|
||||
except Exception as e:
|
||||
router_logger.warning("failed to use tool: " + mcp_tool_name, exc_info=e)
|
||||
return "failed to use tool: " + mcp_tool_name
|
||||
return "failed to use tool: " + mcp_tool_name + ", please use add_mcp_server to install mcp server"
|
||||
|
||||
|
||||
async def add_mcp_server(mcp_server_name: str) -> str:
|
||||
async def add_mcp_server(mcp_server_name: str, client_headers: dict[str, str] = {}) -> str:
|
||||
"""
|
||||
安装指定的mcp server
|
||||
:param mcp_server_name: mcp server名称
|
||||
|
@ -195,19 +246,19 @@ async def add_mcp_server(mcp_server_name: str) -> str:
|
|||
if nacos_http_client is None or mcp_updater is None:
|
||||
return "服务初始化中,请稍后再试"
|
||||
|
||||
mcp_server = mcp_updater.get_mcp_server_by_name(mcp_server_name)
|
||||
mcp_server = await mcp_updater.get_mcp_server_by_name(mcp_server_name)
|
||||
|
||||
if mcp_server is None:
|
||||
return mcp_server_name + " is not found" + ", use search_mcp_server to get mcp servers"
|
||||
|
||||
disenabled_tools = {}
|
||||
disabled_tools = {}
|
||||
tools_meta = mcp_server.mcp_config_detail.tool_spec.tools_meta
|
||||
for tool_name in tools_meta:
|
||||
meta = tools_meta[tool_name]
|
||||
if not meta.enabled:
|
||||
disenabled_tools[tool_name] = True
|
||||
disabled_tools[tool_name] = True
|
||||
|
||||
if mcp_server_name not in mcp_servers_dict:
|
||||
if mcp_server_name not in mcp_servers_dict or mcp_servers_dict[mcp_server_name] is None or not await mcp_servers_dict[mcp_server_name].healthy():
|
||||
env = get_default_environment()
|
||||
if mcp_server.agentConfig is None:
|
||||
mcp_server.agentConfig = {}
|
||||
|
@ -223,30 +274,43 @@ async def add_mcp_server(mcp_server_name: str) -> str:
|
|||
server_config['env'] = env
|
||||
if 'headers' not in server_config:
|
||||
server_config['headers'] = {}
|
||||
print(f"add mcp server: {mcp_server_name}, config:{mcp_server.agentConfig}")
|
||||
router_logger.info(f"add mcp server: {mcp_server_name}, config:{mcp_server.agentConfig}")
|
||||
server = CustomServer(name=mcp_server_name, config=mcp_server.agentConfig)
|
||||
await server.wait_for_initialization()
|
||||
if server.healthy():
|
||||
if server._protocol == 'stdio':
|
||||
await server.wait_for_initialization()
|
||||
if await server.healthy():
|
||||
mcp_servers_dict[mcp_server_name] = server
|
||||
else:
|
||||
mcp_servers_dict[mcp_server_name] = server
|
||||
|
||||
|
||||
if mcp_server_name not in mcp_servers_dict:
|
||||
return "failed to install mcp server: " + mcp_server_name
|
||||
|
||||
server = mcp_servers_dict[mcp_server_name]
|
||||
|
||||
tools = await server.list_tools()
|
||||
init_result = server.get_initialized_response()
|
||||
version = init_result.serverInfo.version if init_result else "1.0.0"
|
||||
tools = await server.list_tools_with_headers(client_headers=client_headers)
|
||||
init_result = await server.get_initialized_response(client_headers=client_headers)
|
||||
mcp_version = init_result.serverInfo.version if init_result and hasattr(init_result, 'serverInfo') else "1.0.0"
|
||||
router_logger.info(f"add mcp server: {mcp_server_name}, version:{mcp_version}")
|
||||
|
||||
tool_list = []
|
||||
for tool in tools:
|
||||
if tool.name in disenabled_tools:
|
||||
if tool.name in disabled_tools:
|
||||
continue
|
||||
dct = {}
|
||||
dct['name'] = tool.name
|
||||
dct['description'] = tool.description
|
||||
dct['inputSchema'] = tool.inputSchema
|
||||
tool_info = mcp_server.mcp_config_detail.tool_spec.tools_dict.get(tool.name)
|
||||
if tool_info:
|
||||
dct['description'] = tool_info.description
|
||||
dct['inputSchema'] = tool_info.input_schema
|
||||
else:
|
||||
dct['description'] = tool.description
|
||||
dct['inputSchema'] = tool.inputSchema
|
||||
|
||||
tool_list.append(dct)
|
||||
|
||||
if nacos_http_client is not None:
|
||||
await nacos_http_client.update_mcp_tools(mcp_server_name, tools, version,
|
||||
await nacos_http_client.update_mcp_tools(mcp_server_name, tools, mcp_version,
|
||||
mcp_server.id if mcp_server.id else "")
|
||||
|
||||
result = "1. " + mcp_server_name + "安装完成, tool 列表为: " + json.dumps(tool_list, ensure_ascii=False) + "\n2." + mcp_server_name + "的工具需要通过nacos-mcp-router的use_tool工具代理使用"
|
||||
|
@ -255,15 +319,16 @@ async def add_mcp_server(mcp_server_name: str) -> str:
|
|||
router_logger.warning("failed to install mcp server: " + mcp_server_name, exc_info=e)
|
||||
return "failed to install mcp server: " + mcp_server_name
|
||||
|
||||
def start_server(app: Server) -> int:
|
||||
def start_server() -> int:
|
||||
|
||||
match transport_type:
|
||||
case 'stdio':
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async def arun():
|
||||
async with stdio_server() as streams:
|
||||
await app.run(
|
||||
streams[0], streams[1], app.create_initialization_options()
|
||||
await mcp_app.run(
|
||||
streams[0], streams[1], mcp_app.create_initialization_options()
|
||||
)
|
||||
|
||||
anyio.run(arun)
|
||||
|
@ -272,23 +337,20 @@ def start_server(app: Server) -> int:
|
|||
case 'sse':
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Mount, Route
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from starlette.responses import Response
|
||||
sse_transport = SseServerTransport("/messages/")
|
||||
sse_port = int(os.getenv("PORT", "8000"))
|
||||
|
||||
async def handle_sse(request):
|
||||
async with sse_transport.connect_sse(
|
||||
request.scope, request.receive, request._send
|
||||
request.scope, request.receive, request._send
|
||||
) as streams:
|
||||
await app.run(
|
||||
streams[0], streams[1], app.create_initialization_options()
|
||||
await mcp_app.run(
|
||||
streams[0], streams[1], mcp_app.create_initialization_options()
|
||||
)
|
||||
return Response()
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def sse_lifespan(app: Starlette) -> AsyncIterator[None]:
|
||||
"""Context manager for session manager."""
|
||||
|
@ -321,14 +383,10 @@ def start_server(app: Server) -> int:
|
|||
from starlette.types import Scope
|
||||
from starlette.types import Receive
|
||||
from starlette.types import Send
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator
|
||||
from starlette.routing import Mount
|
||||
from starlette.applications import Starlette
|
||||
|
||||
streamable_port = int(os.getenv("PORT", "8000"))
|
||||
session_manager = StreamableHTTPSessionManager(
|
||||
app=app,
|
||||
app=mcp_app,
|
||||
event_store=None,
|
||||
json_response=False,
|
||||
stateless=True,
|
||||
|
@ -342,22 +400,11 @@ def start_server(app: Server) -> int:
|
|||
from collections.abc import AsyncIterator
|
||||
|
||||
sse_transport = SseServerTransport("/messages/")
|
||||
sse_port = int(os.getenv("PORT", "8000"))
|
||||
|
||||
async def handle_sse(request):
|
||||
async with sse_transport.connect_sse(
|
||||
request.scope, request.receive, request._send
|
||||
) as streams:
|
||||
await app.run(
|
||||
streams[0], streams[1], app.create_initialization_options()
|
||||
)
|
||||
return Response()
|
||||
async def handle_streamable_http(
|
||||
scope: Scope, receive: Receive, send: Send
|
||||
) -> None:
|
||||
await session_manager.handle_request(scope, receive, send)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(app: Starlette) -> AsyncIterator[None]:
|
||||
"""Context manager for session manager."""
|
||||
|
@ -377,7 +424,6 @@ def start_server(app: Server) -> int:
|
|||
debug=True,
|
||||
routes=[
|
||||
Mount("/mcp", app=handle_streamable_http),
|
||||
Route("/sse", endpoint=handle_sse, methods=["GET"]),
|
||||
Mount("/messages/", app=sse_transport.handle_post_message),
|
||||
],
|
||||
lifespan=lifespan,
|
||||
|
@ -391,71 +437,94 @@ def start_server(app: Server) -> int:
|
|||
|
||||
|
||||
def create_mcp_app() -> Server:
|
||||
app = Server("nacos_mcp_router")
|
||||
|
||||
@app.call_tool()
|
||||
@mcp_app.call_tool()
|
||||
async def call_tool(
|
||||
name: str, arguments: dict
|
||||
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
|
||||
router_logger.info(f"calling tool: {name}, arguments: {arguments}")
|
||||
headers = {}
|
||||
if mcp_app.request_context is not None and mcp_app.request_context.request is not None and mcp_app.request_context.request.headers is not None:
|
||||
headers = mcp_app.request_context.request.headers
|
||||
if mode == 'proxy':
|
||||
if proxied_mcp_name not in mcp_servers_dict:
|
||||
if await init_proxied_mcp():
|
||||
raise NameError(f"failed to init proxied mcp: {proxied_mcp_name}")
|
||||
result = await mcp_servers_dict[proxied_mcp_name].execute_tool(tool_name=name, arguments=arguments)
|
||||
result = await mcp_servers_dict[proxied_mcp_name].execute_tool(tool_name=name, arguments=arguments, client_headers=headers)
|
||||
return result.content
|
||||
else:
|
||||
match name:
|
||||
case "search_mcp_server":
|
||||
content = search_mcp_server(arguments["task_description"], arguments["key_words"])
|
||||
content = await search_mcp_server(arguments["task_description"], arguments["key_words"])
|
||||
return [types.TextContent(type="text", text=content)]
|
||||
case "add_mcp_server":
|
||||
content = await add_mcp_server(arguments["mcp_server_name"])
|
||||
content = await add_mcp_server(arguments["mcp_server_name"], headers)
|
||||
return [types.TextContent(type="text", text=content)]
|
||||
case "use_tool":
|
||||
params = json.loads(arguments["params"])
|
||||
content = await use_tool(arguments["mcp_server_name"], arguments["mcp_tool_name"], params)
|
||||
content = await use_tool(arguments["mcp_server_name"], arguments["mcp_tool_name"], params, headers)
|
||||
return [types.TextContent(type="text", text=content)]
|
||||
case _:
|
||||
return [types.TextContent(type="text", text="not implemented tool")]
|
||||
|
||||
@app.list_tools()
|
||||
@mcp_app.list_tools()
|
||||
async def list_tools() -> list[types.Tool]:
|
||||
headers = {}
|
||||
if mcp_app.request_context is not None and mcp_app.request_context.request is not None and mcp_app.request_context.request.headers is not None:
|
||||
headers = mcp_app.request_context.request.headers
|
||||
if mode == MODE_PROXY:
|
||||
return await proxied_mcp_tools()
|
||||
return await proxied_mcp_tools(headers)
|
||||
else:
|
||||
return router_tools()
|
||||
|
||||
return app
|
||||
return mcp_app
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if asyncio.run(init()) != 0:
|
||||
if init() != 0:
|
||||
return 1
|
||||
app = create_mcp_app()
|
||||
return start_server(app)
|
||||
create_mcp_app()
|
||||
return start_server()
|
||||
|
||||
|
||||
async def init() -> int:
|
||||
global mcp_updater, nacos_http_client, mode, proxied_mcp_name, proxied_mcp_server_config, transport_type, auto_register_tools
|
||||
def init() -> int:
|
||||
global mcp_app, mcp_updater, nacos_http_client, mode, proxied_mcp_name, proxied_mcp_server_config, transport_type, auto_register_tools, proxied_mcp_version
|
||||
|
||||
try:
|
||||
mcp_app = Server("nacos-mcp-router")
|
||||
nacos_addr = os.getenv("NACOS_ADDR", "127.0.0.1:8848")
|
||||
nacos_user_name = os.getenv("NACOS_USERNAME", "nacos")
|
||||
nacos_password = os.getenv("NACOS_PASSWORD", "")
|
||||
nacos_namespace = os.getenv("NACOS_NAMESPACE", "")
|
||||
nacos_http_client = NacosHttpClient(nacosAddr=nacos_addr,
|
||||
userName=nacos_user_name,
|
||||
passwd=nacos_password,
|
||||
namespaceId=nacos_namespace)
|
||||
ak = os.getenv("ACCESS_KEY_ID", "")
|
||||
sk = os.getenv("ACCESS_KEY_SECRET","")
|
||||
params = {"nacosAddr":nacos_addr,"userName": nacos_user_name, "password": nacos_password, "namespaceId": nacos_namespace, "ak": ak, "sk": sk}
|
||||
|
||||
auto_register_tools = os.getenv("AUTO_REGISTER_TOOLS", "true").lower() == "true"
|
||||
mode = os.getenv("MODE", MODE_ROUTER)
|
||||
proxied_mcp_name = os.getenv("PROXIED_MCP_NAME", "")
|
||||
proxied_mcp_server_config_str = os.getenv("PROXIED_MCP_SERVER_CONFIG", "")
|
||||
update_interval = int(os.getenv("UPDATE_INTERVAL", 60))
|
||||
|
||||
if update_interval < 10:
|
||||
update_interval = 10
|
||||
|
||||
if proxied_mcp_server_config_str != "" :
|
||||
proxied_mcp_server_config = json.loads(proxied_mcp_server_config_str)
|
||||
|
||||
transport_type = os.getenv("TRANSPORT_TYPE", TRANSPORT_TYPE_STDIO)
|
||||
|
||||
if mode == MODE_ROUTER or (mode == MODE_PROXY and auto_register_tools):
|
||||
if not isinstance(nacos_addr, str) or not nacos_addr.strip():
|
||||
raise ValueError("nacosAddr must be a non-empty string")
|
||||
|
||||
if not isinstance(nacos_user_name, str) or not nacos_user_name.strip():
|
||||
raise ValueError("userName must be a non-empty string")
|
||||
|
||||
if not isinstance(nacos_password, str) or not nacos_password.strip():
|
||||
raise ValueError("passwd must be a non-empty string")
|
||||
|
||||
nacos_http_client = NacosHttpClient(params)
|
||||
|
||||
init_str = (
|
||||
f"init server, nacos_addr: {nacos_addr}, "
|
||||
f"nacos_user_name: {nacos_user_name}, "
|
||||
|
@ -464,25 +533,24 @@ async def init() -> int:
|
|||
f"transport_type: {transport_type}, "
|
||||
f"proxied_mcp_name: {proxied_mcp_name}, "
|
||||
f"proxied_mcp_server_config: {proxied_mcp_server_config}, "
|
||||
f"auto_register_tools: {auto_register_tools}"
|
||||
f"auto_register_tools: {auto_register_tools}, "
|
||||
f"version: {version_number}"
|
||||
)
|
||||
|
||||
print(init_str)
|
||||
|
||||
router_logger.info(init_str)
|
||||
|
||||
if mode == MODE_PROXY and proxied_mcp_name == "":
|
||||
raise NacosMcpRouterException("proxied_mcp_name must be set in proxy mode")
|
||||
|
||||
if mode == MODE_PROXY and (proxied_mcp_server_config_str == "" or proxied_mcp_server_config_str is None):
|
||||
print(f"proxied_mcp_server_config_str is empty, get mcp server from nacos, proxied_mcp_name: {proxied_mcp_name}")
|
||||
mcp_server = await nacos_http_client.get_mcp_server(id="", name=proxied_mcp_name)
|
||||
print(f"proxied_mcp_server_config: {mcp_server.agent_config()}")
|
||||
proxied_mcp_server_config = mcp_server.agent_config()
|
||||
|
||||
if mode == MODE_ROUTER:
|
||||
chroma_db_service = ChromaDb()
|
||||
mcp_updater = await McpUpdater.create(nacos_http_client, chroma_db_service, 60)
|
||||
mcp_updater = McpUpdater.create(nacos_client=nacos_http_client, chroma_db=chroma_db_service, update_interval=update_interval, enable_vector_db=True, mode=mode, proxy_mcp_name=proxied_mcp_name, enable_auto_refresh=True)
|
||||
else:
|
||||
if auto_register_tools:
|
||||
mcp_updater = McpUpdater.create(nacos_client=nacos_http_client, chroma_db=None, update_interval=update_interval, enable_vector_db=False, mode=mode, proxy_mcp_name=proxied_mcp_name, enable_auto_refresh=True)
|
||||
else:
|
||||
mcp_updater = McpUpdater.create(nacos_client=nacos_http_client, chroma_db=None, update_interval=update_interval, enable_vector_db=False, mode=mode, proxy_mcp_name=proxied_mcp_name, enable_auto_refresh=False)
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
router_logger.error("failed to start", exc_info= e)
|
||||
|
|
|
@ -16,15 +16,20 @@ from mcp.client.sse import sse_client
|
|||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
from .logger import NacosMcpRouteLogger
|
||||
from .nacos_mcp_server_config import NacosMcpServerConfig
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from .mcp_transport import McpTransport
|
||||
from .sse_transport import McpSseTransport
|
||||
from .streamable_http_transport import McpStreamableHttpTransport
|
||||
|
||||
def _stdio_transport_context(config: dict[str, Any]):
|
||||
server_params = StdioServerParameters(command=config['command'], args=config['args'], env=config['env'])
|
||||
server_params = StdioServerParameters(command=config['command'], args=config['args'] if 'args' in config else [], env=config['env'] if 'env' in config else {})
|
||||
return stdio_client(server_params)
|
||||
|
||||
|
||||
def _sse_transport_context(config: dict[str, Any]):
|
||||
return sse_client(url=config['url'], headers=config['headers'] if 'headers' in config else {}, timeout=10)
|
||||
|
||||
def _streamable_http_transport_context(config: dict[str, Any]):
|
||||
return streamablehttp_client(url=config["url"], headers=config['headers'] if 'headers' in config else {})
|
||||
|
||||
class CustomServer:
|
||||
def __init__(self, name: str, config: dict[str, Any]) -> None:
|
||||
|
@ -36,13 +41,24 @@ class CustomServer:
|
|||
self.exit_stack: AsyncExitStack = AsyncExitStack()
|
||||
self._initialized_event = asyncio.Event()
|
||||
self._shutdown_event = asyncio.Event()
|
||||
if "url" in config['mcpServers'][name]:
|
||||
self._transport_context_factory = _sse_transport_context
|
||||
self._initialized: bool = False
|
||||
self._mcp_transport: McpTransport | None = None
|
||||
if 'protocol' in config['mcpServers'][name] and "mcp-sse" == config['mcpServers'][name]['protocol']:
|
||||
# self._transport_context_factory = _sse_transport_context
|
||||
self._protocol = 'mcp-sse'
|
||||
self._mcp_transport = McpSseTransport(config['mcpServers'][name]['url'], config['mcpServers'][name]['headers'])
|
||||
elif 'protocol' in config['mcpServers'][name] and "mcp-streamable" == config['mcpServers'][name]['protocol']:
|
||||
# self._transport_context_factory = _streamable_http_transport_context
|
||||
self._protocol = 'mcp-streamable'
|
||||
self._mcp_transport = McpStreamableHttpTransport(config['mcpServers'][name]['url'], config['mcpServers'][name]['headers'])
|
||||
else:
|
||||
self._transport_context_factory = _stdio_transport_context
|
||||
self._protocol = 'stdio'
|
||||
|
||||
self._server_task = asyncio.create_task(self._server_lifespan_cycle())
|
||||
|
||||
|
||||
|
||||
async def _server_lifespan_cycle(self):
|
||||
try:
|
||||
server_config = self.config
|
||||
|
@ -50,22 +66,36 @@ class CustomServer:
|
|||
mcp_servers = self.config["mcpServers"]
|
||||
for key, value in mcp_servers.items():
|
||||
server_config = value
|
||||
async with self._transport_context_factory(server_config) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
self.session_initialized_response = await session.initialize()
|
||||
self.session = session
|
||||
self._initialized = True
|
||||
self._initialized_event.set()
|
||||
await self.wait_for_shutdown_request()
|
||||
if self._protocol == 'stdio':
|
||||
async with _stdio_transport_context(server_config) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
self.session_initialized_response = await session.initialize()
|
||||
self.session = session
|
||||
self._initialized = True
|
||||
self._initialized_event.set()
|
||||
await self.wait_for_shutdown_request()
|
||||
except Exception as e:
|
||||
NacosMcpRouteLogger.get_logger().warning("failed to init mcp server " + self.name + ", config: " + str(self.config), exc_info=e)
|
||||
self._initialized = False
|
||||
self._initialized_event.set()
|
||||
self._shutdown_event.set()
|
||||
def get_initialized_response(self) -> mcp.types.InitializeResult:
|
||||
return self.session_initialized_response
|
||||
async def get_initialized_response(self, client_headers: dict[str, str] = {}) -> mcp.types.InitializeResult:
|
||||
if self._protocol == 'stdio':
|
||||
return self.session_initialized_response
|
||||
else:
|
||||
if self._mcp_transport is None:
|
||||
raise RuntimeError(f"Server {self.name} not initialized")
|
||||
return await self._mcp_transport.handle_initialize(client_headers)
|
||||
|
||||
def healthy(self) -> bool:
|
||||
return self.session is not None and self._initialized
|
||||
async def healthy(self) -> bool:
|
||||
"""更新healthy方法,增加更详细的检查"""
|
||||
if self._protocol == 'mcp-streamable' or self._protocol == 'mcp-sse':
|
||||
return True
|
||||
|
||||
return (self.session is not None and
|
||||
self._initialized and
|
||||
not self._shutdown_event.is_set()
|
||||
and not await self.is_session_disconnected())
|
||||
|
||||
async def wait_for_initialization(self):
|
||||
await self._initialized_event.wait()
|
||||
|
@ -76,13 +106,30 @@ class CustomServer:
|
|||
async def wait_for_shutdown_request(self):
|
||||
await self._shutdown_event.wait()
|
||||
|
||||
async def list_tools(self) -> list[Any]:
|
||||
if not self.session:
|
||||
raise RuntimeError(f"Server {self.name} is not initialized")
|
||||
async def list_tools(self) -> list[mcp.types.Tool]:
|
||||
return await self.list_tools_with_headers(client_headers={})
|
||||
|
||||
tools_response = await self.session.list_tools()
|
||||
async def list_tools_with_headers(self, client_headers: dict[str, str] = {}) -> list[mcp.types.Tool]:
|
||||
if self._protocol == 'mcp-streamable' or self._protocol == 'mcp-sse':
|
||||
if self._mcp_transport is None:
|
||||
raise RuntimeError(f"Server {self.name} not initialized")
|
||||
tools_response = await self._mcp_transport.handle_list_tools(client_headers)
|
||||
return tools_response.tools
|
||||
else:
|
||||
if not self.session:
|
||||
raise RuntimeError(f"Server {self.name} not initialized")
|
||||
tools_response = await self.session.list_tools()
|
||||
return tools_response.tools
|
||||
|
||||
return tools_response.tools
|
||||
async def call_tool(self, tool_name: str, arguments: dict[str, Any], client_headers: dict[str, str] = {}) -> Any:
|
||||
if self._protocol == 'mcp-streamable' or self._protocol == 'mcp-sse':
|
||||
if self._mcp_transport is None:
|
||||
raise RuntimeError(f"Server {self.name} not initialized")
|
||||
return await self._mcp_transport.handle_tool_call(arguments, client_headers, tool_name)
|
||||
else:
|
||||
if not self.session:
|
||||
raise RuntimeError(f"Server {self.name} not initialized")
|
||||
return await self.session.call_tool(tool_name, arguments)
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
|
@ -90,24 +137,24 @@ class CustomServer:
|
|||
arguments: dict[str, Any],
|
||||
retries: int = 2,
|
||||
delay: float = 1.0,
|
||||
client_headers: dict[str, str] = {}
|
||||
) -> Any:
|
||||
if not self.session:
|
||||
raise RuntimeError(f"Server {self.name} not initialized")
|
||||
|
||||
attempt = 0
|
||||
while attempt < retries:
|
||||
try:
|
||||
result = await self.session.call_tool(tool_name, arguments)
|
||||
|
||||
result = await self.call_tool(tool_name, arguments, client_headers)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(delay)
|
||||
await self.session.initialize()
|
||||
if self._protocol == 'stdio':
|
||||
if self.session is not None:
|
||||
await self.session.initialize()
|
||||
try:
|
||||
result = await self.session.call_tool(tool_name, arguments)
|
||||
result = await self.call_tool(tool_name, arguments, client_headers)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
@ -126,6 +173,79 @@ class CustomServer:
|
|||
except Exception as e:
|
||||
logging.error(f"Error during cleanup of server {self.name}: {e}")
|
||||
|
||||
async def is_session_disconnected(self, timeout: float = 5.0) -> bool:
|
||||
"""
|
||||
检查session是否断开连接
|
||||
|
||||
Args:
|
||||
timeout: 检测超时时间(秒)
|
||||
|
||||
Returns:
|
||||
bool: True表示连接断开,False表示连接正常
|
||||
"""
|
||||
# 基础检查:session对象是否存在
|
||||
if not self.session:
|
||||
NacosMcpRouteLogger.get_logger().info(f"Server {self.name}: session object is None")
|
||||
return True
|
||||
|
||||
# 检查是否已初始化
|
||||
if not self._initialized:
|
||||
NacosMcpRouteLogger.get_logger().info(f"Server {self.name}: not initialized")
|
||||
return True
|
||||
|
||||
# 检查是否请求关闭
|
||||
if self._shutdown_event.is_set():
|
||||
NacosMcpRouteLogger.get_logger().info(f"Server {self.name}: shutdown requested")
|
||||
return True
|
||||
|
||||
try:
|
||||
# 尝试执行一个轻量级操作来测试连接
|
||||
NacosMcpRouteLogger.get_logger().info(f"Server {self.name}: testing connection health")
|
||||
return await self._test_connection_health(timeout)
|
||||
except Exception as e:
|
||||
NacosMcpRouteLogger.get_logger().warning(f"Server {self.name}: connection test failed: {e}")
|
||||
return True
|
||||
|
||||
async def _test_connection_health(self, timeout: float) -> bool:
|
||||
import anyio
|
||||
"""
|
||||
测试连接健康状态
|
||||
|
||||
Args:
|
||||
timeout: 超时时间
|
||||
|
||||
Returns:
|
||||
bool: True表示连接断开,False表示连接正常
|
||||
"""
|
||||
try:
|
||||
# 使用asyncio.wait_for设置超时
|
||||
async with asyncio.timeout(timeout):
|
||||
if self.session is None:
|
||||
return True
|
||||
# 尝试调用一个简单的MCP操作
|
||||
await self.session.list_tools()
|
||||
# 更新最后活动时间
|
||||
import time
|
||||
self._last_activity_time = time.time()
|
||||
return False # 连接正常
|
||||
|
||||
except (asyncio.TimeoutError, mcp.McpError, anyio.ClosedResourceError):
|
||||
NacosMcpRouteLogger.get_logger().warning(f"Server {self.name}: connection test timeout after {timeout}s")
|
||||
return True
|
||||
except (ConnectionError, BrokenPipeError, OSError) as e:
|
||||
NacosMcpRouteLogger.get_logger().warning(f"Server {self.name}: connection error: {e}")
|
||||
return True
|
||||
except Exception as e:
|
||||
# 对于其他异常,可能是协议错误或服务器内部错误
|
||||
# 这里可以根据具体的异常类型来判断是否是连接问题
|
||||
error_msg = str(e).lower()
|
||||
if any(keyword in error_msg for keyword in ['connection', 'broken', 'closed', 'reset', 'timeout']):
|
||||
NacosMcpRouteLogger.get_logger().warning(f"Server {self.name}: connection-related error: {e}")
|
||||
return True
|
||||
else:
|
||||
# 其他错误可能不是连接问题,连接可能仍然正常
|
||||
NacosMcpRouteLogger.get_logger().error(f"Server {self.name}: non-connection error during health check", exc_info=e)
|
||||
return False
|
||||
class McpServer:
|
||||
name: str
|
||||
description: str
|
||||
|
@ -133,12 +253,13 @@ class McpServer:
|
|||
session: ClientSession
|
||||
mcp_config_detail: NacosMcpServerConfig
|
||||
agentConfig: dict[str, Any]
|
||||
mcp_config_detail: NacosMcpServerConfig
|
||||
def __init__(self, name: str, description: str, agentConfig: dict, id: str):
|
||||
version: str
|
||||
def __init__(self, name: str, description: str, agentConfig: dict, id: str, version: str):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.agentConfig = agentConfig
|
||||
self.id = id
|
||||
self.version = version
|
||||
def get_name(self) -> str:
|
||||
return self.name
|
||||
def get_description(self) -> str:
|
||||
|
@ -159,19 +280,21 @@ class ChromaDb:
|
|||
anonymized_telemetry=False,
|
||||
))
|
||||
self._collectionId = "nacos_mcp_router-collection"
|
||||
self._collection = self.dbClient.get_or_create_collection(self._collectionId)
|
||||
self._collection = self.dbClient.get_or_create_collection(name=self._collectionId)
|
||||
self.preIds = []
|
||||
|
||||
def get_collection_count (self) -> int:
|
||||
return self._collection.count()
|
||||
|
||||
def update_data(self, ids: OneOrMany[ID],
|
||||
metadatas: Optional[OneOrMany[Metadata]] = None,
|
||||
documents: Optional[OneOrMany[Document]] = None,) -> None:
|
||||
self._collection.upsert(documents=documents, metadatas=metadatas, ids=ids)
|
||||
|
||||
def get_all_ids(self) -> list[ID]:
|
||||
return self._collection.get().get('ids')
|
||||
def delete_data(self, ids: list[ID]) -> None:
|
||||
self._collection.delete(ids=ids)
|
||||
|
||||
def query(self, query: str, count: int) -> QueryResult:
|
||||
NacosMcpRouteLogger.get_logger().info(f"Querying chroma {query}")
|
||||
return self._collection.query(
|
||||
query_texts=[query],
|
||||
n_results=count
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
from mcp import ClientSession
|
||||
from mcp.types import CallToolRequest
|
||||
from mcp.client.sse import sse_client
|
||||
from typing import Any
|
||||
import asyncio
|
||||
from .mcp_transport import McpTransport
|
||||
from mcp.types import Tool
|
||||
from mcp.types import InitializeResult
|
||||
from .logger import NacosMcpRouteLogger
|
||||
from mcp.types import ListToolsResult
|
||||
class McpSseTransport(McpTransport):
|
||||
def __init__(self, url: str, headers: dict[str, str]):
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
if 'Content-Length' in self.headers:
|
||||
del self.headers['Content-Length']
|
||||
|
||||
async def handle_tool_call(self, args: dict[str, Any], client_headers: dict[str, str], name: str):
|
||||
"""处理tool调用,转发客户端headers到目标服务器"""
|
||||
# 使用特定headers连接目标服务器
|
||||
async with sse_client(
|
||||
url=self.url,
|
||||
headers=self.clean_headers(client_headers)
|
||||
) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
return await session.call_tool(name=name, arguments=args)
|
||||
async def handle_list_tools(self, client_headers: dict[str, str]) -> ListToolsResult:
|
||||
NacosMcpRouteLogger.get_logger().info(f"handle_list_tools, url: {self.url}, headers: {client_headers}")
|
||||
|
||||
async with sse_client(
|
||||
url=self.url,
|
||||
headers=self.clean_headers(client_headers)
|
||||
) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
return await session.list_tools()
|
||||
async def handle_initialize(self, client_headers: dict[str, str]) -> InitializeResult:
|
||||
async with sse_client(
|
||||
url=self.url,
|
||||
headers=self.clean_headers(client_headers)
|
||||
) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
return await session.initialize()
|
|
@ -0,0 +1,42 @@
|
|||
from mcp import ClientSession
|
||||
from mcp.types import CallToolRequest
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from typing import Any
|
||||
import asyncio
|
||||
from .mcp_transport import McpTransport
|
||||
from mcp.types import Tool
|
||||
from mcp.types import InitializeResult
|
||||
from mcp.types import ListToolsResult
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
class McpStreamableHttpTransport(McpTransport):
|
||||
def __init__(self, url: str, headers: dict[str, str]):
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
if 'Content-Length' in self.headers:
|
||||
del self.headers['Content-Length']
|
||||
async def handle_tool_call(self, args: dict[str, Any], client_headers: dict[str, str], name: str) -> CallToolResult:
|
||||
"""处理tool调用,转发客户端headers到目标服务器"""
|
||||
# 使用特定headers连接目标服务器
|
||||
|
||||
async with streamablehttp_client(
|
||||
url=self.url,
|
||||
headers=self.clean_headers(client_headers)
|
||||
) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
return await session.call_tool(name=name, arguments=args)
|
||||
async def handle_list_tools(self, client_headers: dict[str, str]) -> ListToolsResult:
|
||||
async with streamablehttp_client(
|
||||
url=self.url,
|
||||
headers=self.clean_headers(client_headers)
|
||||
) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
return await session.list_tools()
|
||||
|
||||
async def handle_initialize(self, client_headers: dict[str, str]) -> InitializeResult:
|
||||
async with streamablehttp_client(
|
||||
url=self.url,
|
||||
headers=self.clean_headers(client_headers)
|
||||
) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
return await session.initialize()
|
|
@ -1,13 +1,16 @@
|
|||
import unittest, os, asyncio
|
||||
import time
|
||||
from mcp import Tool
|
||||
from unittest.mock import AsyncMock
|
||||
from ..nacos_mcp_router.nacos_http_client import NacosHttpClient
|
||||
|
||||
|
||||
class TestAsyncGeneratorsPerformance(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from nacos_mcp_router.nacos_http_client import NacosHttpClient
|
||||
self.client = NacosHttpClient(nacosAddr="localhost:8848", userName="nacos", passwd="pass", namespaceId="public")
|
||||
ak = os.getenv("ACCESS_KEY_ID","test_ak")
|
||||
sk = os.getenv("ACCESS_KEY_SECRET","test_sk")
|
||||
params = {"nacosAddr": "localhost:8848", "userName": "nacos", "password": "pass",
|
||||
"namespaceId": "public", "ak": ak, "sk": sk}
|
||||
self.client = NacosHttpClient(params)
|
||||
async def asynchronize(self, item):
|
||||
await asyncio.sleep(0.1) # Simulate async operation
|
||||
return item * 2
|
||||
|
|
1206
src/python/uv.lock
1206
src/python/uv.lock
File diff suppressed because it is too large
Load Diff
|
@ -2,6 +2,48 @@
|
|||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
## [1.2.0](https://github.com/JayLi52/nacos-mcp-router/compare/v1.1.0...v1.2.0) (2025-09-05)
|
||||
|
||||
## [1.1.0](https://github.com/JayLi52/nacos-mcp-router/compare/v1.0.12...v1.1.0) (2025-09-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add: e2e test ([6268e06](https://github.com/JayLi52/nacos-mcp-router/commit/6268e0650363dd86f302e66264e40d34d52dd245))
|
||||
* Add: e2e test ([f888a96](https://github.com/JayLi52/nacos-mcp-router/commit/f888a96294d57f2be3418dc4212139b605b29d2e))
|
||||
* Add: e2e test ([5f72782](https://github.com/JayLi52/nacos-mcp-router/commit/5f727825d0fecedf941e2f8b78282eea1abd8cda))
|
||||
* **agent:** 增加 MCP 配置支持并优化服务器连接逻辑 ([3fce96d](https://github.com/JayLi52/nacos-mcp-router/commit/3fce96d3144fab351e6a4c15618a9d21d78a8e48))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **e2e:** 修复 SearchMcpServer 测试中的选择器问题 ([7829d99](https://github.com/JayLi52/nacos-mcp-router/commit/7829d997ccf24837ea0dbb4428cc5805b73438f9))
|
||||
* improve error handling and logging for better debugging ([acfc07b](https://github.com/JayLi52/nacos-mcp-router/commit/acfc07b3fe5f714326c4797d8e77326e901d3f32))
|
||||
* resolve module import issues and improve error handling ([c4ec978](https://github.com/JayLi52/nacos-mcp-router/commit/c4ec9789e1d75229c11e1ac53a646f6abf818da3))
|
||||
* resolve NacosMcpServer method binding issues in search pipeline ([548d4ae](https://github.com/JayLi52/nacos-mcp-router/commit/548d4aeee85353e4c99f27320c8774550cd2eb49))
|
||||
* **typescript:** 修复连接服务器时的 resolvedKey 未定义问题 ([b66cbfc](https://github.com/JayLi52/nacos-mcp-router/commit/b66cbfc6dca3f3bc1cdd584b283265eccaeff402))
|
||||
* update module resolution to Node16 for proper ESM support ([869c54e](https://github.com/JayLi52/nacos-mcp-router/commit/869c54ec6e947698c5b8e4f0ed5e129edb61fe30))
|
||||
|
||||
### [1.0.13](https://github.com/JayLi52/nacos-mcp-router/compare/v1.0.12...v1.0.13) (2025-09-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add: e2e test ([6268e06](https://github.com/JayLi52/nacos-mcp-router/commit/6268e0650363dd86f302e66264e40d34d52dd245))
|
||||
* Add: e2e test ([f888a96](https://github.com/JayLi52/nacos-mcp-router/commit/f888a96294d57f2be3418dc4212139b605b29d2e))
|
||||
* Add: e2e test ([5f72782](https://github.com/JayLi52/nacos-mcp-router/commit/5f727825d0fecedf941e2f8b78282eea1abd8cda))
|
||||
* **agent:** 增加 MCP 配置支持并优化服务器连接逻辑 ([3fce96d](https://github.com/JayLi52/nacos-mcp-router/commit/3fce96d3144fab351e6a4c15618a9d21d78a8e48))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **e2e:** 修复 SearchMcpServer 测试中的选择器问题 ([7829d99](https://github.com/JayLi52/nacos-mcp-router/commit/7829d997ccf24837ea0dbb4428cc5805b73438f9))
|
||||
* improve error handling and logging for better debugging ([acfc07b](https://github.com/JayLi52/nacos-mcp-router/commit/acfc07b3fe5f714326c4797d8e77326e901d3f32))
|
||||
* resolve module import issues and improve error handling ([c4ec978](https://github.com/JayLi52/nacos-mcp-router/commit/c4ec9789e1d75229c11e1ac53a646f6abf818da3))
|
||||
* resolve NacosMcpServer method binding issues in search pipeline ([548d4ae](https://github.com/JayLi52/nacos-mcp-router/commit/548d4aeee85353e4c99f27320c8774550cd2eb49))
|
||||
* **typescript:** 修复连接服务器时的 resolvedKey 未定义问题 ([b66cbfc](https://github.com/JayLi52/nacos-mcp-router/commit/b66cbfc6dca3f3bc1cdd584b283265eccaeff402))
|
||||
* update module resolution to Node16 for proper ESM support ([869c54e](https://github.com/JayLi52/nacos-mcp-router/commit/869c54ec6e947698c5b8e4f0ed5e129edb61fe30))
|
||||
|
||||
### [1.0.12](https://github.com/nacos-group/nacos-mcp-router/compare/v1.0.11...v1.0.12) (2025-05-15)
|
||||
|
||||
### [1.0.11](https://github.com/nacos-group/nacos-mcp-router/compare/v1.0.10...v1.0.11) (2025-05-15)
|
||||
|
|
|
@ -17,7 +17,38 @@
|
|||
### 环境要求
|
||||
- Node.js 16+
|
||||
- Nacos 服务端
|
||||
- ts-node (用于开发和测试)
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装项目依赖
|
||||
npm install
|
||||
|
||||
# 安装开发依赖(如果需要运行测试)
|
||||
npm install --save-dev ts-node jest @types/jest ts-jest
|
||||
```
|
||||
|
||||
## 开发与测试
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
# 构建项目
|
||||
npm run build
|
||||
|
||||
# 运行单元测试
|
||||
npm test
|
||||
|
||||
# 运行端到端测试
|
||||
npm run test:e2e
|
||||
|
||||
# 以 UI 模式运行端到端测试
|
||||
npm run test:e2e:ui
|
||||
|
||||
# 调试模式
|
||||
npm run debug
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
|
|
|
@ -0,0 +1,155 @@
|
|||
# E2E Testing with MCP Inspector
|
||||
|
||||
这个文档介绍了基于 **真正的 MCP Inspector + Playwright** 的端到端测试实现。
|
||||
|
||||
## 🎯 真正的 MCP Inspector E2E 测试
|
||||
|
||||
与之前的简单实现不同,现在我们使用了正确的测试方式:
|
||||
|
||||
### ✅ 正确的方式(新实现)
|
||||
1. **启动 MCP Inspector**: 使用 `npx @modelcontextprotocol/inspector node dist/stdio.js`
|
||||
2. **解析认证信息**: 从日志中提取 URL 和 AUTH_TOKEN
|
||||
3. **使用 Playwright**: 进行真正的浏览器 UI 自动化测试
|
||||
4. **模拟用户操作**: 通过 UI 点击、输入等操作测试 MCP 功能
|
||||
|
||||
### ❌ 之前的错误方式
|
||||
- 直接调用 `node dist/stdio.js`
|
||||
- 没有使用 MCP Inspector 的 Web 界面
|
||||
- 没有模拟真实的用户 UI 操作
|
||||
|
||||
## 🚀 测试架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Mock Nacos │ │ MCP Inspector │ │ Playwright │
|
||||
│ Server │◄───│ (Web UI) │◄───│ Browser Tests │
|
||||
│ (Port 8848) │ │ (Port 6274) │ │ (UI Automation)│
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Nacos MCP Router │
|
||||
│ (stdio.js) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 📋 NPM 命令
|
||||
|
||||
### 真正的 MCP Inspector E2E 测试
|
||||
```bash
|
||||
# 无头模式(推荐用于 CI/CD)
|
||||
npm run test:e2e
|
||||
|
||||
# 有头模式(可以看到浏览器操作)
|
||||
npm run test:e2e:headed
|
||||
|
||||
# 调试模式(逐步执行)
|
||||
npm run test:e2e:debug
|
||||
|
||||
# UI 模式(Playwright UI 界面)
|
||||
npm run test:e2e:ui
|
||||
|
||||
# 仅运行 Playwright 测试(需要手动启动服务)
|
||||
npm run test:playwright
|
||||
npm run test:playwright:headed
|
||||
```
|
||||
|
||||
### 旧的简单测试(已保留)
|
||||
```bash
|
||||
# 旧的直接调用方式(不是真正的 MCP Inspector 测试)
|
||||
npm run test:e2e:old
|
||||
```
|
||||
|
||||
## 🧪 测试用例
|
||||
|
||||
### 1. MCP Inspector 界面测试
|
||||
- ✅ 验证 MCP Inspector 成功启动
|
||||
- ✅ 验证 Web 界面正常加载
|
||||
- ✅ 验证认证 Token 正确设置
|
||||
|
||||
### 2. 工具列表测试
|
||||
- 🔍 检查 SearchMcpServer 工具是否在列表中
|
||||
- 🔍 验证工具参数表单是否正确显示
|
||||
|
||||
### 3. 搜索功能测试
|
||||
- 🧪 精确服务器名称搜索
|
||||
- 🧪 多关键词搜索
|
||||
- 🧪 不存在关键词的处理
|
||||
- 🧪 UI 交互操作(选择工具、填写参数、点击调用)
|
||||
|
||||
## 🔧 实现细节
|
||||
|
||||
### MCP Inspector 启动流程
|
||||
1. **环境变量设置**: 指向 Mock Nacos 服务器
|
||||
2. **启动命令**: `npx @modelcontextprotocol/inspector node dist/stdio.js`
|
||||
3. **日志解析**: 提取 URL 和认证 Token
|
||||
4. **健康检查**: 确保服务就绪
|
||||
|
||||
### Playwright 配置
|
||||
- **浏览器**: Chromium(默认)
|
||||
- **模式**: 支持 headless、headed、debug、ui
|
||||
- **截图**: 失败时自动截图
|
||||
- **视频**: 失败时录制视频
|
||||
- **报告**: HTML 格式测试报告
|
||||
|
||||
### Mock 服务器
|
||||
- **Mock Nacos**: 提供标准的 Nacos API 响应
|
||||
- **测试数据**: 包含 exact-server-name、database-query-server、file-server 等
|
||||
- **API 兼容**: 支持分页、搜索、健康检查等端点
|
||||
|
||||
## 🎯 验证结果
|
||||
|
||||
测试已验证以下流程正确工作:
|
||||
|
||||
### ✅ 成功验证的部分
|
||||
- ✅ Mock Nacos 服务器启动 (Port 8848)
|
||||
- ✅ MCP Inspector 启动 (Port 6274)
|
||||
- ✅ 认证 Token 生成和解析
|
||||
- ✅ 服务健康检查通过
|
||||
- ✅ Playwright 配置正确
|
||||
- ✅ 测试用例结构完整
|
||||
- ✅ **自动依赖安装** - 零配置运行
|
||||
|
||||
### 🔄 需要完成的部分
|
||||
- 🔄 运行完整的 UI 测试流程
|
||||
- 🔄 优化测试用例的 UI 选择器
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 一键运行(完全自动化)
|
||||
```bash
|
||||
# 构建项目并运行 E2E 测试(全自动,包含依赖安装)
|
||||
npm run test:e2e:headed
|
||||
```
|
||||
|
||||
**🎉 新特性:零配置运行!**
|
||||
- ✅ 自动检测并安装 Playwright 浏览器
|
||||
- ✅ 自动启动 Mock Nacos 服务器
|
||||
- ✅ 自动启动 MCP Inspector
|
||||
- ✅ 自动运行所有测试用例
|
||||
- ✅ 自动清理资源
|
||||
|
||||
### 手动安装(可选)
|
||||
如果你想手动控制依赖安装:
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npm run build
|
||||
npm run test:e2e:headed
|
||||
```
|
||||
|
||||
### 查看结果
|
||||
- 测试报告: `npx playwright show-report`
|
||||
- 截图位置: `test-results/`
|
||||
- 视频位置: `test-results/`
|
||||
|
||||
## 🎉 主要成就
|
||||
|
||||
1. **真正的 MCP Inspector 集成**: 不再是简单的 stdio 调用
|
||||
2. **完整的 UI 自动化**: 使用 Playwright 模拟用户操作
|
||||
3. **Mock 服务架构**: 无需外部 Nacos 依赖
|
||||
4. **多种测试模式**: 支持 headless、headed、debug、ui 模式
|
||||
5. **全自动化流程**: 一键启动所有服务并运行测试
|
||||
6. **🆕 零配置运行**: 自动检测并安装 Playwright 浏览器依赖
|
||||
|
||||
这是一个**真正的端到端测试框架**,完全基于 MCP Inspector 的 Web 界面进行 UI 自动化测试!用户只需运行一个命令即可完成所有设置和测试。
|
|
@ -0,0 +1,157 @@
|
|||
# SearchMcpServer 技术文档
|
||||
|
||||
## 目录
|
||||
- [1. 接口概述](#1-接口概述)
|
||||
- [2. 数据流分析](#2-数据流分析)
|
||||
- [2.1 接口定义与注册](#21-接口定义与注册)
|
||||
- [2.2 数据加载流程](#22-数据加载流程)
|
||||
- [2.3 索引建立](#23-索引建立)
|
||||
- [2.4 搜索与结果返回](#24-搜索与结果返回)
|
||||
- [3. 关键数据结构](#3-关键数据结构)
|
||||
- [3.1 NacosMcpServer](#31-nacosmcpserver)
|
||||
- [3.2 NacosMcpServerConfig](#32-nacosmcpserverconfig)
|
||||
- [4. 核心代码位置](#4-核心代码位置)
|
||||
- [5. 数据流总结](#5-数据流总结)
|
||||
|
||||
## 1. 接口概述
|
||||
|
||||
SearchMcpServer 是一个 MCP 工具接口,用于根据任务描述和关键字搜索 MCP 服务器。主要实现在 `src/router.ts` 的 `registerMcpTools` 方法中注册。
|
||||
|
||||
## 2. 数据流分析
|
||||
|
||||
### 2.1 接口定义与注册
|
||||
|
||||
```typescript
|
||||
// src/router.ts
|
||||
this.mcpServer.tool(
|
||||
"SearchMcpServer",
|
||||
`根据任务描述及关键字搜索mcp server...`,
|
||||
{
|
||||
taskDescription: z.string(),
|
||||
keyWords: z.string().array().nonempty().max(2)
|
||||
},
|
||||
async ({ taskDescription, keyWords }) => {
|
||||
// 处理逻辑
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 2.2 数据加载流程
|
||||
|
||||
1. **数据来源**:
|
||||
- MCP 服务器信息存储在 Nacos 配置中心
|
||||
- 通过 `NacosHttpClient` 类与 Nacos 交互
|
||||
|
||||
2. **数据加载**:
|
||||
- 系统启动时,`McpManager` 会初始化并加载 MCP 服务器信息
|
||||
- 通过 `updateNow` 方法定期更新 MCP 服务器列表
|
||||
|
||||
### 2.3 索引建立
|
||||
|
||||
1. **向量数据库**:
|
||||
- 使用 `VectorDB` 类进行向量检索
|
||||
- 在 `Router.start()` 中初始化 VectorDB
|
||||
|
||||
```typescript
|
||||
// src/router.ts
|
||||
if (!this.vectorDB) {
|
||||
this.vectorDB = new VectorDB();
|
||||
await this.vectorDB.start();
|
||||
await this.vectorDB.isReady();
|
||||
}
|
||||
```
|
||||
|
||||
2. **索引过程**:
|
||||
- MCP 服务器信息被转换为向量并存储在 VectorDB 中
|
||||
- 使用 `@xenova/transformers` 进行文本嵌入
|
||||
|
||||
### 2.4 搜索与结果返回
|
||||
|
||||
1. **搜索流程**:
|
||||
- 接收用户输入的 `taskDescription` 和 `keyWords`
|
||||
- 调用 `mcpManager.getMcpServer` 进行搜索
|
||||
|
||||
```typescript
|
||||
// src/mcp_manager.ts
|
||||
async getMcpServer(queryTexts: string, count: number): Promise<NacosMcpServer[]> {
|
||||
const result = await this.vectorDbService.query(queryTexts, count);
|
||||
// 处理并返回结果
|
||||
}
|
||||
```
|
||||
|
||||
2. **结果处理**:
|
||||
- 从 VectorDB 获取相似度最高的结果
|
||||
- 格式化返回给用户
|
||||
|
||||
## 3. 关键数据结构
|
||||
|
||||
### 3.1 NacosMcpServer
|
||||
|
||||
```typescript
|
||||
// src/router_types.ts
|
||||
export class NacosMcpServer {
|
||||
name: string;
|
||||
description: string;
|
||||
mcpConfigDetail: NacosMcpServerConfigImpl | null;
|
||||
agentConfig: Record<string, any>;
|
||||
|
||||
// 方法
|
||||
getName(): string
|
||||
getDescription(): string
|
||||
getAgentConfig(): Record<string, any>
|
||||
toDict(): Record<string, any>
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 NacosMcpServerConfig
|
||||
|
||||
```typescript
|
||||
// src/nacos_mcp_server_config.ts
|
||||
export interface NacosMcpServerConfig {
|
||||
name: string;
|
||||
protocol: string;
|
||||
description: string | null;
|
||||
version: string;
|
||||
remoteServerConfig: RemoteServerConfig;
|
||||
localServerConfig: Record<string, any>;
|
||||
enabled: boolean;
|
||||
capabilities: string[];
|
||||
backendEndpoints: BackendEndpoint[];
|
||||
toolSpec: ToolSpec;
|
||||
getToolDescription(): string;
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 核心代码位置
|
||||
|
||||
1. **接口注册**:
|
||||
- `src/router.ts` - `Router.registerMcpTools()`
|
||||
|
||||
2. **MCP 服务器管理**:
|
||||
- `src/mcp_manager.ts` - `McpManager` 类
|
||||
- `src/nacos_http_client.ts` - `NacosHttpClient` 类
|
||||
|
||||
3. **数据结构**:
|
||||
- `src/router_types.ts` - 核心数据模型
|
||||
- `src/nacos_mcp_server_config.ts` - 配置相关结构
|
||||
|
||||
4. **向量检索**:
|
||||
- `VectorDB` 类实现(在代码库中可能在其他文件)
|
||||
|
||||
## 5. 数据流总结
|
||||
|
||||
1. **初始化阶段**:
|
||||
- 启动时加载 MCP 服务器信息到内存
|
||||
- 初始化向量数据库
|
||||
|
||||
2. **搜索阶段**:
|
||||
- 接收用户查询
|
||||
- 将查询转换为向量
|
||||
- 在向量数据库中执行相似度搜索
|
||||
- 返回最匹配的 MCP 服务器列表
|
||||
|
||||
3. **更新阶段**:
|
||||
- 定期从 Nacos 同步 MCP 服务器信息
|
||||
- 更新本地缓存和向量索引
|
||||
|
||||
这个设计允许系统高效地根据自然语言描述和关键词搜索 MCP 服务器,同时保持数据的实时性。
|
|
@ -1,7 +0,0 @@
|
|||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/test/**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
|
||||
roots: ['<rootDir>/test'],
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import type { Config } from '@jest/types';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/test/unit'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/test/unit/setupTests.ts'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: 'tsconfig.test.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
collectCoverage: true,
|
||||
coverageDirectory: 'coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.test.{ts,tsx}',
|
||||
'!**/node_modules/**',
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"nacos-server": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"./dist/stdio.js"
|
||||
],
|
||||
"env": {
|
||||
"NACOS_SERVER_ADDR": "localhost:8848",
|
||||
"NACOS_USERNAME": "nacos",
|
||||
"NACOS_PASSWORD": "nacos"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "nacos-mcp-router",
|
||||
"version": "1.0.12",
|
||||
"version": "1.2.0",
|
||||
"description": "Nacos MCP Router TypeScript implementation",
|
||||
"main": "dist/stdio.js",
|
||||
"bin": {
|
||||
|
@ -15,16 +15,25 @@
|
|||
"start": "ts-node src/stdio.ts",
|
||||
"dev-stdio": "ts-node src/stdio.ts",
|
||||
"dev-sse": "ts-node src/simpleSseServer.ts",
|
||||
"test": "jest",
|
||||
"debug": "npx @modelcontextprotocol/inspector npx nacos-mcp-router"
|
||||
"test": "jest --config=jest.config.ts --setupFilesAfterEnv=./test/setupTests.ts",
|
||||
"test:watch": "jest --watch --config=jest.config.ts --setupFilesAfterEnv=./test/setupTests.ts",
|
||||
"test:coverage": "jest --coverage --config=jest.config.ts --setupFilesAfterEnv=./test/setupTests.ts",
|
||||
"test:unit": "jest",
|
||||
"test:e2e:old": "./scripts/e2e/run-search-e2e-test.sh",
|
||||
"test:e2e": "./scripts/run-mcp-inspector-e2e.sh headless",
|
||||
"test:e2e:headed": "./scripts/run-mcp-inspector-e2e.sh headed",
|
||||
"test:e2e:debug": "./scripts/run-mcp-inspector-e2e.sh debug",
|
||||
"test:e2e:ui": "./scripts/run-mcp-inspector-e2e.sh ui",
|
||||
"test:playwright": "npx playwright test",
|
||||
"test:playwright:headed": "npx playwright test --headed",
|
||||
"test:all": "./scripts/run-e2e-test.sh",
|
||||
"debug": "npx @modelcontextprotocol/inspector@latest --config mcp.json --server nacos-server"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@types/node": "^20.11.24",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"axios": "^1.9.0",
|
||||
"chromadb": "^2.3.0",
|
||||
|
@ -32,14 +41,65 @@
|
|||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"hnswlib-node": "^3.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.19.1",
|
||||
"jest": "^29.7.0",
|
||||
"playwright": "^1.54.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"ts-node-dev": "^2.0.0"
|
||||
"ts-jest": "^29.4.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest/presets/default-esm",
|
||||
"testEnvironment": "node",
|
||||
"extensionsToTreatAsEsm": [
|
||||
".ts"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
"transform": {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"useESM": true,
|
||||
"tsconfig": "tsconfig.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/test/unit/setupTests.ts"
|
||||
],
|
||||
"testMatch": [
|
||||
"**/test/unit/**/*.test.ts"
|
||||
],
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"json",
|
||||
"node"
|
||||
],
|
||||
"collectCoverage": true,
|
||||
"coverageDirectory": "coverage",
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"lcov"
|
||||
],
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.MCP_INSPECTOR_URL || 'http://localhost:6274',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Take screenshot on failure */
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
/* Record video on failure */
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Global setup/teardown */
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
|
||||
/* Test timeout */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 10 * 1000,
|
||||
},
|
||||
});
|
|
@ -0,0 +1,147 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Mock data for testing
|
||||
const mockMcpServers = [
|
||||
{
|
||||
name: 'exact-server-name',
|
||||
description: 'A test server for exact name matching exact-server-name',
|
||||
protocol: 'stdio',
|
||||
backendEndpoints: [],
|
||||
localServerConfig: {
|
||||
command: 'node',
|
||||
args: ['test-server.js']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'database-query-server',
|
||||
description: 'Handles database queries and operations',
|
||||
protocol: 'stdio',
|
||||
backendEndpoints: [],
|
||||
localServerConfig: {
|
||||
command: 'node',
|
||||
args: ['db-server.js']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'file-server',
|
||||
description: 'File management and operations server',
|
||||
protocol: 'stdio',
|
||||
backendEndpoints: [],
|
||||
localServerConfig: {
|
||||
command: 'node',
|
||||
args: ['file-server.js']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Health check endpoint for isReady() and getMcpServers()
|
||||
app.get('/nacos/v3/admin/ai/mcp/list', (req, res) => {
|
||||
console.log('Mock Nacos: Received MCP list request');
|
||||
|
||||
// Handle pagination parameters
|
||||
const pageNo = parseInt(req.query.pageNo) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize) || 100;
|
||||
|
||||
// Format response to match expected structure
|
||||
const pageItems = mockMcpServers.map(server => ({
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
enabled: true,
|
||||
protocol: server.protocol,
|
||||
createTime: new Date().toISOString(),
|
||||
updateTime: new Date().toISOString()
|
||||
}));
|
||||
|
||||
res.status(200).json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
pageItems: pageItems,
|
||||
totalCount: pageItems.length,
|
||||
pageNo: pageNo,
|
||||
pageSize: pageSize
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Get specific MCP server by name
|
||||
app.get('/nacos/v3/admin/ai/mcp', (req, res) => {
|
||||
const mcpName = req.query.mcpName;
|
||||
console.log(`Mock Nacos: Received request for MCP server: ${mcpName}`);
|
||||
|
||||
const server = mockMcpServers.find(s => s.name === mcpName);
|
||||
|
||||
if (server) {
|
||||
res.status(200).json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: server
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
code: 404,
|
||||
message: 'MCP server not found',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Search MCP servers by keyword
|
||||
app.get('/nacos/v3/admin/ai/mcp/search', (req, res) => {
|
||||
const keyword = req.query.keyword || '';
|
||||
console.log(`Mock Nacos: Received search request for keyword: ${keyword}`);
|
||||
|
||||
const filteredServers = mockMcpServers.filter(server =>
|
||||
server.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||
server.description.toLowerCase().includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: filteredServers
|
||||
});
|
||||
});
|
||||
|
||||
// Update MCP tools list (for testing purposes)
|
||||
app.post('/nacos/v3/admin/ai/mcp/tools', (req, res) => {
|
||||
const { mcpName, tools } = req.body;
|
||||
console.log(`Mock Nacos: Received tools update for ${mcpName}:`, tools);
|
||||
|
||||
res.status(200).json({
|
||||
code: 200,
|
||||
message: 'Tools updated successfully',
|
||||
data: { mcpName, toolsCount: tools ? tools.length : 0 }
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.MOCK_NACOS_PORT || 8848;
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Mock Nacos server running on port ${PORT}`);
|
||||
console.log(`Health check: http://localhost:${PORT}/nacos/v3/admin/ai/mcp/list`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Mock Nacos server shutting down...');
|
||||
server.close(() => {
|
||||
console.log('Mock Nacos server stopped');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Mock Nacos server shutting down...');
|
||||
server.close(() => {
|
||||
console.log('Mock Nacos server stopped');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
|
@ -0,0 +1,233 @@
|
|||
#!/bin/bash
|
||||
|
||||
# E2E Test for Search Functionality using MCP Inspector
|
||||
# This script tests the SearchMcpServer tool through MCP Inspector CLI
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
MOCK_NACOS_PORT=8848
|
||||
TEST_TIMEOUT=30
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
log_info "Cleaning up..."
|
||||
|
||||
# Kill mock nacos server
|
||||
if [ ! -z "$MOCK_NACOS_PID" ]; then
|
||||
log_info "Stopping mock Nacos server (PID: $MOCK_NACOS_PID)"
|
||||
kill $MOCK_NACOS_PID 2>/dev/null || true
|
||||
wait $MOCK_NACOS_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kill MCP server if running
|
||||
if [ ! -z "$MCP_SERVER_PID" ]; then
|
||||
log_info "Stopping MCP server (PID: $MCP_SERVER_PID)"
|
||||
kill $MCP_SERVER_PID 2>/dev/null || true
|
||||
wait $MCP_SERVER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
log_info "Cleanup completed"
|
||||
}
|
||||
|
||||
# Set trap for cleanup
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Helper function to wait for server to be ready
|
||||
wait_for_server() {
|
||||
local url=$1
|
||||
local timeout=$2
|
||||
local counter=0
|
||||
|
||||
log_info "Waiting for server at $url to be ready..."
|
||||
|
||||
while [ $counter -lt $timeout ]; do
|
||||
if curl -s -f "$url" > /dev/null 2>&1; then
|
||||
log_info "Server is ready!"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
counter=$((counter + 1))
|
||||
done
|
||||
|
||||
log_error "Server at $url failed to start within $timeout seconds"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Helper function to test MCP tool call
|
||||
test_mcp_tool() {
|
||||
local tool_name=$1
|
||||
local tool_args=$2
|
||||
local expected_keyword=$3
|
||||
|
||||
log_info "Testing MCP tool: $tool_name"
|
||||
log_info "Tool args: $tool_args"
|
||||
|
||||
# Create a temp file for the test
|
||||
local temp_file=$(mktemp)
|
||||
|
||||
# Create a JSON-RPC request for the tool call
|
||||
local request="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$tool_name\",\"arguments\":$tool_args}}"
|
||||
|
||||
log_info "Sending JSON-RPC request: $request"
|
||||
|
||||
# Start MCP server in background and capture its output
|
||||
echo "$request" | node "$PROJECT_ROOT/dist/stdio.js" > "$temp_file" 2>&1 &
|
||||
local mcp_pid=$!
|
||||
|
||||
# Wait for the process to complete with timeout
|
||||
local timeout=10
|
||||
local count=0
|
||||
while [ $count -lt $timeout ]; do
|
||||
if ! kill -0 $mcp_pid 2>/dev/null; then
|
||||
# Process has finished
|
||||
wait $mcp_pid 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
# Check if process is still running, if so kill it forcefully
|
||||
if kill -0 $mcp_pid 2>/dev/null; then
|
||||
log_info "Process timeout, killing MCP server process $mcp_pid"
|
||||
kill -TERM $mcp_pid 2>/dev/null || true
|
||||
sleep 2
|
||||
# If still running, force kill
|
||||
if kill -0 $mcp_pid 2>/dev/null; then
|
||||
kill -KILL $mcp_pid 2>/dev/null || true
|
||||
fi
|
||||
wait $mcp_pid 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Read the output
|
||||
local output=$(cat "$temp_file")
|
||||
rm -f "$temp_file"
|
||||
|
||||
log_info "MCP Server output: $output"
|
||||
|
||||
# Validate the response
|
||||
if echo "$output" | grep -q "error"; then
|
||||
log_error "Tool call returned an error"
|
||||
log_error "Output: $output"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if expected keyword is in the output
|
||||
if [ ! -z "$expected_keyword" ]; then
|
||||
if echo "$output" | grep -i -q "$expected_keyword"; then
|
||||
log_info "✓ Expected keyword '$expected_keyword' found in output"
|
||||
else
|
||||
log_warn "⚠ Expected keyword '$expected_keyword' not found in output"
|
||||
# Not failing the test as content might vary
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate JSON structure or success indicators
|
||||
if echo "$output" | grep -q '"content"' || echo "$output" | grep -q "successfully" || echo "$output" | grep -q "获取"; then
|
||||
log_info "✓ Valid response found"
|
||||
return 0
|
||||
else
|
||||
log_warn "⚠ Unexpected response format, but proceeding"
|
||||
log_warn "Output: $output"
|
||||
return 0 # Don't fail for format issues in early testing
|
||||
fi
|
||||
}
|
||||
|
||||
# Main test execution
|
||||
main() {
|
||||
log_info "Starting E2E test for MCP Search functionality"
|
||||
log_info "Project root: $PROJECT_ROOT"
|
||||
|
||||
# Change to project directory
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Check if dist directory exists, if not build the project
|
||||
if [ ! -d "dist" ]; then
|
||||
log_info "Building project..."
|
||||
npm run build || {
|
||||
log_error "Failed to build project"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Start mock Nacos server
|
||||
log_info "Starting mock Nacos server on port $MOCK_NACOS_PORT..."
|
||||
node "$SCRIPT_DIR/mock-nacos-server.js" &
|
||||
MOCK_NACOS_PID=$!
|
||||
|
||||
# Wait for mock Nacos server to be ready
|
||||
wait_for_server "http://localhost:$MOCK_NACOS_PORT/nacos/v3/admin/ai/mcp/list" 10 || {
|
||||
log_error "Mock Nacos server failed to start"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Set environment variables for MCP server to use mock Nacos
|
||||
export NACOS_SERVER_ADDR="localhost:$MOCK_NACOS_PORT"
|
||||
export NACOS_USERNAME="nacos"
|
||||
export NACOS_PASSWORD="nacos_password"
|
||||
export COMPASS_API_BASE="https://registry.mcphub.io"
|
||||
|
||||
log_info "Environment variables set:"
|
||||
log_info " NACOS_SERVER_ADDR=$NACOS_SERVER_ADDR"
|
||||
log_info " NACOS_USERNAME=$NACOS_USERNAME"
|
||||
|
||||
# Give a moment for everything to settle
|
||||
sleep 2
|
||||
|
||||
# Test 1: Search for exact server name
|
||||
log_info "=== Test 1: Search for exact server name ==="
|
||||
test_mcp_tool "SearchMcpServer" '{"taskDescription":"查找精确服务器名称","keyWords":["exact-server-name"]}' "exact-server-name" || {
|
||||
log_error "Test 1 failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Test 2: Search for database-related servers
|
||||
log_info "=== Test 2: Search for database-related servers ==="
|
||||
test_mcp_tool "SearchMcpServer" '{"taskDescription":"查找数据库相关服务","keyWords":["database","query"]}' "database" || {
|
||||
log_error "Test 2 failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Test 3: Search for file operations
|
||||
log_info "=== Test 3: Search for file operations ==="
|
||||
test_mcp_tool "SearchMcpServer" '{"taskDescription":"文件操作服务","keyWords":["file"]}' "file" || {
|
||||
log_error "Test 3 failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Test 4: Search with non-existent keyword (should handle gracefully)
|
||||
log_info "=== Test 4: Search with non-existent keyword ==="
|
||||
test_mcp_tool "SearchMcpServer" '{"taskDescription":"不存在的服务搜索","keyWords":["nonexistent12345"]}' "" || {
|
||||
log_error "Test 4 failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_info "🎉 All E2E tests passed!"
|
||||
log_info "SearchMcpServer tool is working correctly with MCP Inspector CLI"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
|
@ -0,0 +1,182 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Main E2E Test Runner
|
||||
# This script runs all end-to-end tests for the nacos-mcp-router project
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_header() {
|
||||
echo -e "${BLUE}=== $1 ===${NC}"
|
||||
}
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies() {
|
||||
log_info "Checking dependencies..."
|
||||
|
||||
# Check Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
log_error "Node.js is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check npm
|
||||
if ! command -v npm &> /dev/null; then
|
||||
log_error "npm is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check curl
|
||||
if ! command -v curl &> /dev/null; then
|
||||
log_error "curl is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "✓ All dependencies are available"
|
||||
}
|
||||
|
||||
# Install project dependencies
|
||||
install_dependencies() {
|
||||
log_info "Installing project dependencies..."
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
if [ ! -d "node_modules" ]; then
|
||||
npm install || {
|
||||
log_error "Failed to install dependencies"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
log_info "✓ Dependencies installed"
|
||||
}
|
||||
|
||||
# Build project
|
||||
build_project() {
|
||||
log_info "Building project..."
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
npm run build || {
|
||||
log_error "Failed to build project"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_info "✓ Project built successfully"
|
||||
}
|
||||
|
||||
# Run unit tests first
|
||||
run_unit_tests() {
|
||||
log_header "Running Unit Tests"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
npm test || {
|
||||
log_error "Unit tests failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_info "✓ Unit tests passed"
|
||||
}
|
||||
|
||||
# Run E2E tests
|
||||
run_e2e_tests() {
|
||||
log_header "Running E2E Tests"
|
||||
|
||||
# Run search functionality E2E test
|
||||
log_info "Running search functionality E2E test..."
|
||||
"$SCRIPT_DIR/e2e/run-search-e2e-test.sh" || {
|
||||
log_error "Search E2E test failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_info "✓ All E2E tests passed"
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
log_header "Nacos MCP Router E2E Test Suite"
|
||||
log_info "Project root: $PROJECT_ROOT"
|
||||
|
||||
# Check if we should skip unit tests
|
||||
SKIP_UNIT_TESTS=false
|
||||
if [ "$1" = "--skip-unit" ]; then
|
||||
SKIP_UNIT_TESTS=true
|
||||
log_warn "Skipping unit tests as requested"
|
||||
fi
|
||||
|
||||
# Check if we should only run E2E tests
|
||||
E2E_ONLY=false
|
||||
if [ "$1" = "--e2e-only" ]; then
|
||||
E2E_ONLY=true
|
||||
log_info "Running E2E tests only"
|
||||
fi
|
||||
|
||||
# Run checks and setup
|
||||
check_dependencies
|
||||
install_dependencies
|
||||
build_project
|
||||
|
||||
# Run tests
|
||||
if [ "$E2E_ONLY" = "false" ] && [ "$SKIP_UNIT_TESTS" = "false" ]; then
|
||||
run_unit_tests
|
||||
fi
|
||||
|
||||
run_e2e_tests
|
||||
|
||||
log_header "Test Suite Complete"
|
||||
log_info "🎉 All tests passed successfully!"
|
||||
log_info ""
|
||||
log_info "Summary:"
|
||||
log_info " ✓ Dependencies checked"
|
||||
log_info " ✓ Project built"
|
||||
if [ "$E2E_ONLY" = "false" ] && [ "$SKIP_UNIT_TESTS" = "false" ]; then
|
||||
log_info " ✓ Unit tests passed"
|
||||
fi
|
||||
log_info " ✓ E2E tests passed"
|
||||
log_info ""
|
||||
log_info "The nacos-mcp-router project is working correctly!"
|
||||
}
|
||||
|
||||
# Show usage
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --skip-unit Skip unit tests and run only E2E tests"
|
||||
echo " --e2e-only Run only E2E tests (same as --skip-unit)"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Run all tests"
|
||||
echo " $0 --skip-unit # Run only E2E tests"
|
||||
echo " $0 --e2e-only # Run only E2E tests"
|
||||
}
|
||||
|
||||
# Handle command line arguments
|
||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
|
@ -0,0 +1,254 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Nacos MCP Router 端到端测试启动脚本
|
||||
# 基于 MCP Inspector + Playwright 的真正 E2E 测试
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 开始 Nacos MCP Router 端到端测试流程"
|
||||
echo "======================================="
|
||||
|
||||
# 清理函数
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "🧹 清理进程..."
|
||||
if [[ -n $MCP_INSPECTOR_PID ]]; then
|
||||
kill $MCP_INSPECTOR_PID 2>/dev/null || true
|
||||
echo "✅ MCP Inspector 进程已终止"
|
||||
fi
|
||||
|
||||
if [[ -n $MOCK_NACOS_PID ]]; then
|
||||
kill $MOCK_NACOS_PID 2>/dev/null || true
|
||||
echo "✅ Mock Nacos 进程已终止"
|
||||
fi
|
||||
|
||||
# 额外清理可能占用端口的进程
|
||||
cleanup_ports
|
||||
|
||||
# 清理临时文件
|
||||
rm -f mcp-inspector.log mock-nacos.log
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 清理端口占用
|
||||
cleanup_ports() {
|
||||
local ports=(6274 6277 8848)
|
||||
for port in "${ports[@]}"; do
|
||||
local pids=$(lsof -ti :$port 2>/dev/null || true)
|
||||
if [[ -n "$pids" ]]; then
|
||||
echo "🧹 清理端口 $port 上的进程: $pids"
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
# 额外清理 inspector 相关进程 - 更精确的匹配
|
||||
pkill -f "mcp-inspector" 2>/dev/null || true
|
||||
pkill -f "scripts/e2e/mock-nacos-server.js" 2>/dev/null || true
|
||||
sleep 2
|
||||
}
|
||||
|
||||
# 设置信号处理
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# 第一步:构建项目
|
||||
echo "📦 构建 Nacos MCP Router..."
|
||||
cd "$PROJECT_ROOT"
|
||||
npm run build
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ 构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 构建完成"
|
||||
|
||||
# 第二步:检查并安装 Playwright 浏览器
|
||||
echo ""
|
||||
echo "🎭 检查 Playwright 浏览器..."
|
||||
|
||||
# 检查 Playwright 浏览器是否可用(更通用的检测方式)
|
||||
if ! npx playwright test --list > /dev/null 2>&1; then
|
||||
echo "🔄 Playwright 浏览器未安装,正在自动安装..."
|
||||
|
||||
# 安装 Playwright 浏览器
|
||||
npx playwright install chromium --with-deps
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Playwright 浏览器安装失败"
|
||||
echo "💡 提示:你也可以手动运行:npx playwright install chromium"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Playwright 浏览器安装完成"
|
||||
else
|
||||
echo "✅ Playwright 浏览器已就绪"
|
||||
fi
|
||||
|
||||
# 第三步:启动 Mock Nacos 服务器
|
||||
echo ""
|
||||
echo "🔄 启动 Mock Nacos 服务器..."
|
||||
|
||||
# 先清理可能占用的端口
|
||||
echo "🧹 清理现有端口占用..."
|
||||
cleanup_ports
|
||||
|
||||
node "$SCRIPT_DIR/e2e/mock-nacos-server.js" > mock-nacos.log 2>&1 &
|
||||
MOCK_NACOS_PID=$!
|
||||
|
||||
echo "⏳ 等待 Mock Nacos 服务器启动..."
|
||||
sleep 3
|
||||
|
||||
# 检查 Mock Nacos 是否启动成功
|
||||
if ! curl -s "http://localhost:8848/nacos/v3/admin/ai/mcp/list" > /dev/null 2>&1; then
|
||||
echo "❌ Mock Nacos 服务器启动失败"
|
||||
echo "日志内容:"
|
||||
cat mock-nacos.log 2>/dev/null || echo "无法读取日志文件"
|
||||
cleanup
|
||||
fi
|
||||
|
||||
echo "✅ Mock Nacos 服务器已启动"
|
||||
|
||||
# 第四步:启动 MCP Inspector
|
||||
echo ""
|
||||
echo "🔄 启动 MCP Inspector..."
|
||||
|
||||
# 设置环境变量指向 Mock Nacos
|
||||
export NACOS_SERVER_ADDR="localhost:8848"
|
||||
export NACOS_USERNAME="nacos"
|
||||
export NACOS_PASSWORD="nacos_password"
|
||||
export COMPASS_API_BASE="https://registry.mcphub.io"
|
||||
|
||||
ENABLE_FILE_LOGGING=true npx @modelcontextprotocol/inspector node "$PROJECT_ROOT/dist/stdio.js" > mcp-inspector.log 2>&1 &
|
||||
MCP_INSPECTOR_PID=$!
|
||||
|
||||
echo "⏳ 等待 MCP Inspector 启动..."
|
||||
|
||||
# 等待并解析 MCP Inspector 输出
|
||||
timeout=30
|
||||
count=0
|
||||
INSPECTOR_URL=""
|
||||
AUTH_TOKEN=""
|
||||
|
||||
while [ $count -lt $timeout ]; do
|
||||
if [[ -f mcp-inspector.log ]]; then
|
||||
# 首先检查是否有带 token 的完整 URL
|
||||
if grep -q "inspector with token pre-filled" mcp-inspector.log; then
|
||||
INSPECTOR_URL=$(grep -o "http://localhost:[0-9]*/?MCP_PROXY_AUTH_TOKEN=[a-f0-9-]*" mcp-inspector.log | head -1)
|
||||
if [[ -n $INSPECTOR_URL ]]; then
|
||||
# 提取 token 和 base URL
|
||||
AUTH_TOKEN=$(echo $INSPECTOR_URL | grep -o "MCP_PROXY_AUTH_TOKEN=[a-f0-9-]*" | cut -d'=' -f2)
|
||||
BASE_URL=$(echo $INSPECTOR_URL | cut -d'?' -f1)
|
||||
echo "✅ 找到完整的 Inspector URL: $INSPECTOR_URL"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查服务器是否启动(寻找端口信息)
|
||||
if grep -q "localhost:6274" mcp-inspector.log; then
|
||||
BASE_URL="http://localhost:6274"
|
||||
# 尝试多种方式提取 token
|
||||
AUTH_TOKEN=$(grep -oE "token[\"':]*[[:space:]]*[\"']?[a-f0-9-]+" mcp-inspector.log | head -1 | grep -oE "[a-f0-9-]+$" || echo "")
|
||||
|
||||
# 如果没有找到 token,尝试其他模式
|
||||
if [[ -z $AUTH_TOKEN ]]; then
|
||||
AUTH_TOKEN=$(grep -oE "MCP_PROXY_AUTH_TOKEN[=:][\"']?[a-f0-9-]+" mcp-inspector.log | head -1 | grep -oE "[a-f0-9-]+$" || echo "")
|
||||
fi
|
||||
|
||||
if [[ -n $AUTH_TOKEN ]]; then
|
||||
INSPECTOR_URL="$BASE_URL?MCP_PROXY_AUTH_TOKEN=$AUTH_TOKEN"
|
||||
echo "✅ 从日志提取到 Inspector URL: $INSPECTOR_URL"
|
||||
break
|
||||
else
|
||||
echo "⚠️ 找到服务器但未找到 token,使用基础 URL: $BASE_URL"
|
||||
INSPECTOR_URL="$BASE_URL"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
if [[ -z $BASE_URL ]]; then
|
||||
echo "❌ MCP Inspector 启动失败或超时"
|
||||
echo "日志内容:"
|
||||
cat mcp-inspector.log 2>/dev/null || echo "无法读取日志文件"
|
||||
cleanup
|
||||
fi
|
||||
|
||||
echo "✅ MCP Inspector 已启动"
|
||||
echo "📍 URL: $BASE_URL"
|
||||
echo "🔑 Token: $AUTH_TOKEN"
|
||||
|
||||
# 第五步:等待服务就绪
|
||||
echo ""
|
||||
echo "⏳ 等待服务就绪..."
|
||||
for i in {1..10}; do
|
||||
if curl -s "$BASE_URL" > /dev/null 2>&1; then
|
||||
echo "✅ 服务就绪"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 10 ]; then
|
||||
echo "❌ 服务未就绪,超时"
|
||||
cleanup
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 第六步:运行 Playwright 测试
|
||||
echo ""
|
||||
echo "🧪 运行 Playwright 测试..."
|
||||
echo "使用 URL: $INSPECTOR_URL"
|
||||
|
||||
# 导出环境变量供 Playwright 使用
|
||||
export MCP_AUTH_TOKEN="$AUTH_TOKEN"
|
||||
export MCP_INSPECTOR_URL="$BASE_URL"
|
||||
export MCP_INSPECTOR_FULL_URL="$INSPECTOR_URL"
|
||||
|
||||
# 运行测试(根据参数选择模式)
|
||||
TEST_MODE=${1:-"headed"}
|
||||
|
||||
case $TEST_MODE in
|
||||
"headless")
|
||||
echo "🔧 运行无头模式测试..."
|
||||
NODE_OPTIONS='--no-deprecation' npx playwright test
|
||||
;;
|
||||
"debug")
|
||||
echo "🐛 运行调试模式测试..."
|
||||
NODE_OPTIONS='--no-deprecation' npx playwright test --debug
|
||||
;;
|
||||
"ui")
|
||||
echo "🎨 运行 UI 模式测试..."
|
||||
NODE_OPTIONS='--no-deprecation' npx playwright test --ui
|
||||
;;
|
||||
*)
|
||||
echo "👀 运行有头模式测试..."
|
||||
NODE_OPTIONS='--no-deprecation' npx playwright test --headed
|
||||
;;
|
||||
esac
|
||||
|
||||
TEST_EXIT_CODE=$?
|
||||
|
||||
# 第七步:显示结果
|
||||
echo ""
|
||||
echo "======================================="
|
||||
if [ $TEST_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ 测试完成!所有测试通过"
|
||||
else
|
||||
echo "❌ 测试完成,但有测试失败"
|
||||
echo "📊 查看详细报告: npx playwright show-report"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📊 测试报告和截图位置: test-results/"
|
||||
echo "🔗 Inspector 仍在运行: $INSPECTOR_URL"
|
||||
echo ""
|
||||
echo "按 Ctrl+C 停止所有服务并退出..."
|
||||
|
||||
# 保持脚本运行直到用户中断
|
||||
while true; do
|
||||
sleep 1
|
||||
done
|
|
@ -27,7 +27,7 @@ export class McpManager {
|
|||
private async updateNow(): Promise<void> {
|
||||
try {
|
||||
const mcpServers = await this.nacosClient.getMcpServers();
|
||||
logger.info(`get mcp server list from nacos, size: ${mcpServers.length}`);
|
||||
logger.debug(`get mcp server list from nacos, size: ${mcpServers.length}`);
|
||||
|
||||
if (mcpServers.length === 0) {
|
||||
return;
|
||||
|
@ -57,9 +57,9 @@ export class McpManager {
|
|||
}
|
||||
}
|
||||
|
||||
logger.info(`updated mcp server cache, size: ${cache.size}`);
|
||||
logger.debug(`updated mcp server cache, size: ${cache.size}`);
|
||||
const mcpServerNames = Array.from(cache.keys());
|
||||
logger.info(`updated mcp server names: ${mcpServerNames.join(", ")}`);
|
||||
logger.debug(`updated mcp server names: ${mcpServerNames.join(", ")}`);
|
||||
|
||||
this._cache = cache;
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { HierarchicalNSW } from 'hnswlib-node';
|
||||
// import { pipeline } from '@xenova/transformers'; // 改为动态导入
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
|
|
@ -159,13 +159,10 @@ export class NacosHttpClient {
|
|||
|
||||
logger.info(`update mcp tools, params ${JSON.stringify(params)}`);
|
||||
|
||||
const updateUrl = `http://${this.nacosAddr}/nacos/v3/admin/ai/mcp?`;
|
||||
const updateResponse = await axios.put(updateUrl, params, {
|
||||
const updateResponse = await this.client.put('/nacos/v3/admin/ai/mcp', params, {
|
||||
// Override only what differs from default headers
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'charset': 'utf-8',
|
||||
'userName': this.userName,
|
||||
'password': this.passwd
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -6,8 +6,12 @@ import { logger } from "./logger";
|
|||
import { z } from "zod";
|
||||
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { VectorDB, NacosMcpServer } from "./router_types";
|
||||
import { SearchParams, SearchProvider } from "./types/search";
|
||||
import { NacosMcpProvider } from "./services/search/NacosMcpProvider";
|
||||
import { SearchService, COMPASS_API_BASE } from "./services/search/SearchService";
|
||||
// import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { CompassSearchProvider } from "./services/search/CompassSearchProvider";
|
||||
|
||||
const MCP_SERVER_NAME = "nacos-mcp-router";
|
||||
|
||||
|
@ -33,6 +37,7 @@ export class Router {
|
|||
private nacosClient: NacosHttpClient;
|
||||
private mcpManager: McpManager | undefined;
|
||||
private vectorDB: VectorDB | undefined;
|
||||
private searchService: SearchService | undefined;
|
||||
private mcpServer: McpServer | undefined;
|
||||
|
||||
constructor(config: RouterConfig) {
|
||||
|
@ -53,21 +58,7 @@ export class Router {
|
|||
}).max(2) },
|
||||
async ({ taskDescription, keyWords }) => {
|
||||
try {
|
||||
const mcpServers1: NacosMcpServer[] = [];
|
||||
|
||||
// 根据关键字搜索MCP服务器
|
||||
for (const keyWord of keyWords) {
|
||||
const mcps = await this.mcpManager!.searchMcpByKeyword(keyWord);
|
||||
if (mcps.length > 0) {
|
||||
mcpServers1.push(...mcps);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到的服务器数量少于5个,使用任务描述进行从向量库补充搜索
|
||||
if (mcpServers1.length < 5) {
|
||||
const additionalServers = await this.mcpManager!.getMcpServer(taskDescription, 5 - mcpServers1.length);
|
||||
mcpServers1.push(...additionalServers);
|
||||
}
|
||||
const mcpServers1: NacosMcpServer[] = await this.searchMcpServer(taskDescription,keyWords);
|
||||
|
||||
// 构建结果
|
||||
const result: Record<string, { name: string; description: string }> = {};
|
||||
|
@ -151,14 +142,62 @@ ${content}
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for MCP servers using the configured search service
|
||||
* @param taskDescription Description of the task to search for
|
||||
* @param keyWords Additional keywords to refine the search
|
||||
* @returns Array of matching NacosMcpServer instances
|
||||
*/
|
||||
public async searchMcpServer(taskDescription: string, keyWords: [string, ...string[]]): Promise<NacosMcpServer[]> {
|
||||
if (!this.searchService) {
|
||||
throw new McpError(ErrorCode.InternalError, "Search service not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
taskDescription,
|
||||
keywords: keyWords,
|
||||
// Include any additional search parameters as needed
|
||||
};
|
||||
|
||||
// Use the search service to get results from all providers
|
||||
const results = await this.searchService.search(params);
|
||||
|
||||
// Ensure we return results in the expected format with proper method bindings
|
||||
return results.map(server => {
|
||||
// Create a new object with all properties from the server
|
||||
const result = { ...server } as NacosMcpServer;
|
||||
|
||||
// Add methods with proper 'this' binding
|
||||
result.getName = function() { return this.name; };
|
||||
result.getDescription = function() { return this.description || ''; };
|
||||
result.getAgentConfig = function() { return this.agentConfig || {}; };
|
||||
result.toDict = function() {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description || '',
|
||||
mcpConfigDetail: this.mcpConfigDetail,
|
||||
agentConfig: this.agentConfig || {}
|
||||
};
|
||||
};
|
||||
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in searchMcpServer:', error);
|
||||
throw new McpError(ErrorCode.InternalError, `Search failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async start(replaceTransport?: Transport) {
|
||||
try {
|
||||
// const modelName = "all-MiniLM-L6-v2";
|
||||
// const defaultEF = new DefaultEmbeddingFunction({ model: modelName });
|
||||
// console.log(`defaultEF: ${defaultEF}`);
|
||||
|
||||
const { env } = await import("@xenova/transformers");
|
||||
(env as any).remoteHost = "https://hf-mirror.com";
|
||||
const { env } = await import('@xenova/transformers');
|
||||
const mirrorHost = process.env.HF_MIRROR_HOST || 'https://hf-mirror.com';
|
||||
(env as any).remoteHost = mirrorHost;
|
||||
if (!this.vectorDB) {
|
||||
this.vectorDB = new VectorDB();
|
||||
await this.vectorDB.start();
|
||||
|
@ -171,7 +210,14 @@ ${content}
|
|||
}
|
||||
logger.info(`nacosClient is ready: ${isReady}`);
|
||||
if (!this.mcpManager) {
|
||||
// 初始化核心服务
|
||||
this.mcpManager = new McpManager(this.nacosClient, this.vectorDB, 5000);
|
||||
|
||||
// Initialize search service with providers
|
||||
const nacosProvider = new NacosMcpProvider(this.mcpManager);
|
||||
const compassProvider = new CompassSearchProvider(COMPASS_API_BASE);
|
||||
|
||||
this.searchService = new SearchService([nacosProvider, compassProvider]);
|
||||
}
|
||||
if (!this.mcpServer) {
|
||||
this.mcpServer = new McpServer({
|
||||
|
|
|
@ -9,6 +9,7 @@ import { CallToolResultSchema, ListResourcesResultSchema, LoggingMessageNotifica
|
|||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
|
||||
function _stdioTransportContext(config: Record<string, any>): StdioClientTransport {
|
||||
logger.info(`stdio transport context, config: ${JSON.stringify(config)}`);
|
||||
return new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
|
@ -36,6 +37,7 @@ export class CustomServer {
|
|||
private client: Client | undefined;
|
||||
private sessionId: string | undefined;
|
||||
private protocol: string;
|
||||
private selectedServerKey: string | undefined;
|
||||
constructor(name: string, config: Record<string, any>, protocol: string) {
|
||||
this.name = name;
|
||||
this.config = config;
|
||||
|
@ -57,6 +59,29 @@ export class CustomServer {
|
|||
// })
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析服务器键,处理别名和错误情况
|
||||
* @param key 要解析的键名
|
||||
* @param context 上下文信息,用于日志记录
|
||||
* @returns 解析后的服务器键
|
||||
*/
|
||||
private resolveServerKey(key: string, context: string = 'server'): string {
|
||||
const serverKeys = this.config?.mcpServers ? Object.keys(this.config.mcpServers) : [];
|
||||
let resolvedKey = key;
|
||||
|
||||
if (!serverKeys.includes(resolvedKey)) {
|
||||
if (serverKeys.length === 1) {
|
||||
resolvedKey = serverKeys[0];
|
||||
logger.warn(`${context} 使用的 key '${key}' 不在 mcpServers 中,自动使用唯一 key '${resolvedKey}'`);
|
||||
} else {
|
||||
logger.error(`${context} 使用的 key '${key}' 不在 mcpServers 中,可用 keys: ${JSON.stringify(serverKeys)}`);
|
||||
throw new Error(`${context} failed: server key '${key}' not found in agentConfig.mcpServers`);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedKey;
|
||||
}
|
||||
|
||||
public async start(mcpServerName: string) {
|
||||
let notificationCount = 0;
|
||||
// Create a new client
|
||||
|
@ -92,15 +117,19 @@ export class CustomServer {
|
|||
logger.error('Failed to list resources after change notification');
|
||||
}
|
||||
});
|
||||
// 解析实际的 server key(避免传入别名导致取值为 undefined)
|
||||
this.selectedServerKey = this.resolveServerKey(mcpServerName, 'mcpServerName');
|
||||
|
||||
// Connect the client
|
||||
let transport: Transport;
|
||||
if (this.protocol === 'mcp-streamble') {
|
||||
transport = this._transportContextFactory({
|
||||
...this.config.mcpServers[mcpServerName],
|
||||
...this.config.mcpServers[this.selectedServerKey!],
|
||||
sessionId: this.sessionId // StreamableHttpTransport 需要Client保存sessionId
|
||||
});
|
||||
} else {
|
||||
transport = this._transportContextFactory(this.config.mcpServers[mcpServerName]);
|
||||
logger.info(`stdio transport context, config: ${JSON.stringify(this.config)}`);
|
||||
transport = this._transportContextFactory(this.config.mcpServers[this.selectedServerKey!]);
|
||||
}
|
||||
await this.client.connect(transport)
|
||||
// TODO: StreamableHttpTransport 未返回SessionId,没有赋值成功 看看transport由哪里初始化
|
||||
|
@ -181,8 +210,7 @@ export class CustomServer {
|
|||
|
||||
const executeWithRetry = async (attempt: number): Promise<any> => {
|
||||
try {
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
+ setTimeout(() => reject(new Error('Request timeout')), 10000));
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 10000));
|
||||
|
||||
const result = await Promise.race([timeoutPromise, this.client!.request({
|
||||
method: 'tools/call',
|
||||
|
@ -208,7 +236,9 @@ export class CustomServer {
|
|||
// Try to reconnect if needed
|
||||
if (!(await this.healthy())) {
|
||||
logger.info(`Reconnecting to server ${this.name} before retry`);
|
||||
const transport = this._transportContextFactory(this.config.mcpServers[this.name]);
|
||||
const key = this.selectedServerKey || this.name;
|
||||
const resolvedKey = this.resolveServerKey(key, 'reconnect');
|
||||
const transport = this._transportContextFactory(this.config.mcpServers[resolvedKey]);
|
||||
await this.client!.connect(transport);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
import { SearchProvider } from "../../types/search";
|
||||
import { NacosMcpServer } from "../../types/nacos_mcp_server";
|
||||
import { logger } from "../../logger";
|
||||
import { NacosMcpServer as BaseNacosMcpServer } from "../../router_types";
|
||||
|
||||
/**
|
||||
* COMPASS API search provider implementation that adapts to NacosMcpServer
|
||||
*/
|
||||
export class CompassSearchProvider implements SearchProvider {
|
||||
private apiBase: string;
|
||||
private defaultAgentConfig: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Create a new CompassSearchProvider
|
||||
* @param apiBase Base URL for the COMPASS API
|
||||
* @param defaultAgentConfig Default agent configuration for created NacosMcpServer instances
|
||||
*/
|
||||
constructor(apiBase: string, defaultAgentConfig: Record<string, any> = {}) {
|
||||
if (!apiBase.endsWith('/')) {
|
||||
apiBase = apiBase + '/';
|
||||
}
|
||||
this.apiBase = apiBase;
|
||||
this.defaultAgentConfig = defaultAgentConfig;
|
||||
logger.info(`CompassSearchProvider initialized with API base: ${this.apiBase}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for MCP servers using the COMPASS API and convert results to NacosMcpServer
|
||||
* @param params Search parameters including task description and optional filters
|
||||
* @returns Promise with array of NacosMcpServer instances
|
||||
*/
|
||||
async search(params: Parameters<SearchProvider['search']>[0]): ReturnType<SearchProvider['search']> {
|
||||
const query = [
|
||||
params.taskDescription,
|
||||
...(params.keywords || []),
|
||||
...(params.capabilities || [])
|
||||
].join(' ').trim();
|
||||
|
||||
try {
|
||||
logger.debug(`Searching COMPASS API with query: ${query}`);
|
||||
const requestUrl = `${this.apiBase}recommend?description=${encodeURIComponent(query)}`;
|
||||
|
||||
const response = await fetch(requestUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = `COMPASS API request failed with status ${response.status}`;
|
||||
const error = new Error(errorMsg);
|
||||
logger.error(errorMsg, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: requestUrl,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = await response.json() as Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
github_url: string;
|
||||
score: number;
|
||||
}>;
|
||||
|
||||
logger.debug(`Received ${data.length} results from COMPASS API`);
|
||||
|
||||
// Convert MCPServerResponse to NacosMcpServer
|
||||
const results: NacosMcpServer[] = [];
|
||||
for (const item of data) {
|
||||
try {
|
||||
// First create a base NacosMcpServer instance
|
||||
const baseServer = new BaseNacosMcpServer(
|
||||
item.title,
|
||||
item.description,
|
||||
{
|
||||
...this.defaultAgentConfig,
|
||||
source: 'compass',
|
||||
sourceUrl: item.github_url,
|
||||
categories: [],
|
||||
tags: []
|
||||
}
|
||||
);
|
||||
|
||||
// Then enhance it with search-specific properties
|
||||
const nacosServer = Object.assign(baseServer, {
|
||||
providerName: 'compass',
|
||||
similarity: item.score,
|
||||
score: item.score
|
||||
});
|
||||
results.push(nacosServer);
|
||||
} catch (error) {
|
||||
logger.error('Error converting COMPASS result to NacosMcpServer:', {
|
||||
error,
|
||||
item,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Error in CompassSearchProvider: ${message}`, {
|
||||
error,
|
||||
query,
|
||||
apiBase: this.apiBase,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { NacosMcpServer } from '../../router_types';
|
||||
import { McpManager } from '../../mcp_manager';
|
||||
import { SearchParams, SearchProvider } from '../../types/search';
|
||||
|
||||
/**
|
||||
* Default implementation backed by the existing {@link McpManager} logic that
|
||||
* queries Nacos and the in-memory vector DB.
|
||||
*/
|
||||
export class NacosMcpProvider implements SearchProvider {
|
||||
private readonly mcpManager: McpManager;
|
||||
|
||||
constructor(mcpManager: McpManager) {
|
||||
this.mcpManager = mcpManager;
|
||||
}
|
||||
|
||||
async search(params: SearchParams): Promise<NacosMcpServer[]> {
|
||||
const { taskDescription, keywords = [] } = params;
|
||||
|
||||
const candidates: NacosMcpServer[] = [];
|
||||
|
||||
// 1. Keyword search (exact / fuzzy match in cache)
|
||||
for (const keyword of keywords) {
|
||||
const byKeyword = await this.mcpManager.searchMcpByKeyword(keyword);
|
||||
if (byKeyword.length > 0) {
|
||||
candidates.push(...byKeyword);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Vector DB semantic search if results are fewer than 5
|
||||
if (candidates.length < 5) {
|
||||
const additional = await this.mcpManager.getMcpServer(
|
||||
taskDescription,
|
||||
5 - candidates.length,
|
||||
);
|
||||
candidates.push(...additional);
|
||||
}
|
||||
|
||||
// TODO: 去重 / rerank – 留待后续的结果处理组件实现
|
||||
return candidates;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
import { SearchParams, SearchProvider } from "../../types/search";
|
||||
import { logger } from "../../logger";
|
||||
import { RerankMcpServer, type ProviderPriorities, type RerankOptions } from "./rerank/RerankMcpServer";
|
||||
import { type ProviderResult } from "../../types/rerank";
|
||||
import { NacosMcpServer, createMcpProviderResult as createServer } from "../../types/nacos_mcp_server";
|
||||
import { CompassSearchProvider } from "./CompassSearchProvider";
|
||||
|
||||
/**
|
||||
* Base URL for the COMPASS API.
|
||||
* Can be overridden by setting the COMPASS_API_BASE environment variable.
|
||||
*/
|
||||
export const COMPASS_API_BASE = process.env.COMPASS_API_BASE || 'https://registry.mcphub.io';
|
||||
|
||||
// Helper to ensure we have a properly typed server with all required methods
|
||||
function ensureEnhancedServer(server: any): NacosMcpServer {
|
||||
// If it's already a proper NacosMcpServer with all methods, return as is
|
||||
if (server &&
|
||||
typeof server.getName === 'function' &&
|
||||
typeof server.getDescription === 'function' &&
|
||||
typeof server.getAgentConfig === 'function' &&
|
||||
typeof server.toDict === 'function') {
|
||||
return server as NacosMcpServer;
|
||||
}
|
||||
|
||||
// Otherwise create a new NacosMcpServer instance with all required methods
|
||||
return createServer({
|
||||
...server,
|
||||
name: server.name || '',
|
||||
description: server.description || '',
|
||||
agentConfig: server.agentConfig || {},
|
||||
mcpConfigDetail: server.mcpConfigDetail || null
|
||||
}, {
|
||||
providerName: server.providerName || 'unknown',
|
||||
similarity: server.similarity || 0,
|
||||
score: server.score || 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A lightweight search service that orchestrates multiple SearchProviders
|
||||
* and provides a single `search` facade. The implementation is simplified
|
||||
* compared to the mcpadvisor version but keeps extensibility hooks (add / remove
|
||||
* provider, result dedup / basic priority ordering).
|
||||
*/
|
||||
export class SearchService {
|
||||
private providers: SearchProvider[] = [];
|
||||
private rerankService: RerankMcpServer;
|
||||
private defaultRerankOptions: RerankOptions = {
|
||||
limit: 7,
|
||||
minSimilarity: 0.4,
|
||||
enableProfessionalRerank: false,
|
||||
};
|
||||
|
||||
constructor(
|
||||
providers: SearchProvider[] = [],
|
||||
providerPriorities: ProviderPriorities = {},
|
||||
rerankOptions?: Partial<RerankOptions>,
|
||||
enableCompass: boolean = true
|
||||
) {
|
||||
this.providers = [...providers];
|
||||
if (enableCompass) {
|
||||
const compassProvider = new CompassSearchProvider(COMPASS_API_BASE);
|
||||
this.providers.push(compassProvider);
|
||||
}
|
||||
|
||||
this.defaultRerankOptions = { ...this.defaultRerankOptions, ...rerankOptions };
|
||||
this.rerankService = new RerankMcpServer(providerPriorities, this.defaultRerankOptions);
|
||||
|
||||
logger.info(`SearchService initialized with ${this.providers.length} providers.`);
|
||||
logger.debug(`COMPASS_API_BASE: ${COMPASS_API_BASE}`);
|
||||
}
|
||||
|
||||
/** Add a provider at runtime */
|
||||
addProvider(provider: SearchProvider): void {
|
||||
this.providers.push(provider);
|
||||
}
|
||||
|
||||
/** Remove provider by index */
|
||||
removeProvider(index: number): void {
|
||||
if (index >= 0 && index < this.providers.length) {
|
||||
this.providers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Return copy of current providers list */
|
||||
getProviders(): SearchProvider[] {
|
||||
return [...this.providers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider priorities for reranking
|
||||
*/
|
||||
updateProviderPriorities(priorities: ProviderPriorities): void {
|
||||
this.rerankService.updateProviderPriorities(priorities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update default rerank options
|
||||
*/
|
||||
updateRerankOptions(options: Partial<RerankOptions>): void {
|
||||
this.defaultRerankOptions = { ...this.defaultRerankOptions, ...options };
|
||||
this.rerankService.updateDefaultOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke all providers in parallel, merge, deduplicate and rerank results.
|
||||
*/
|
||||
async search(
|
||||
params: SearchParams,
|
||||
rerankOptions: Partial<RerankOptions> = {}
|
||||
): Promise<NacosMcpServer[]> {
|
||||
if (this.providers.length === 0) {
|
||||
logger.warn("No search providers registered, returning empty result.");
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info(`Searching with params: ${JSON.stringify(params)}`);
|
||||
const providerResults: ProviderResult[] = [];
|
||||
const searchPromises = this.providers.map(async (provider) => {
|
||||
const providerName = provider.constructor.name;
|
||||
try {
|
||||
const results = await provider.search(params);
|
||||
const typedResults = results.map(result =>
|
||||
ensureEnhancedServer({
|
||||
...result,
|
||||
providerName
|
||||
})
|
||||
);
|
||||
|
||||
providerResults.push({
|
||||
providerName,
|
||||
results: typedResults,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Provider ${providerName} failed:`, err);
|
||||
// Push empty results on error
|
||||
providerResults.push({
|
||||
providerName,
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
try {
|
||||
// Merge and rerank results
|
||||
const mergedOptions = { ...this.defaultRerankOptions, ...rerankOptions };
|
||||
logger.info(`Reranking with options: ${JSON.stringify(mergedOptions)}`);
|
||||
|
||||
const rerankedResults = await this.rerankService.rerank(providerResults, mergedOptions);
|
||||
|
||||
logger.info(`Successfully reranked to ${rerankedResults.length} results`);
|
||||
return rerankedResults;
|
||||
} catch (error) {
|
||||
logger.error('Error during reranking:', error);
|
||||
// Fallback to simple merge if reranking fails
|
||||
const allResults = providerResults.flatMap(pr => pr.results);
|
||||
return [...new Map(allResults.map(r => [r.getName(), r])).values()];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
import { logger } from "../../../logger";
|
||||
import {
|
||||
ProviderPriorities,
|
||||
RerankOptions,
|
||||
ProviderResult,
|
||||
IRerankProcessor
|
||||
} from "../../../types/rerank";
|
||||
import { RerankProcessorFactory } from "./processors";
|
||||
import { NacosMcpServer, isNacosMcpServer, createMcpProviderResult } from "../../../types/nacos_mcp_server";
|
||||
|
||||
// Re-export types for external use
|
||||
export type { ProviderPriorities, RerankOptions } from "../../../types/rerank";
|
||||
|
||||
/**
|
||||
* Service for re-ranking MCP server search results from multiple providers
|
||||
*/
|
||||
export class RerankMcpServer {
|
||||
private processor: IRerankProcessor;
|
||||
private defaultOptions: Required<RerankOptions>;
|
||||
|
||||
constructor(
|
||||
private providerPriorities: ProviderPriorities = {},
|
||||
defaultOptions: Partial<RerankOptions> = {}
|
||||
) {
|
||||
this.defaultOptions = {
|
||||
limit: 7,
|
||||
minSimilarity: 0,
|
||||
enableProfessionalRerank: false,
|
||||
...defaultOptions
|
||||
};
|
||||
|
||||
// Create the processor chain
|
||||
this.processor = RerankProcessorFactory.createChain(providerPriorities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge and rerank results from multiple providers
|
||||
*/
|
||||
async rerank(
|
||||
providerResults: ProviderResult[],
|
||||
options: Partial<RerankOptions> = {}
|
||||
): Promise<NacosMcpServer[]> {
|
||||
const mergedOptions = { ...this.defaultOptions, ...options };
|
||||
|
||||
// Flatten and deduplicate results by name before processing
|
||||
const { merged, duplicates } = this.mergeAndDeduplicate(providerResults);
|
||||
|
||||
logger.debug(
|
||||
`Reranking ${merged.length} unique results from ${providerResults.length} providers`
|
||||
);
|
||||
|
||||
if (duplicates > 0) {
|
||||
logger.debug(`Merged ${duplicates} duplicate results from multiple providers`);
|
||||
}
|
||||
|
||||
// Process through the chain
|
||||
return this.processor.process(merged, mergedOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge results from multiple providers, keeping track of duplicates
|
||||
*/
|
||||
private mergeAndDeduplicate(
|
||||
providerResults: ProviderResult[]
|
||||
): { merged: NacosMcpServer[]; duplicates: number } {
|
||||
const seen = new Map<string, NacosMcpServer>();
|
||||
let duplicates = 0;
|
||||
|
||||
// Process each provider's results
|
||||
for (const { providerName, results } of providerResults) {
|
||||
for (const baseResult of results) {
|
||||
try {
|
||||
// Skip invalid base results
|
||||
if (!baseResult || typeof baseResult !== 'object') {
|
||||
logger.warn('Skipping invalid search result: not an object');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure we have required properties with defaults
|
||||
const baseProps = {
|
||||
name: baseResult.name || '',
|
||||
description: baseResult.description || '',
|
||||
agentConfig: baseResult.agentConfig || {},
|
||||
mcpConfigDetail: (baseResult as any).mcpConfigDetail || null,
|
||||
// Include any additional properties from the base result
|
||||
...Object.fromEntries(
|
||||
Object.entries(baseResult).filter(
|
||||
([key]) => !['name', 'description', 'agentConfig', 'mcpConfigDetail'].includes(key)
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
// Create a properly typed NacosMcpServer with all required methods
|
||||
const result = createMcpProviderResult(baseProps, {
|
||||
providerName,
|
||||
similarity: 'similarity' in baseResult ? Number(baseResult.similarity) : undefined,
|
||||
score: 'score' in baseResult ? Number(baseResult.score) : undefined
|
||||
});
|
||||
|
||||
const key = result.getName().toLowerCase();
|
||||
|
||||
if (seen.has(key)) {
|
||||
// For duplicates, keep the one with higher score
|
||||
const existing = seen.get(key)!;
|
||||
const existingScore = existing.score ?? existing.similarity ?? 0;
|
||||
const newScore = result.score ?? result.similarity ?? 0;
|
||||
|
||||
if (newScore > existingScore) {
|
||||
seen.set(key, result);
|
||||
}
|
||||
duplicates++;
|
||||
} else {
|
||||
seen.set(key, result);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing search result:', error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the map values to an array and ensure all items are valid NacosMcpServers
|
||||
const mergedResults: NacosMcpServer[] = [];
|
||||
for (const server of seen.values()) {
|
||||
if (isNacosMcpServer(server)) {
|
||||
mergedResults.push(server);
|
||||
} else {
|
||||
logger.warn('Skipping invalid server result - missing required methods');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
merged: mergedResults,
|
||||
duplicates
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider priorities
|
||||
*/
|
||||
updateProviderPriorities(priorities: ProviderPriorities): void {
|
||||
this.providerPriorities = { ...this.providerPriorities, ...priorities };
|
||||
// Recreate processor chain with new priorities
|
||||
this.processor = RerankProcessorFactory.createChain(this.providerPriorities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update default rerank options
|
||||
*/
|
||||
updateDefaultOptions(options: Partial<RerankOptions>): void {
|
||||
this.defaultOptions = { ...this.defaultOptions, ...options };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
import { logger } from "../../../logger";
|
||||
import { BaseRerankProcessor, IRerankProcessor, ProviderPriorities, RerankOptions } from "../../../types/rerank";
|
||||
import { NacosMcpServer, createMcpProviderResult } from "../../../types/nacos_mcp_server";
|
||||
import { NacosMcpServer as BaseNacosMcpServer } from "../../../router_types";
|
||||
|
||||
// Helper type guard for enhanced NacosMcpServer
|
||||
function isEnhancedServer(server: any): server is NacosMcpServer {
|
||||
return server && typeof server === 'object' && 'name' in server && 'description' in server;
|
||||
}
|
||||
|
||||
// Helper to ensure we have a properly typed server
|
||||
function ensureEnhancedServer(server: any): NacosMcpServer {
|
||||
if (isEnhancedServer(server)) {
|
||||
return server;
|
||||
}
|
||||
return createMcpProviderResult(server as BaseNacosMcpServer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates scores for results based on provider priority and similarity
|
||||
*/
|
||||
export class ScoreCalculationProcessor extends BaseRerankProcessor {
|
||||
constructor(private providerPriorities: ProviderPriorities) {
|
||||
super();
|
||||
}
|
||||
|
||||
process(
|
||||
results: NacosMcpServer[],
|
||||
options: RerankOptions
|
||||
): NacosMcpServer[] {
|
||||
const scored = results.map(server => {
|
||||
const result = ensureEnhancedServer(server);
|
||||
|
||||
// If score already calculated, use it
|
||||
if ('score' in result && result.score !== undefined) return result;
|
||||
|
||||
// Otherwise calculate based on provider priority and similarity
|
||||
const priority = this.providerPriorities[result.providerName || ''] || 0;
|
||||
const similarity = result.similarity ?? 0;
|
||||
|
||||
// Simple weighted score - can be adjusted based on requirements
|
||||
const score = similarity * 0.7 + (priority / 10) * 0.3;
|
||||
|
||||
return createMcpProviderResult(result, { score });
|
||||
});
|
||||
|
||||
return this.next(scored, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out results below the minimum similarity threshold
|
||||
*/
|
||||
export class ScoreFilterProcessor extends BaseRerankProcessor {
|
||||
process(results: NacosMcpServer[], options: RerankOptions): NacosMcpServer[] {
|
||||
if (options.minSimilarity === undefined) {
|
||||
return this.next(results, options);
|
||||
}
|
||||
|
||||
const filtered = results.map(ensureEnhancedServer).filter(
|
||||
result => (result.similarity ?? 0) >= options.minSimilarity!
|
||||
);
|
||||
|
||||
if (filtered.length < results.length) {
|
||||
logger.debug(
|
||||
`Filtered out ${results.length - filtered.length} results below min similarity ${options.minSimilarity}`
|
||||
);
|
||||
}
|
||||
|
||||
return this.next(filtered, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts results by score in descending order
|
||||
*/
|
||||
export class ScoreSortProcessor extends BaseRerankProcessor {
|
||||
process(results: NacosMcpServer[]): NacosMcpServer[] {
|
||||
const sorted = [...results].map(ensureEnhancedServer).sort((a, b) => {
|
||||
const scoreA = a.score ?? a.similarity ?? 0;
|
||||
const scoreB = b.score ?? b.similarity ?? 0;
|
||||
return scoreB - scoreA; // Descending
|
||||
});
|
||||
|
||||
return this.next(sorted, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits the number of results returned
|
||||
*/
|
||||
export class LimitProcessor extends BaseRerankProcessor {
|
||||
process(results: NacosMcpServer[], options: RerankOptions): NacosMcpServer[] {
|
||||
if (options.limit === undefined || options.limit <= 0) {
|
||||
return this.next(results, options);
|
||||
}
|
||||
|
||||
const limited = results.map(ensureEnhancedServer).slice(0, options.limit);
|
||||
|
||||
if (limited.length < results.length) {
|
||||
logger.debug(`Limited results from ${results.length} to ${options.limit}`);
|
||||
}
|
||||
|
||||
return limited; // No next processor after limit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for domain-specific professional reranking
|
||||
* Can be extended with custom business logic
|
||||
*/
|
||||
export class ProfessionalRerankProcessor extends BaseRerankProcessor {
|
||||
constructor(private enabled: boolean = false) {
|
||||
super();
|
||||
}
|
||||
|
||||
process(results: NacosMcpServer[], options: RerankOptions): NacosMcpServer[] {
|
||||
if (!this.enabled && !options.enableProfessionalRerank) {
|
||||
return this.next(results, options);
|
||||
}
|
||||
|
||||
// Ensure all results are properly typed
|
||||
const enhancedResults = results.map(ensureEnhancedServer);
|
||||
|
||||
// TODO: Implement domain-specific reranking logic here
|
||||
// For now, just pass through
|
||||
logger.debug("Professional rerank executed (no-op in current implementation)");
|
||||
|
||||
return this.next(enhancedResults, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating the rerank processor chain
|
||||
*/
|
||||
export class RerankProcessorFactory {
|
||||
static createChain(providerPriorities: ProviderPriorities): IRerankProcessor {
|
||||
const scoreCalculation = new ScoreCalculationProcessor(providerPriorities);
|
||||
const scoreFilter = new ScoreFilterProcessor();
|
||||
const scoreSort = new ScoreSortProcessor();
|
||||
const limit = new LimitProcessor();
|
||||
const professionalRerank = new ProfessionalRerankProcessor(false);
|
||||
|
||||
// Build the chain: calculate -> filter -> professional -> sort -> limit
|
||||
scoreCalculation
|
||||
.setNext(scoreFilter)
|
||||
.setNext(professionalRerank)
|
||||
.setNext(scoreSort)
|
||||
.setNext(limit);
|
||||
|
||||
return scoreCalculation;
|
||||
}
|
||||
}
|
|
@ -4,6 +4,34 @@ import { Router, RouterConfig } from './router';
|
|||
import { logger } from './logger';
|
||||
import { config } from './config';
|
||||
|
||||
function formatReason(reason: unknown): string {
|
||||
if (reason instanceof Error) {
|
||||
const name = reason.name || 'Error';
|
||||
const message = reason.message || '';
|
||||
const stack = reason.stack ? `\n${reason.stack}` : '';
|
||||
// Keep it single-line friendly; stack is on following lines
|
||||
return `${name}: ${message}${stack}`;
|
||||
}
|
||||
try {
|
||||
return typeof reason === 'string' ? reason : JSON.stringify(reason);
|
||||
} catch {
|
||||
return String(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Global error handlers to prevent process crashes
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const msg = formatReason(reason);
|
||||
logger.error(`Unhandled Rejection: ${msg}`);
|
||||
setTimeout(() => process.exit(1), 100);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
const msg = formatReason(error);
|
||||
logger.error(`Uncaught Exception: ${msg}`);
|
||||
setTimeout(() => process.exit(1), 100);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const router = new Router(config as RouterConfig);
|
||||
|
@ -12,8 +40,9 @@ async function main() {
|
|||
await router.start();
|
||||
logger.info('Nacos MCP Router started successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Nacos MCP Router:', error);
|
||||
process.exit(1);
|
||||
const msg = formatReason(error);
|
||||
logger.error(`Failed to start Nacos MCP Router: ${msg}`);
|
||||
setTimeout(() => process.exit(1), 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
import { NacosMcpServer as BaseNacosMcpServer } from "../router_types";
|
||||
|
||||
/**
|
||||
* Extended NacosMcpServer type that includes additional properties used in search and reranking
|
||||
*/
|
||||
export interface NacosMcpServer extends BaseNacosMcpServer {
|
||||
/** Optional provider name that returned this result */
|
||||
providerName?: string;
|
||||
|
||||
/** Optional relevance score (0-1) from the search provider */
|
||||
similarity?: number;
|
||||
|
||||
/** Optional computed score after reranking */
|
||||
score?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for partial NacosMcpServer properties that can be used to create a new instance
|
||||
*/
|
||||
type NacosMcpServerInit = Partial<BaseNacosMcpServer> & {
|
||||
name: string;
|
||||
description?: string;
|
||||
agentConfig?: Record<string, any>;
|
||||
mcpConfigDetail?: any;
|
||||
[key: string]: any; // Allow any additional properties
|
||||
};
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a NacosMcpServer
|
||||
*/
|
||||
export function isNacosMcpServer(obj: any): obj is NacosMcpServer {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
'name' in obj &&
|
||||
'description' in obj &&
|
||||
'agentConfig' in obj &&
|
||||
typeof obj.getName === 'function' &&
|
||||
typeof obj.getDescription === 'function' &&
|
||||
typeof obj.getAgentConfig === 'function' &&
|
||||
typeof obj.toDict === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NacosMcpServer with additional search/rerank properties
|
||||
* Ensures all required methods are properly bound to the returned object
|
||||
*/
|
||||
export function createMcpProviderResult(
|
||||
base: NacosMcpServerInit,
|
||||
options: {
|
||||
providerName?: string;
|
||||
similarity?: number;
|
||||
score?: number;
|
||||
} = {}
|
||||
): NacosMcpServer {
|
||||
// Create a new instance of NacosMcpServer with required properties
|
||||
const server = new BaseNacosMcpServer(
|
||||
base.name,
|
||||
base.description || '',
|
||||
base.agentConfig || {}
|
||||
) as NacosMcpServer;
|
||||
|
||||
// Add mcpConfigDetail if provided
|
||||
if (base.mcpConfigDetail !== undefined) {
|
||||
(server as any).mcpConfigDetail = base.mcpConfigDetail;
|
||||
}
|
||||
|
||||
// Add search/rerank specific properties
|
||||
if (options.providerName) {
|
||||
server.providerName = options.providerName;
|
||||
}
|
||||
// NacosMcpProvider is the default provider, so it should have the highest priority
|
||||
if (options.providerName === 'NacosMcpProvider') {
|
||||
server.similarity = 1;
|
||||
server.score = 1;
|
||||
} else {
|
||||
if (options.similarity !== undefined) {
|
||||
server.similarity = options.similarity;
|
||||
}
|
||||
if (options.score !== undefined) {
|
||||
server.score = options.score;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy any additional properties from base
|
||||
const extraProps = Object.entries(base).reduce<Record<string, any>>((acc, [key, value]) => {
|
||||
if (!['name', 'description', 'agentConfig', 'mcpConfigDetail'].includes(key)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Object.assign(server, extraProps);
|
||||
|
||||
return server;
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { NacosMcpServer } from "../router_types";
|
||||
|
||||
/**
|
||||
* Provider priorities for result reranking.
|
||||
* Higher values indicate higher priority when results have equal scores.
|
||||
*/
|
||||
export type ProviderPriorities = Record<string, number>;
|
||||
|
||||
/**
|
||||
* Options for the reranking process
|
||||
*/
|
||||
export interface RerankOptions {
|
||||
/** Maximum number of results to return */
|
||||
limit?: number;
|
||||
/** Minimum similarity score (0-1) for results to be included */
|
||||
minSimilarity?: number;
|
||||
/** Whether to enable professional reranking (e.g., domain-specific sorting) */
|
||||
enableProfessionalRerank?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from a single provider before merging/reranking
|
||||
*/
|
||||
export interface ProviderResult {
|
||||
/** Name of the provider */
|
||||
providerName: string;
|
||||
/** Results returned by this provider */
|
||||
results: NacosMcpServer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for rerank processor in the chain of responsibility
|
||||
*/
|
||||
export interface IRerankProcessor {
|
||||
/**
|
||||
* Process the results
|
||||
* @param results Results to process
|
||||
* @param options Reranking options
|
||||
* @returns Processed results
|
||||
*/
|
||||
process(
|
||||
results: NacosMcpServer[],
|
||||
options: RerankOptions
|
||||
): NacosMcpServer[];
|
||||
|
||||
/**
|
||||
* Set the next processor in the chain
|
||||
* @param next Next processor
|
||||
*/
|
||||
setNext(next: IRerankProcessor): IRerankProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for rerank processors implementing the chain of responsibility pattern
|
||||
*/
|
||||
export abstract class BaseRerankProcessor implements IRerankProcessor {
|
||||
protected nextProcessor: IRerankProcessor | null = null;
|
||||
|
||||
setNext(next: IRerankProcessor): IRerankProcessor {
|
||||
this.nextProcessor = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
process(
|
||||
results: NacosMcpServer[],
|
||||
options: RerankOptions
|
||||
): NacosMcpServer[] {
|
||||
if (this.nextProcessor) {
|
||||
return this.nextProcessor.process(results, options);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to safely call the next processor in the chain
|
||||
*/
|
||||
protected next(
|
||||
results: NacosMcpServer[],
|
||||
options: RerankOptions
|
||||
): NacosMcpServer[] {
|
||||
if (this.nextProcessor) {
|
||||
return this.nextProcessor.process(results, options);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { NacosMcpServer } from "../router_types";
|
||||
|
||||
/**
|
||||
* Parameters used to search for MCP servers.
|
||||
*/
|
||||
export interface SearchParams {
|
||||
/** 描述用户当前任务,用于在向量库中检索相关的 MCP 服务器 */
|
||||
taskDescription: string;
|
||||
/** 搜索关键词,可选。将直接在缓存中做关键词匹配 */
|
||||
keywords?: string[];
|
||||
/** 所需的能力标签,可选。预留字段,方便后续在不同 Provider 中做能力过滤或参与向量搜索 */
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A SearchProvider is responsible for returning a list of {@link NacosMcpServer}
|
||||
* that are most relevant to the provided {@link SearchParams}.
|
||||
*
|
||||
* In the future there could be many different implementations (e.g. remote HTTP
|
||||
* provider, local cache provider, LLM‐based provider, etc.). All of them must
|
||||
* conform to this interface so that the router can chain providers,
|
||||
* re-rank results, and finally return a unified list to the caller.
|
||||
*/
|
||||
export interface SearchProvider {
|
||||
/**
|
||||
* Search MCP servers based on the given parameters.
|
||||
*
|
||||
* @param params Parameters describing the user task and optional filters.
|
||||
* @returns A promise that resolves to an array of matching MCP servers.
|
||||
*/
|
||||
search(params: SearchParams): Promise<NacosMcpServer[]>;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { NacosMcpServer } from '../../src/router_types';
|
||||
|
||||
export interface SearchTestCase {
|
||||
name: string;
|
||||
input: {
|
||||
taskDescription: string;
|
||||
keyWords: string[];
|
||||
};
|
||||
expected: {
|
||||
minResults: number;
|
||||
expectedKeywords?: string[];
|
||||
descriptionShouldContain?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const searchTestCases: SearchTestCase[] = [
|
||||
{
|
||||
name: 'should find MCP servers by exact name',
|
||||
input: {
|
||||
taskDescription: 'Find MCP server by exact name',
|
||||
keyWords: ['exact-server-name']
|
||||
},
|
||||
expected: {
|
||||
minResults: 1,
|
||||
expectedKeywords: ['exact-server-name']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'should find MCP servers by description keywords',
|
||||
input: {
|
||||
taskDescription: 'Find MCP servers related to database operations',
|
||||
keyWords: ['database', 'query']
|
||||
},
|
||||
expected: {
|
||||
minResults: 1,
|
||||
descriptionShouldContain: ['database', 'queries']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'should handle empty results gracefully',
|
||||
input: {
|
||||
taskDescription: 'Non-existent server search',
|
||||
keyWords: ['nonexistent12345']
|
||||
},
|
||||
expected: {
|
||||
minResults: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'should handle special characters in search',
|
||||
input: {
|
||||
taskDescription: 'Search with special characters',
|
||||
keyWords: ['api-v1', 'test@example.com']
|
||||
},
|
||||
expected: {
|
||||
minResults: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const mockMcpServers: NacosMcpServer[] = [
|
||||
{
|
||||
name: 'exact-server-name',
|
||||
description: 'A test server for exact name matching exact-server-name',
|
||||
mcpConfigDetail: null,
|
||||
agentConfig: {},
|
||||
getName: () => 'exact-server-name',
|
||||
getDescription: () => 'A test server for exact name matching: exact-server-name',
|
||||
getAgentConfig: () => ({}),
|
||||
toDict: () => ({
|
||||
name: 'exact-server-name',
|
||||
description: 'A test server for exact name matching exact-server-name',
|
||||
mcpConfigDetail: null,
|
||||
agentConfig: {}
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'database-query-server',
|
||||
description: 'Handles database queries and operations',
|
||||
mcpConfigDetail: null,
|
||||
agentConfig: {},
|
||||
getName: () => 'database-query-server',
|
||||
getDescription: () => 'Handles database queries and operations',
|
||||
getAgentConfig: () => ({}),
|
||||
toDict: () => ({
|
||||
name: 'database-query-server',
|
||||
description: 'Handles database queries and operations',
|
||||
mcpConfigDetail: null,
|
||||
agentConfig: {}
|
||||
})
|
||||
}
|
||||
];
|
|
@ -0,0 +1,5 @@
|
|||
// Jest global setup for all tests.
|
||||
// Currently no global configuration is required, but the file must exist
|
||||
// because the npm test script references it via --setupFilesAfterEnv.
|
||||
|
||||
export {};
|
|
@ -0,0 +1,267 @@
|
|||
import { Router } from "../../src/router";
|
||||
import { SearchService } from "../../src/services/search/SearchService";
|
||||
import { NacosMcpProvider } from "../../src/services/search/NacosMcpProvider";
|
||||
import { mockMcpServers as originalMockMcpServers, searchTestCases } from "../fixtures/searchTestData";
|
||||
import { NacosMcpServer } from "../../src/types/nacos_mcp_server";
|
||||
import { SearchParams } from "../../src/types/search";
|
||||
|
||||
// Minimal McpManager interface with only the methods we need for testing
|
||||
interface MinimalMcpManager {
|
||||
searchMcpByKeyword(keyword: string): Promise<NacosMcpServer[]>;
|
||||
getMcpServer(taskDescription: string, count: number): Promise<NacosMcpServer[]>;
|
||||
getMcpServers(): Promise<NacosMcpServer[]>;
|
||||
}
|
||||
|
||||
// Type guard to check if params is a string
|
||||
function isStringParam(params: SearchParams | string): params is string {
|
||||
return typeof params === 'string';
|
||||
}
|
||||
|
||||
// Helper function to extract query from SearchParams
|
||||
function getQueryFromParams(params: SearchParams | string): string {
|
||||
return isStringParam(params) ? params : (params as any).query || '';
|
||||
}
|
||||
|
||||
// Helper function to create a proper NacosMcpServer object
|
||||
function createNacosMcpServer(base: Partial<NacosMcpServer>): NacosMcpServer {
|
||||
// Create a new object with all required properties
|
||||
const server = {
|
||||
name: base.name || '',
|
||||
description: base.description || '',
|
||||
mcpConfigDetail: base.mcpConfigDetail || null,
|
||||
agentConfig: base.agentConfig || {},
|
||||
providerName: base.providerName || 'nacos',
|
||||
similarity: base.similarity || 1.0,
|
||||
score: base.score || 1.0,
|
||||
// Ensure all required methods are properly bound to the object
|
||||
getName: function() { return this.name; },
|
||||
getDescription: function() { return this.description; },
|
||||
getAgentConfig: function() { return this.agentConfig; },
|
||||
toDict: function() {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
mcpConfigDetail: this.mcpConfigDetail,
|
||||
agentConfig: this.agentConfig
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Copy any additional properties from base
|
||||
Object.assign(server, base);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// Create enhanced mock servers with all required NacosMcpServer methods
|
||||
const mockMcpServers = originalMockMcpServers.map(serverData => {
|
||||
// Create a new server with all required methods and data
|
||||
return createNacosMcpServer({
|
||||
...serverData,
|
||||
providerName: 'nacos',
|
||||
// Ensure these are set in case they're not in serverData
|
||||
name: serverData.name || '',
|
||||
description: serverData.description || '',
|
||||
mcpConfigDetail: serverData.mcpConfigDetail || null,
|
||||
agentConfig: serverData.agentConfig || {}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Simplified McpManager implementation for testing
|
||||
*/
|
||||
class DummyMcpManager implements MinimalMcpManager {
|
||||
async searchMcpByKeyword(keyword: string): Promise<NacosMcpServer[]> {
|
||||
const kw = keyword.toLowerCase();
|
||||
return mockMcpServers.filter(server =>
|
||||
server.getName().toLowerCase().includes(kw) ||
|
||||
(server.getDescription() || '').toLowerCase().includes(kw)
|
||||
);
|
||||
}
|
||||
|
||||
async getMcpServer(_taskDescription: string, count: number): Promise<NacosMcpServer[]> {
|
||||
return mockMcpServers.slice(0, count);
|
||||
}
|
||||
|
||||
async getMcpServers(): Promise<NacosMcpServer[]> {
|
||||
return [...mockMcpServers];
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal Router configuration – values are irrelevant for the tested method
|
||||
const dummyConfig = {
|
||||
nacos: {
|
||||
serverAddr: "dummy-addr",
|
||||
username: "dummy-user",
|
||||
password: "dummy-pass"
|
||||
},
|
||||
mcp: {
|
||||
host: "",
|
||||
port: 0
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Mock CompassSearchProvider for testing
|
||||
class MockCompassSearchProvider {
|
||||
async search(_params: SearchParams | string): Promise<NacosMcpServer[]> {
|
||||
// Return a subset of mock data that would match a typical search
|
||||
return mockMcpServers.slice(0, 2).map(serverData => {
|
||||
// Create a new server instance with compass provider info
|
||||
const server = createNacosMcpServer({
|
||||
...serverData,
|
||||
providerName: 'compass',
|
||||
similarity: 0.9,
|
||||
score: 0.9
|
||||
});
|
||||
|
||||
// Ensure all data is properly set on the instance
|
||||
return Object.assign(server, serverData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("Router.searchNacosMcpServer", () => {
|
||||
let router: Router;
|
||||
let searchService: SearchService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh instance of the mock manager for each test
|
||||
const mcpManager = new DummyMcpManager();
|
||||
|
||||
// Create a mock NacosMcpProvider that works with our simplified McpManager
|
||||
const nacosProvider = {
|
||||
search: async (params: SearchParams | string) => {
|
||||
try {
|
||||
const query = getQueryFromParams(params);
|
||||
const results = await mcpManager.searchMcpByKeyword(query);
|
||||
|
||||
// Ensure we return properly constructed NacosMcpServer instances
|
||||
return results.map(serverData => {
|
||||
const server = createNacosMcpServer({
|
||||
...serverData,
|
||||
providerName: 'nacos'
|
||||
});
|
||||
|
||||
// Verify the server has all required methods
|
||||
if (typeof server.getName !== 'function') {
|
||||
throw new Error('Server is missing getName method');
|
||||
}
|
||||
|
||||
return server;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in mock nacosProvider.search:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create a mock CompassSearchProvider
|
||||
const compassProvider = new MockCompassSearchProvider();
|
||||
|
||||
// Create the search service with our mock providers
|
||||
searchService = new SearchService([nacosProvider, compassProvider]);
|
||||
|
||||
// Create router with minimal config
|
||||
router = new Router({
|
||||
nacos: {
|
||||
serverAddr: 'localhost:8848',
|
||||
username: 'nacos',
|
||||
password: 'nacos'
|
||||
},
|
||||
mcp: {
|
||||
host: '0.0.0.0',
|
||||
port: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Inject our mocks into the router
|
||||
// @ts-ignore - accessing private property for testing
|
||||
router.mcpManager = mcpManager as any;
|
||||
// @ts-ignore - accessing private property for testing
|
||||
router.searchService = searchService;
|
||||
|
||||
// Verify the searchService is properly set
|
||||
if (!router['searchService']) {
|
||||
throw new Error('searchService not properly set on router');
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to verify server has all required methods
|
||||
function verifyServerMethods(server: NacosMcpServer) {
|
||||
try {
|
||||
expect(server).toBeDefined();
|
||||
expect(server).toBeInstanceOf(Object);
|
||||
|
||||
// Check for required methods
|
||||
const requiredMethods = ['getName', 'getDescription', 'getAgentConfig', 'toDict'];
|
||||
requiredMethods.forEach(method => {
|
||||
expect(server).toHaveProperty(method);
|
||||
expect(typeof (server as any)[method]).toBe('function');
|
||||
});
|
||||
|
||||
// Verify method calls don't throw and return expected types
|
||||
expect(() => {
|
||||
const name = server.getName();
|
||||
expect(typeof name).toBe('string');
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => {
|
||||
const desc = server.getDescription();
|
||||
expect(desc === undefined || typeof desc === 'string').toBe(true);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => {
|
||||
const agentConfig = server.getAgentConfig();
|
||||
expect(agentConfig).toBeDefined();
|
||||
expect(typeof agentConfig).toBe('object');
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => {
|
||||
const dict = server.toDict();
|
||||
expect(dict).toBeDefined();
|
||||
expect(typeof dict).toBe('object');
|
||||
expect(dict).toHaveProperty('name');
|
||||
expect(dict).toHaveProperty('description');
|
||||
expect(dict).toHaveProperty('agentConfig');
|
||||
}).not.toThrow();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Server verification failed:', {
|
||||
server,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
it.each(searchTestCases)("%s", async testCase => {
|
||||
const { taskDescription, keyWords } = testCase.input;
|
||||
const { minResults, expectedKeywords, descriptionShouldContain } = testCase.expected;
|
||||
|
||||
// Router method requires at least one keyword – cast to the required tuple type
|
||||
const results = await router.searchMcpServer(
|
||||
taskDescription,
|
||||
keyWords as [string, ...string[]]
|
||||
);
|
||||
|
||||
// Minimum result count
|
||||
expect(results.length).toBeGreaterThanOrEqual(minResults);
|
||||
|
||||
// Expected keywords contained in server names
|
||||
if (expectedKeywords) {
|
||||
expectedKeywords.forEach(k => {
|
||||
const has = results.some(r => r.name.includes(k));
|
||||
expect(has).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
// Expected substrings in description
|
||||
if (descriptionShouldContain) {
|
||||
descriptionShouldContain.forEach(substr => {
|
||||
const has = results.some(r => r.description.includes(substr));
|
||||
expect(has).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
// Setup file for Jest tests
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Mock any global objects or functions needed for testing
|
||||
global.console = {
|
||||
...console,
|
||||
// Override any console methods here if needed
|
||||
log: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as any;
|
|
@ -0,0 +1,57 @@
|
|||
import { chromium, FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
console.log('🔧 Playwright 全局设置开始...');
|
||||
|
||||
// 获取环境变量
|
||||
const baseURL = process.env.MCP_INSPECTOR_URL || 'http://localhost:6274';
|
||||
const authToken = process.env.MCP_AUTH_TOKEN;
|
||||
const fullURL = process.env.MCP_INSPECTOR_FULL_URL;
|
||||
|
||||
console.log(`📍 MCP Inspector URL: ${baseURL}`);
|
||||
if (authToken) {
|
||||
console.log(`🔑 认证 Token: ${authToken.substring(0, 8)}...`);
|
||||
}
|
||||
if (fullURL) {
|
||||
console.log(`🔗 完整 URL: ${fullURL}`);
|
||||
}
|
||||
|
||||
// 验证 MCP Inspector 是否可访问
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('🔍 验证 MCP Inspector 可访问性...');
|
||||
|
||||
// 尝试访问主页
|
||||
const targetURL = fullURL || baseURL;
|
||||
await page.goto(targetURL, { timeout: 10000 });
|
||||
|
||||
// 等待页面加载
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 检查是否成功加载 MCP Inspector
|
||||
const title = await page.title();
|
||||
console.log(`📄 页面标题: ${title}`);
|
||||
|
||||
// 检查是否有 MCP Inspector 的特征元素
|
||||
const hasInspectorElements = await page.locator('body').count() > 0;
|
||||
|
||||
if (hasInspectorElements) {
|
||||
console.log('✅ MCP Inspector 可访问');
|
||||
} else {
|
||||
console.warn('⚠️ MCP Inspector 页面可能未完全加载');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ MCP Inspector 访问失败:', error);
|
||||
throw new Error(`MCP Inspector 不可访问: ${error}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log('✅ Playwright 全局设置完成');
|
||||
}
|
||||
|
||||
export default globalSetup;
|
|
@ -0,0 +1,118 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('MCP Inspector - Search MCP Server 功能测试', () => {
|
||||
let baseURL: string;
|
||||
let authToken: string;
|
||||
let fullURL: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
baseURL = process.env.MCP_INSPECTOR_URL || 'http://localhost:6274';
|
||||
authToken = process.env.MCP_AUTH_TOKEN || '';
|
||||
fullURL = process.env.MCP_INSPECTOR_FULL_URL || baseURL;
|
||||
|
||||
console.log(`🔗 测试 URL: ${fullURL}`);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 导航到 MCP Inspector
|
||||
await page.goto(fullURL);
|
||||
|
||||
// 等待页面加载完成
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 等待 MCP Inspector 界面加载
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.getByRole('button', { name: 'Connect' }).click({ timeout: 3000 });
|
||||
console.log('✅ 连接 MCP Inspector 界面成功');
|
||||
|
||||
try {
|
||||
const toolsTab = page.getByRole('tab', { name: 'Tools' });
|
||||
const listToolsButton = page.getByRole('button', { name: 'List Tools' });
|
||||
const isListToolsVisible = await listToolsButton.isVisible().catch(() => false);
|
||||
if (!isListToolsVisible) {
|
||||
await toolsTab.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
await listToolsButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
} catch (error: any) {
|
||||
console.warn('⚠️ Warning: Could not activate Tools tab:', error.message);
|
||||
// Don't fail the test, just log the warning
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够打开 MCP Inspector 界面', async ({ page }) => {
|
||||
// 验证页面标题或关键元素
|
||||
const title = await page.title();
|
||||
console.log(`页面标题: ${title}`);
|
||||
|
||||
expect(await page.locator('body').count()).toBeGreaterThan(0);
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await page.screenshot({ path: 'test-results/mcp-inspector-loaded.png' });
|
||||
});
|
||||
|
||||
test('应该能够调用 SearchMcpServer 工具', async ({ page }) => {
|
||||
console.log('🧪 测试 SearchMcpServer 工具调用...');
|
||||
await page.waitForTimeout(5000);
|
||||
try {
|
||||
await page.getByText('SearchMcpServer').click();
|
||||
console.log('✅ 选择了 SearchMcpServer 工具');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 尝试填写工具参数
|
||||
const taskDescInput = page.locator('input[name="taskDescription"], textarea[name="taskDescription"]');
|
||||
if (await taskDescInput.count() > 0) {
|
||||
await taskDescInput.fill('用于测试的 MCP');
|
||||
console.log('✅ 填写了任务描述');
|
||||
}
|
||||
|
||||
const keyWordsInput = page.locator('.npm__react-simple-code-editor__textarea');
|
||||
if (await keyWordsInput.count() > 0) {
|
||||
await keyWordsInput.fill('["test","测试"]');
|
||||
console.log('✅ 填写了关键词');
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const callButton = page.locator('button:has-text("Call"), button:has-text("Execute"), button:has-text("Run"), button[type="submit"]');
|
||||
if (await callButton.count() > 0) {
|
||||
await callButton.first().click();
|
||||
console.log('✅ 点击了调用按钮');
|
||||
|
||||
// 等待结果
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 检查是否有结果显示
|
||||
const resultArea = page.locator('[title="Click to collapse"]').first();
|
||||
if (await resultArea.count() > 0) {
|
||||
const resultText = await resultArea.textContent();
|
||||
console.log(`📋 工具调用结果: ${resultText?.substring(0, 200)}...`);
|
||||
|
||||
// 验证结果包含期望的内容
|
||||
const expectedKeywords = ['exact-server-name', '获取', '步骤'];
|
||||
const isResultValid = expectedKeywords.some(keyword => resultText.includes(keyword));
|
||||
if (resultText && isResultValid) {
|
||||
console.log('✅ 工具调用成功,返回了期望的结果');
|
||||
} else {
|
||||
console.log('⚠️ 工具调用结果格式可能不符合预期');
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ 未找到结果显示区域');
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ 未找到调用按钮');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 工具调用测试出错:', error);
|
||||
} finally {
|
||||
// 截图用于调试
|
||||
await page.screenshot({ path: 'test-results/search-tool-test.png' });
|
||||
}
|
||||
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -1,24 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"~/*": ["src/*"]
|
||||
}
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "Node16",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"],
|
||||
"lib": ["es2022"],
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"./src/types"
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "test"]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["test/**/*.ts"]
|
||||
}
|
Loading…
Reference in New Issue