Compare commits
151 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 | |
|
8fbfc2a7c9 | |
|
773ec46eda | |
|
8cdcebf2c1 | |
|
f0e5574ba0 | |
|
7e1ffd8809 | |
|
f24b27765f | |
|
0df8242da8 | |
|
23f17acf7f | |
|
9c9fa9b6b8 | |
|
358ebe856f | |
|
dae157daeb | |
|
520e478303 | |
|
e34e36e6eb | |
|
92770f3e61 | |
|
4fc0bce46f | |
|
0a8cb558ad | |
|
82c9204a26 | |
|
af8c91ccaf | |
|
30de876d65 | |
|
20db8c9416 | |
|
934ddfd27b | |
|
ab13d3f834 | |
|
91005e7b4e | |
|
77ab29cd71 | |
|
1a0581ee41 | |
|
0522df741f | |
|
d192cae676 | |
|
7d3a8bb603 | |
|
ee6b8be28e | |
|
fb49ffb60f | |
|
97b061f308 | |
|
139eb9d191 | |
|
50c5a4c091 | |
|
603b8c539c | |
|
53cb22b72b | |
|
210a65f7c3 | |
|
11357d7d2a | |
|
f2a8911423 | |
|
a3e65f0cbd | |
|
d025d10ad8 | |
|
74b38a72cc | |
|
080aeb3037 | |
|
0932615a0a | |
|
3efc64be40 | |
|
7e1e20a14f | |
|
1e138b44ac | |
|
0e990a869e | |
|
fe67a23421 | |
|
f3296f6c76 | |
|
409fc31f2e | |
|
9f2505e402 | |
|
1af9115131 | |
|
f07fcd8047 | |
|
24733a9cbe | |
|
c8dea04b2c | |
|
28e150241f | |
|
85b47fb126 | |
|
c291a54cc2 | |
|
b0ba65b673 | |
|
3ef6568749 | |
|
f3f1fe649b | |
|
5bb8bcab1d | |
|
8f5ca8937e | |
|
251f1f1cdf | |
|
9cb79ed34c | |
|
1756430370 | |
|
52ee0305e1 | |
|
4da97abc65 | |
|
72ec73f4e4 | |
|
7c52a3a60a | |
|
9d3860e427 | |
|
26fc69ee50 | |
|
b0a7ec3785 | |
|
e26308e06e | |
|
dfca86425f | |
|
6622186ae4 | |
|
766aa0b05f | |
|
a3767d8a3e | |
|
0e031e2b23 | |
|
b4529400c6 | |
|
e8a82bf9b8 | |
|
33adf84b6f | |
|
99b440cf8e | |
|
7b055094b1 |
|
@ -32,16 +32,16 @@ jobs:
|
|||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version-file: "./src/python/.python-version"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: .
|
||||
working-directory: ./src/python
|
||||
run: uv sync --frozen --all-extras --dev
|
||||
|
||||
- name: Run pyright
|
||||
working-directory: .
|
||||
working-directory: ./src/python
|
||||
run: uv run --frozen pyright
|
||||
|
||||
- name: Build package
|
||||
working-directory: .
|
||||
working-directory: ./src/python
|
||||
run: uv build
|
||||
|
|
|
@ -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}}
|
|
@ -36,22 +36,22 @@ jobs:
|
|||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version-file: "./src/python/.python-version"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: .
|
||||
working-directory: ./src/python
|
||||
run: uv sync --frozen --all-extras --dev
|
||||
|
||||
- name: Run pyright
|
||||
working-directory: .
|
||||
working-directory: ./src/python
|
||||
run: uv run --frozen pyright
|
||||
|
||||
- name: Build package
|
||||
working-directory: .
|
||||
working-directory: ./src/python
|
||||
run: uv build
|
||||
|
||||
- name: Publish package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: ./dist
|
||||
packages-dir: ./src/python/dist
|
||||
|
|
|
@ -165,10 +165,21 @@ cython_debug/
|
|||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
src/typescript/node_modules
|
||||
.vscode/
|
||||
getting-started/
|
||||
my_hnsw_*.*
|
||||
|
||||
# TypeScript Test
|
||||
src/typescript/coverage
|
||||
src/typescript/test-results
|
||||
src/typescript/playwright-report
|
||||
|
||||
package-lock.json
|
|
@ -1,8 +0,0 @@
|
|||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
144
README.md
144
README.md
|
@ -1,15 +1,78 @@
|
|||
|
||||
# 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)
|
||||
 
|
||||
|
||||
[切换到中文版](README_cn.md)
|
||||
|
||||
<p>
|
||||
<a href="./README.md">English</a> | <a href="./README_cn.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
### 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`
|
||||
- Search MCP servers by task and keywords.
|
||||
|
@ -30,14 +93,19 @@ This MCP(Model Context Protocol) Server provides tools to search, install, proxy
|
|||
- `params`(map): The parameters of the MCP tool.
|
||||
- Returns: Result returned from the target MCP server.
|
||||
|
||||
## Installation
|
||||
|
||||
### Using uv (recommended)
|
||||
##### 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
|
||||
export NACOS_PASSWORD=$PASSWORD
|
||||
uvx nacos-mcp-router@latest
|
||||
```
|
||||
|
||||
### Using PIP
|
||||
###### Using PIP
|
||||
|
||||
Alternatively you can install `nacos-mcp-router` via pip:
|
||||
|
||||
|
@ -51,16 +119,19 @@ After installation, you can run it as a script using(As an example,Nacos is
|
|||
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
|
||||
```
|
||||
|
||||
## Configuration
|
||||
###### 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/nacos-mcp-router:latest
|
||||
```
|
||||
|
||||
### Usage with Cline、Cursor、Claude and other applications
|
||||
###### Usage with Cline、Cursor、Claude and other applications
|
||||
|
||||
Add this to MCP settings of your application:
|
||||
|
||||
#### Using uvx
|
||||
* Using uvx
|
||||
|
||||
```json
|
||||
{
|
||||
|
@ -86,22 +157,57 @@ Add this to MCP settings of your application:
|
|||
|
||||
> 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.
|
||||
|
||||
* 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/nacos-mcp-router:latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
#### Proxy Mode
|
||||
The proxy mode supports converting SSE and stdio protocol MCP Servers into streamable HTTP protocol MCP Servers.
|
||||
|
||||
If you are doing local development, simply follow the steps:
|
||||
##### 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/nacos-mcp-router:latest
|
||||
```
|
||||
|
||||
1. Clone this repo into your local environment.
|
||||
2. Modify codes in `src/mcp_server_nacos` to implement your wanted features.
|
||||
3. Test using the Claude desktop app. Add the following to your claude_desktop_config.json:
|
||||
#### 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 | |
|
||||
|
||||
### typescript
|
||||
#### Usage with Cline、Cursor、Claude and other applications
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nacos-mcp-router": {
|
||||
"command": "uv",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"--directory","PATH-TO-PROJECT","run","nacos-mcp-router"
|
||||
"nacos-mcp-router@latest"
|
||||
],
|
||||
"env": {
|
||||
"NACOS_ADDR": "<NACOS-ADDR>, optional, default is 127.0.0.1:8848",
|
||||
|
|
110
README_cn.md
110
README_cn.md
|
@ -1,12 +1,27 @@
|
|||
# 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服务。
|
||||
|
||||
### Tools
|
||||
|
||||
|
||||
## 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列表
|
||||
|
@ -27,16 +42,18 @@ Nacos-MCP-Router是一个基于MCP官方标准SDK实现的的MCP Server。它提
|
|||
- `params`(map): 被调的目标MCP Server的工具的参数
|
||||
- 输出: 被调的目标MCP Server的工具的输出结果
|
||||
|
||||
## 安装
|
||||
### 环境要求
|
||||
- Python3.12及以上
|
||||
- 推荐使用uv管理依赖
|
||||
### 使用 uv (推荐)
|
||||
|
||||
#### 使用
|
||||
##### 使用 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
|
||||
|
||||
此外,你也可以通过pip安装 `nacos-mcp-router` :
|
||||
|
||||
|
@ -50,16 +67,19 @@ pip install nacos-mcp-router
|
|||
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
|
||||
```
|
||||
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等
|
||||
##### 使用Cline、Cursor、Claude等
|
||||
|
||||
添加MCP Server配置如下:
|
||||
|
||||
#### 使用 uvx
|
||||
###### 使用 uvx
|
||||
|
||||
```json
|
||||
{
|
||||
|
@ -85,8 +105,29 @@ python -m nacos-mcp-router
|
|||
|
||||
> 如果启动失败,你需要把`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
|
||||
```
|
||||
|
||||
### 开发
|
||||
|
||||
本地开发步骤如下:
|
||||
|
||||
|
@ -112,6 +153,47 @@ python -m nacos-mcp-router
|
|||
}
|
||||
```
|
||||
|
||||
## 许可证
|
||||
### 环境变量设置
|
||||
| | | | | |
|
||||
|----|---------------|----------------|------|-------------------------------------------|
|
||||
| 参数 | 描述 | 默认值 | 是否必填 | 备注 |
|
||||
| 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,94 +0,0 @@
|
|||
import functools
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from .md5_util import get_md5
|
||||
from .nacos_http_client import NacosHttpClient
|
||||
from .router_types import ChromaDb, McpServer
|
||||
from .logger import NacosMcpRouteLogger
|
||||
|
||||
|
||||
class McpUpdater:
|
||||
def __init__(self, nacosHttpClient: NacosHttpClient, chromaDbService: ChromaDb, update_interval: float) -> None:
|
||||
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._cache = dict[str, McpServer]()
|
||||
self.updateNow()
|
||||
self._thread = threading.Thread(target=functools.partial(self.asyncUpdater))
|
||||
self._thread.daemon = False
|
||||
self._thread.start()
|
||||
self._chromaDbId = "nacos_mcp_router_collection_" + str(os.getpid())
|
||||
|
||||
|
||||
def updateNow(self)-> None:
|
||||
mcpServers = self.nacosHttpClient.get_mcp_servers()
|
||||
NacosMcpRouteLogger.get_logger().info("get mcp server list from nacos, size: " + str(len(mcpServers)))
|
||||
if len(mcpServers) == 0:
|
||||
return
|
||||
|
||||
docs = []
|
||||
ids = []
|
||||
cache = {}
|
||||
for mcpServer in mcpServers:
|
||||
des = mcpServer.description
|
||||
if mcpServer.mcp_config_detail is not None:
|
||||
des = mcpServer.mcp_config_detail.get_tool_description()
|
||||
|
||||
cache[str(mcpServer.get_name())] = mcpServer
|
||||
md5_str = get_md5(des)
|
||||
if mcpServer.name not in self.mcp_server_config_version or self.mcp_server_config_version[mcpServer.name] != md5_str:
|
||||
self.mcp_server_config_version[mcpServer.name] = md5_str
|
||||
ids.append(str(mcpServer.get_name()))
|
||||
docs.append(des)
|
||||
|
||||
|
||||
|
||||
self._cache = cache
|
||||
|
||||
if len(ids) > 0:
|
||||
self.chromaDbService.update_data(
|
||||
documents=docs,
|
||||
ids=ids)
|
||||
def asyncUpdater(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
time.sleep(self.interval)
|
||||
self.updateNow()
|
||||
except Exception as e:
|
||||
NacosMcpRouteLogger.get_logger().warning("exception while updating mcp servers: " , exc_info=e)
|
||||
|
||||
def getMcpServer(self, query: str, count: int) -> list[McpServer]:
|
||||
result = self.chromaDbService.query(query,count)
|
||||
ids = result['ids']
|
||||
mcp_servers = list[McpServer]()
|
||||
for id in ids:
|
||||
for id1 in id:
|
||||
mcp_server = self._cache.get(id1)
|
||||
if mcp_server is not None:
|
||||
mcp_servers.append(mcp_server)
|
||||
return mcp_servers
|
||||
|
||||
def search_mcp_by_keyword(self, keyword: str) -> list[McpServer]:
|
||||
servers = list[McpServer]()
|
||||
NacosMcpRouteLogger.get_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)
|
||||
NacosMcpRouteLogger.get_logger().info("result mcp servers search by keywords: " + str(len(servers)))
|
||||
return servers
|
||||
|
||||
def get_mcp_server_by_name(self, mcp_name: str) -> McpServer:
|
||||
result = self._cache[mcp_name]
|
||||
return result
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import json
|
||||
|
||||
import httpx
|
||||
from mcp import Tool
|
||||
|
||||
from .router_types import McpServer
|
||||
from .nacos_mcp_server_config import NacosMcpServerConfig, ToolSpec
|
||||
from .logger import NacosMcpRouteLogger
|
||||
|
||||
|
||||
class NacosHttpClient:
|
||||
def __init__(self, nacosAddr: str, userName: str, passwd: str) -> None:
|
||||
if nacosAddr == "":
|
||||
raise ValueError("nacosAddr cannot be an empty string")
|
||||
if userName == "":
|
||||
raise ValueError("userName cannot be an empty string")
|
||||
if passwd == "":
|
||||
raise ValueError("passwd cannot be an empty string")
|
||||
|
||||
self.nacosAddr = nacosAddr
|
||||
self.userName = userName
|
||||
self.passwd = passwd
|
||||
|
||||
def get_mcp_server_by_name(self, name: str) -> McpServer:
|
||||
url = "http://{0}/nacos/v3/admin/ai/mcp?mcpName={1}".format(self.nacosAddr, name)
|
||||
headers = {"Content-Type": "application/json", "charset": "utf-8", "userName": self.userName,
|
||||
"password": self.passwd}
|
||||
response = httpx.get(url, headers=headers)
|
||||
mcp_server = McpServer(name=name, description="", agentConfig={})
|
||||
if response.status_code == 200:
|
||||
jsonObj = json.loads(response.content.decode("utf-8"))
|
||||
data = jsonObj['data']
|
||||
config = NacosMcpServerConfig.from_dict(data)
|
||||
mcpServer = McpServer(name=config.name, description=config.description if config.description is not None else "",
|
||||
agentConfig=config.local_server_config)
|
||||
mcpServer.mcp_config_detail = config
|
||||
|
||||
if config.protocol != "stdio":
|
||||
if len(config.backend_endpoints) > 0:
|
||||
endpoint = config.backend_endpoints[0]
|
||||
http_schema = "http"
|
||||
if endpoint.port == 443:
|
||||
http_schema = "https"
|
||||
|
||||
url = "{0}://{1}:{2}{3}".format(http_schema, endpoint.address, str(
|
||||
endpoint.port), config.remote_server_config.export_path)
|
||||
if not config.remote_server_config.export_path.startswith("/"):
|
||||
url = "{0}://{1}:{2}/{3}".format(http_schema, endpoint.address, str(
|
||||
endpoint.port), config.remote_server_config.export_path)
|
||||
|
||||
if 'mcpServers' not in mcpServer.agentConfig or mcpServer.agentConfig['mcpServers'] == None:
|
||||
mcpServer.agentConfig['mcpServers'] = {}
|
||||
mcpServers = mcpServer.agentConfig['mcpServers']
|
||||
dct = {"name": mcp_server.name, "description": mcp_server.description, "url": url}
|
||||
mcpServers[mcp_server.name] = dct
|
||||
return mcpServer
|
||||
else:
|
||||
NacosMcpRouteLogger.get_logger().warning("failed to get mcp server {}, response {}" .format(mcp_server.name, response.content))
|
||||
return mcp_server
|
||||
|
||||
def get_mcp_servers_by_page(self, page_no: int, page_size: int) -> list[McpServer]:
|
||||
mcpServers = list[McpServer]()
|
||||
try:
|
||||
url = "http://{0}/nacos/v3/admin/ai/mcp/list?pageNo={1}&pageSize={2}".format(self.nacosAddr, str(page_no), str(
|
||||
page_size))
|
||||
headers = {"Content-Type": "application/json", "charset": "utf-8", "userName": self.userName,
|
||||
"password": self.passwd}
|
||||
response = httpx.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
NacosMcpRouteLogger.get_logger().warning(
|
||||
"failed to get mcp server list response {}".format( response.content))
|
||||
return []
|
||||
|
||||
jsonObj = json.loads(response.content.decode("utf-8"))
|
||||
data = jsonObj['data']
|
||||
for mcp_server_dict in data['pageItems']:
|
||||
if mcp_server_dict["enabled"] and (mcp_server_dict["protocol"] == "mcp-sse" or mcp_server_dict["protocol"] == "stdio") :
|
||||
mcp_name = mcp_server_dict["name"]
|
||||
mcpServer = self.get_mcp_server_by_name(mcp_name)
|
||||
|
||||
if mcpServer.description == "":
|
||||
continue
|
||||
mcpServers.append(mcpServer)
|
||||
return mcpServers
|
||||
except Exception as e:
|
||||
NacosMcpRouteLogger.get_logger().warning("failed to get mcp server list", exc_info=e)
|
||||
return mcpServers
|
||||
|
||||
def get_mcp_servers(self) -> list[McpServer]:
|
||||
mcpServers = []
|
||||
try:
|
||||
page_size = 100
|
||||
page_no = 1
|
||||
url = "http://{0}/nacos/v3/admin/ai/mcp/list?pageNo={1}&pageSize={2}".format(self.nacosAddr, str(page_no), str(
|
||||
page_size))
|
||||
|
||||
headers = {"Content-Type": "application/json", "charset": "utf-8", "userName": self.userName,
|
||||
"password": self.passwd}
|
||||
response = httpx.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
NacosMcpRouteLogger.get_logger().warning(
|
||||
"failed to get mcp server list, url {}, response {}".format(url, response.content.decode("utf-8")))
|
||||
return []
|
||||
|
||||
jsonObj = json.loads(response.content.decode("utf-8"))
|
||||
total_count = jsonObj['data']['totalCount']
|
||||
total_pages = int(total_count / page_size) + 1
|
||||
|
||||
for i in range(1, total_pages + 1):
|
||||
mcps = self.get_mcp_servers_by_page(i, page_size)
|
||||
for mcp_server in mcps:
|
||||
mcpServers.append(mcp_server)
|
||||
return mcpServers
|
||||
except Exception as e:
|
||||
return mcpServers
|
||||
|
||||
def update_mcp_tools(self,mcp_name:str, tools: list[Tool]) -> bool:
|
||||
url = "http://{0}/nacos/v3/admin/ai/mcp?mcpName={1}".format(self.nacosAddr, mcp_name)
|
||||
headers = {"Content-Type": "application/json", "charset": "utf-8", "userName": self.userName,
|
||||
"password": self.passwd}
|
||||
response = httpx.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
jsonObj = json.loads(response.content.decode("utf-8"))
|
||||
data = jsonObj['data']
|
||||
tool_list = []
|
||||
for tool in tools:
|
||||
dct = {}
|
||||
dct["name"] = tool.name
|
||||
dct["description"] = tool.description
|
||||
dct["inputSchema"] = tool.inputSchema
|
||||
tool_list.append(dct)
|
||||
endpointSpecification = {}
|
||||
if data['protocol'] != "stdio":
|
||||
endpointSpecification['data'] = data['remoteServerConfig']['serviceRef']
|
||||
endpointSpecification['type'] = 'REF'
|
||||
if 'toolSpec' not in data or data['toolSpec'] is None:
|
||||
data['toolSpec'] = {}
|
||||
|
||||
data['toolSpec']['tools'] = tool_list
|
||||
params = {}
|
||||
params['mcpName'] = mcp_name
|
||||
toolSpecification = data['toolSpec']
|
||||
|
||||
|
||||
del data['toolSpec']
|
||||
del data['backendEndpoints']
|
||||
|
||||
|
||||
params["serverSpecification"] = json.dumps(data, ensure_ascii=False)
|
||||
params["endpointSpecification"] = json.dumps(endpointSpecification, ensure_ascii=False)
|
||||
params["toolSpecification"] = json.dumps(toolSpecification, ensure_ascii=False)
|
||||
|
||||
NacosMcpRouteLogger.get_logger().info("update mcp tools, params {}".format(json.dumps(params, ensure_ascii=False)))
|
||||
url = "http://" + self.nacosAddr + "/nacos/v3/admin/ai/mcp?"
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded", "charset": "utf-8", "userName": self.userName,
|
||||
"password": self.passwd}
|
||||
response_update = httpx.put(url, headers=headers, data=params)
|
||||
if response_update.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
NacosMcpRouteLogger.get_logger().warning(
|
||||
"failed to update mcp tools list, caused: {}".format(response_update.content))
|
||||
return False
|
||||
else:
|
||||
NacosMcpRouteLogger.get_logger().warning("failed to update mcp tools list, caused: {}".format(response.content))
|
||||
return False
|
File diff suppressed because one or more lines are too long
|
@ -1,244 +0,0 @@
|
|||
import json
|
||||
import os
|
||||
|
||||
import anyio
|
||||
from mcp import types
|
||||
from mcp.client.stdio import get_default_environment
|
||||
from mcp.server import Server
|
||||
|
||||
from .logger import NacosMcpRouteLogger
|
||||
from .mcp_manager import McpUpdater
|
||||
from .nacos_http_client import NacosHttpClient
|
||||
from .router_types import ChromaDb
|
||||
from .router_types import CustomServer
|
||||
|
||||
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_http_client = NacosHttpClient(nacosAddr=nacos_addr if nacos_addr != "" else "127.0.0.1:8848", userName=nacos_user_name if nacos_user_name != "" else "nacos",passwd=nacos_password)
|
||||
chroma_db_service = ChromaDb()
|
||||
mcp_updater = McpUpdater(nacosHttpClient=nacos_http_client, chromaDbService=chroma_db_service, update_interval=60)
|
||||
mcp_servers_dict = {}
|
||||
|
||||
router_logger = NacosMcpRouteLogger.get_logger()
|
||||
|
||||
async def search_mcp_server(task_description: str, key_words: str) -> str:
|
||||
"""
|
||||
Name:
|
||||
search_mcp_server
|
||||
|
||||
Description:
|
||||
执行任务前首先使用本工具。根据任务描述及关键字搜索mcp server,制定完成任务的步骤。
|
||||
|
||||
Args:
|
||||
task_description (string): 用户任务描述,使用中文
|
||||
key_words (string): 字符串数组,用户任务关键字,可以为多个,英文逗号分隔,最多为2个
|
||||
"""
|
||||
try:
|
||||
mcp_servers1 = []
|
||||
keywords = key_words.split(",")
|
||||
for key_word in keywords:
|
||||
mcps = mcp_updater.search_mcp_by_keyword(key_word)
|
||||
if len(mcps) > 0:
|
||||
for mcp in mcps:
|
||||
mcp_servers1.append(mcp)
|
||||
|
||||
if len(mcp_servers1) < 5:
|
||||
keywords.append(task_description)
|
||||
mcp_servers2 = mcp_updater.getMcpServer(task_description,5-len(mcp_servers1))
|
||||
for mcp in mcp_servers2:
|
||||
mcp_servers1.append(mcp)
|
||||
|
||||
result = {}
|
||||
for mcpServer in mcp_servers1:
|
||||
dct = {}
|
||||
dct['name'] = str(mcpServer.get_name())
|
||||
dct['description'] = str(mcpServer.get_description())
|
||||
result[str(mcpServer.get_name())] = dct
|
||||
|
||||
content = json.dumps(result, ensure_ascii=False)
|
||||
|
||||
jsonString = "## 获取" + task_description + "的步骤如下:\n" + '''
|
||||
### 1. 当前可用的mcp server列表为:''' + content + '''
|
||||
\n ### 2. 从当前可用的mcp server列表中选择你需要的mcp server调add_mcp_server工具安装mcp server
|
||||
'''
|
||||
return jsonString
|
||||
except Exception as e:
|
||||
router_logger.warning("failed to search_mcp_server: " + task_description, exc_info=e)
|
||||
jsonString = "failed to search mcp server for " + task_description
|
||||
return jsonString
|
||||
|
||||
|
||||
async def use_tool(mcp_server_name: str, mcp_tool_name: str, params:dict) -> str:
|
||||
try:
|
||||
if mcp_server_name not in mcp_servers_dict:
|
||||
router_logger.warning("mcp server {} not found, use search_mcp_server to get mcp servers".format(mcp_server_name))
|
||||
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"
|
||||
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
|
||||
|
||||
async def add_mcp_server(mcp_server_name: str) -> str:
|
||||
"""
|
||||
安装指定的mcp server
|
||||
:param mcp_server_name: mcp server名称
|
||||
:return: mcp server安装结果
|
||||
"""
|
||||
try:
|
||||
mcp_server = nacos_http_client.get_mcp_server_by_name(mcp_server_name)
|
||||
if mcp_server is None or mcp_server.description == "":
|
||||
mcp_server = 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 = {}
|
||||
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
|
||||
|
||||
if mcp_server_name not in mcp_servers_dict:
|
||||
env = get_default_environment()
|
||||
if mcp_server.agentConfig is None:
|
||||
mcp_server.agentConfig = {}
|
||||
if 'mcpServers' not in mcp_server.agentConfig or mcp_server.agentConfig['mcpServers'] is None:
|
||||
mcp_server.agentConfig['mcpServers'] = {}
|
||||
|
||||
mcp_servers = mcp_server.agentConfig["mcpServers"]
|
||||
for key, value in mcp_servers.items():
|
||||
server_config = value
|
||||
if 'env' in server_config:
|
||||
for k in server_config['env']:
|
||||
env[k] = server_config['env'][k]
|
||||
server_config['env'] = env
|
||||
if 'headers' not in server_config:
|
||||
server_config['headers'] = {}
|
||||
|
||||
server = CustomServer(name=mcp_server_name,config=mcp_server.agentConfig)
|
||||
await server.wait_for_initialization()
|
||||
if server.healthy():
|
||||
mcp_servers_dict[mcp_server_name] = server
|
||||
|
||||
server = mcp_servers_dict[mcp_server_name]
|
||||
|
||||
tools = await server.list_tools()
|
||||
tool_list = []
|
||||
for tool in tools:
|
||||
if tool.name in disenabled_tools:
|
||||
continue
|
||||
dct = {}
|
||||
dct['name'] = tool.name
|
||||
dct['description'] = tool.description
|
||||
dct['inputSchema'] = tool.inputSchema
|
||||
tool_list.append(dct)
|
||||
|
||||
nacos_http_client.update_mcp_tools(mcp_server_name,tools)
|
||||
|
||||
result = "1. " + mcp_server_name + "安装完成, tool 列表为: " + json.dumps(tool_list, ensure_ascii=False) + "\n 2." + mcp_server_name + "的工具需要通过nacos-mcp-router的use_tool工具代理使用"
|
||||
return result
|
||||
except Exception as e:
|
||||
router_logger.warning("failed to install mcp server: " + mcp_server_name, exc_info=e)
|
||||
return "failed to install mcp server: " + mcp_server_name
|
||||
|
||||
def main() -> int:
|
||||
app = Server("nacos_mcp_router")
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(
|
||||
name: str, arguments: dict
|
||||
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
|
||||
match name:
|
||||
case "search_mcp_server":
|
||||
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"])
|
||||
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)
|
||||
return [types.TextContent(type="text", text=content)]
|
||||
case _:
|
||||
return [types.TextContent(type="text", text="not implemented tool")]
|
||||
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools() -> list[types.Tool]:
|
||||
return [
|
||||
types.Tool(
|
||||
name="search_mcp_server",
|
||||
description="执行任务前首先使用本工具。根据任务描述及关键字搜索mcp server, 制定完成任务的步骤。",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["task_description", "key_words"],
|
||||
"properties": {
|
||||
"task_description": {
|
||||
"type": "string",
|
||||
"description": "用户任务描述 ",
|
||||
},
|
||||
"key_words": {
|
||||
"type": "string",
|
||||
"description": "用户任务关键字,可以为多个,英文逗号分隔,最多为2个"
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
types.Tool(
|
||||
name="add_mcp_server",
|
||||
description="安装指定的mcp server",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["mcp_server_name"],
|
||||
"properties": {
|
||||
"mcp_server_name": {
|
||||
"type": "string",
|
||||
"description": "MCP Server名称"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
types.Tool(
|
||||
name="use_tool",
|
||||
description="使用某个MCP Server的工具",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["mcp_server_name","mcp_tool_name","params"],
|
||||
"properties": {
|
||||
"mcp_server_name": {
|
||||
"type": "string",
|
||||
"description": "需要使用的MCP Server名称"
|
||||
},
|
||||
"mcp_tool_name":{
|
||||
"type": "string",
|
||||
"description": "需要使用的MCP Server工具名称"
|
||||
},
|
||||
"params": {
|
||||
"type": "string",
|
||||
"description": "需要使用的MCP Server工具的参数"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
anyio.run(arun)
|
||||
|
||||
return 0
|
|
@ -1,175 +0,0 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Optional, Any
|
||||
|
||||
import chromadb
|
||||
from chromadb import Metadata
|
||||
from chromadb.config import Settings
|
||||
from chromadb.api.types import OneOrMany, ID, Document, GetResult, QueryResult
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.stdio import get_default_environment, StdioServerParameters, stdio_client
|
||||
from .logger import NacosMcpRouteLogger
|
||||
from .nacos_mcp_server_config import NacosMcpServerConfig
|
||||
|
||||
def _stdio_transport_context(config: dict[str, Any]):
|
||||
server_params = StdioServerParameters(command=config['command'], args=config['args'], env=config['env'])
|
||||
return stdio_client(server_params)
|
||||
|
||||
|
||||
def _sse_transport_context(config: dict[str, Any]):
|
||||
return sse_client(url=config['url'], headers=config['headers'], timeout=10)
|
||||
|
||||
|
||||
class CustomServer:
|
||||
def __init__(self, name: str, config: dict[str, Any]) -> None:
|
||||
self.name: str = name
|
||||
self.config: dict[str, Any] = config
|
||||
self.stdio_context: Any | None = None
|
||||
self.session: ClientSession | None = None
|
||||
self._cleanup_lock: asyncio.Lock = asyncio.Lock()
|
||||
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
|
||||
else:
|
||||
self._transport_context_factory = _stdio_transport_context
|
||||
|
||||
self._server_task = asyncio.create_task(self._server_lifespan_cycle())
|
||||
|
||||
async def _server_lifespan_cycle(self):
|
||||
try:
|
||||
server_config = self.config
|
||||
if "mcpServers" in self.config:
|
||||
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()
|
||||
except Exception as e:
|
||||
NacosMcpRouteLogger.get_logger().warning("failed to init mcp server " + self.name + ", config: " + str(self.config), exc_info=e)
|
||||
self._initialized_event.set()
|
||||
self._shutdown_event.set()
|
||||
|
||||
def healthy(self) -> bool:
|
||||
return self.session is not None and self._initialized
|
||||
|
||||
async def wait_for_initialization(self):
|
||||
await self._initialized_event.wait()
|
||||
|
||||
async def request_for_shutdown(self):
|
||||
self._shutdown_event.set()
|
||||
|
||||
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")
|
||||
|
||||
tools_response = await self.session.list_tools()
|
||||
|
||||
return tools_response.tools
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
retries: int = 2,
|
||||
delay: float = 1.0,
|
||||
) -> 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)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(delay)
|
||||
await self.session.initialize()
|
||||
try:
|
||||
result = await self.session.call_tool(tool_name, arguments)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up server resources."""
|
||||
async with self._cleanup_lock:
|
||||
try:
|
||||
await self.exit_stack.aclose()
|
||||
self.session = None
|
||||
self.stdio_context = None
|
||||
except Exception as e:
|
||||
logging.error(f"Error during cleanup of server {self.name}: {e}")
|
||||
|
||||
class McpServer:
|
||||
name: str
|
||||
description: str
|
||||
client: ClientSession
|
||||
session: ClientSession
|
||||
mcp_config_detail: NacosMcpServerConfig
|
||||
agentConfig: dict[str, Any]
|
||||
mcp_config_detail: NacosMcpServerConfig
|
||||
def __init__(self, name: str, description: str, agentConfig: dict):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.agentConfig = agentConfig
|
||||
def get_name(self) -> str:
|
||||
return self.name
|
||||
def get_description(self) -> str:
|
||||
return self.description
|
||||
def agent_config(self) -> dict:
|
||||
return self.agentConfig
|
||||
def to_dict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"agentConfig": self.agent_config(),
|
||||
}
|
||||
|
||||
class ChromaDb:
|
||||
def __init__(self) -> None:
|
||||
self.dbClient = chromadb.PersistentClient(path=os.path.expanduser("~") + "/.nacos_mcp_router/chroma_db",
|
||||
settings=Settings(
|
||||
anonymized_telemetry=False,
|
||||
))
|
||||
self._collectionId = "nacos_mcp_router-collection-" + str(os.getpid())
|
||||
self._collection = self.dbClient.get_or_create_collection(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 query(self, query: str, count: int) -> QueryResult:
|
||||
return self._collection.query(
|
||||
query_texts=[query],
|
||||
n_results=count
|
||||
)
|
||||
|
||||
def get(self, id: list[str]) -> GetResult:
|
||||
return self._collection.get(ids=id)
|
|
@ -0,0 +1,20 @@
|
|||
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
|
||||
|
||||
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 .
|
||||
|
||||
# 启动服务
|
||||
CMD ["python", "-m", "nacos_mcp_router"]
|
|
@ -0,0 +1,171 @@
|
|||
# nacos-mcp-router: A MCP server that provides functionalities such as search, installation, proxy, and more.
|
||||
|
||||
[切换到中文版](README_cn.md)
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Quick Start
|
||||
### router mode
|
||||
#### Tools
|
||||
|
||||
1. `search_mcp_server`
|
||||
- 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`
|
||||
- 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`
|
||||
- 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.
|
||||
|
||||
#### 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
|
||||
export NACOS_PASSWORD=$PASSWORD
|
||||
uvx nacos-mcp-router@latest
|
||||
```
|
||||
|
||||
##### Using PIP
|
||||
|
||||
Alternatively you can install `nacos-mcp-router` via pip:
|
||||
|
||||
```
|
||||
pip install nacos-mcp-router
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
##### 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
|
||||
```
|
||||
|
||||
##### Usage with Cline、Cursor、Claude and other applications
|
||||
|
||||
Add this to MCP settings of your application:
|
||||
|
||||
####### Using uvx
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers":
|
||||
{
|
||||
"nacos-mcp-router":
|
||||
{
|
||||
"command": "uvx",
|
||||
"args":
|
||||
[
|
||||
"nacos-mcp-router@latest"
|
||||
],
|
||||
"env":
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 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.
|
||||
|
||||
###### 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
If you are doing local development, simply follow the steps:
|
||||
|
||||
1. Clone this repo into your local environment.
|
||||
2. Modify codes in `src/mcp_server_nacos` to implement your wanted features.
|
||||
3. Test using the Claude desktop app. Add the following to your claude_desktop_config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nacos-mcp-router": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory","PATH-TO-PROJECT","run","nacos-mcp-router"
|
||||
],
|
||||
"env": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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. |
|
||||
|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
|
||||
|
||||
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,169 @@
|
|||
# nacos-mcp-router: 一个提供MCP Server推荐、分发、安装及代理功能的MCP Server.
|
||||
|
||||
## 概述
|
||||
|
||||
[Nacos](https://nacos.io) 一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。Nacos提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理
|
||||
|
||||
Nacos-MCP-Router是一个基于MCP官方标准SDK实现的的MCP Server。它提供了一组工具,提供MCP Server推荐、分发、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
|
||||
|
||||
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 | - | 否 | |
|
||||
|
||||
|
||||
## 常见问题
|
||||
[常见问题](./docs/troubleshooting.md)
|
||||
|
||||
## 许可证
|
||||
nacos-mcp-router 使用 Apache 2.0 许可证. 这意味着您可以自由地使用、修改和分发该软件,但需遵守 Apache 2.0 许可证的条款和条件。更多详细信息,请参阅项目仓库中的 LICENSE 文件
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
## 常见问题排查
|
||||
|
||||
### MCP搜索结果为空
|
||||
1. 确认环境变量NACOS_ADDR、NACOS_USERNAME、NACOS_PASSWORD配置正确
|
||||
2. 检查Nacos Server是否正常
|
||||
```shell
|
||||
curl -i "http://$NACOS_ADDR/nacos/v3/admin/ai/mcp/list?" -H "userName:$USERNAME" -H "password:$PASSWORD"
|
||||
curl -i "http://$NACOS_ADDR/nacos/v3/admin/ai/mcp?mcpName=$MCP_NAME" -H "userName:$USERNAME" -H "password:$PASSWORD"
|
||||
```
|
||||
正常应返回类似如下内容
|
||||

|
||||
|
||||
3. 0.1.9之前版本限流问题
|
||||
因Nacos服务端升级,nacos-mcp-router 0.1.9之前的版本使用3.0.1及之后的Nacos服务端时可能会因为限流导致搜素结果为空,请升级nacos-mcp-router版本至0.1.9以上
|
||||
|
||||
### 启动失败问题
|
||||
1. 请确认uvx或npx等命令已安装且相应账号有执行权限。如果命令确认安装,可以尝试把mcp配置中command字段的值改为绝对路径
|
||||
2. 确认网络正常,nacos-mcp-router启动过程中需要下载依赖
|
||||
|
Binary file not shown.
After Width: | Height: | Size: 697 KiB |
|
@ -1,12 +1,12 @@
|
|||
[project]
|
||||
name = "nacos_mcp_router"
|
||||
version = "0.1.7"
|
||||
version = "0.2.2"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"chromadb>=1.0.5",
|
||||
"mcp>=1.6.0",
|
||||
"mcp>=1.9.4",
|
||||
"requests>=2.32.3",
|
||||
]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import logging
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from .router import main
|
||||
if __name__ == "__main__":
|
|
@ -1,3 +1,4 @@
|
|||
#-*- coding: utf-8 -*-
|
||||
from .router import main
|
||||
|
||||
if __name__ == "__main__":
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
from typing import Final
|
||||
|
||||
TRANSPORT_TYPE_STDIO: Final[str] = 'stdio'
|
||||
TRANSPORT_TYPE_SSE: Final[str] = 'sse'
|
||||
TRANSPORT_TYPE_STREAMABLE_HTTP: Final[str] = 'streamable_http'
|
||||
MODE_ROUTER: Final[str] = "router"
|
||||
MODE_PROXY: Final[str] = "proxy"
|
|
@ -1,3 +1,5 @@
|
|||
#-*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
@ -9,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:
|
|
@ -0,0 +1,256 @@
|
|||
#-*- coding: utf-8 -*-
|
||||
import functools
|
||||
import os
|
||||
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 | 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._running = False
|
||||
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
|
||||
def create(cls,
|
||||
nacos_client: NacosHttpClient,
|
||||
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 = True
|
||||
|
||||
if enable_auto_refresh:
|
||||
updater._thread.start()
|
||||
|
||||
return updater
|
||||
|
||||
def asyncUpdater(self) -> None:
|
||||
debug_mode = os.getenv('DEBUG_MODE')
|
||||
if debug_mode is not None:
|
||||
logger.info("debug mode is enabled")
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
des = detail.get_tool_description()
|
||||
|
||||
name = mcpServer.get_name()
|
||||
sname = str(name)
|
||||
|
||||
cache[sname] = mcpServer
|
||||
|
||||
md5_str = get_md5(des)
|
||||
|
||||
with self.lock:
|
||||
self._cache = cache
|
||||
except Exception as e:
|
||||
logger.warning("exception while updating mcp server: ", exc_info=e)
|
||||
|
||||
async def _get_from_cache(self, id: str) -> Optional[McpServer]:
|
||||
"""从缓存中获取 MCP 服务器"""
|
||||
with self.lock:
|
||||
return self._cache.get(id)
|
||||
|
||||
async def _cache_values(self) -> List[McpServer]:
|
||||
"""获取缓存中的所有值"""
|
||||
with self.lock:
|
||||
return list(self._cache.values())
|
||||
|
||||
async def getMcpServer(self, query: str, count: int) -> List[McpServer]:
|
||||
"""通过查询获取 MCP 服务器"""
|
||||
if not self.enable_vector_db or self.chromaDbService is None:
|
||||
return []
|
||||
|
||||
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 ""
|
||||
|
||||
# 统一编码为 UTF-8
|
||||
if isinstance(text, bytes):
|
||||
text = text.decode('utf-8', errors='ignore')
|
||||
|
||||
# 移除不可见字符和多余空格
|
||||
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,3 +1,4 @@
|
|||
#-*- coding: utf-8 -*-
|
||||
import hashlib
|
||||
|
||||
def get_md5(text: str) -> str:
|
|
@ -0,0 +1,369 @@
|
|||
#-*- 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
|
||||
from .logger import NacosMcpRouteLogger
|
||||
|
||||
# logger, setup logger if not exists
|
||||
logger = NacosMcpRouteLogger.get_logger()
|
||||
|
||||
# Content types, used in request Nacos Server http headers, default is JSON.
|
||||
CONTENT_TYPE_JSON = "application/json; charset=utf8"
|
||||
CONTENT_TYPE_URLENCODED = "application/x-www-form-urlencoded; charset=utf8"
|
||||
|
||||
# HTTP schema, default is HTTP
|
||||
_SCHEMA_HTTP = "http"
|
||||
_SCHEMA = os.getenv("NACOS_SERVER_SCHEMA", _SCHEMA_HTTP)
|
||||
|
||||
class NacosHttpClient:
|
||||
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 = 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:
|
||||
"""
|
||||
Retrieve an MCP server by its name from the NACOS server.
|
||||
|
||||
This asynchronous method sends a GET request to the NACOS server to fetch the configuration
|
||||
of a specific MCP server identified by its name. If the request is successful, it constructs
|
||||
an `McpServer` object using the retrieved data. If the request fails, it logs a warning and
|
||||
returns an `McpServer` object with default values.
|
||||
|
||||
Args:
|
||||
name (str): The name of the MCP server to retrieve.
|
||||
id (str): The ID of the MCP server to retrieve.
|
||||
|
||||
Returns:
|
||||
McpServer: An [McpServer] object representing the retrieved MCP server. If the request
|
||||
fails, the object will have default values.
|
||||
"""
|
||||
params = {}
|
||||
if self.namespaceId != "" and self.namespaceId is not None:
|
||||
params['namespaceId'] = self.namespaceId
|
||||
if id is not None and id != "":
|
||||
params['mcpId'] = id
|
||||
else:
|
||||
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, 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,
|
||||
version=config.version)
|
||||
|
||||
mcp_server.mcp_config_detail = config
|
||||
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):
|
||||
mcp_servers = list[McpServer]()
|
||||
|
||||
params = {}
|
||||
if self.namespaceId != "":
|
||||
params['namespaceId'] = self.namespaceId
|
||||
params['pageNo'] = page_no
|
||||
params['pageSize'] = page_size
|
||||
params['search'] = "blur"
|
||||
|
||||
uri = f'/nacos/v3/admin/ai/mcp/list?'+urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
success, data = await self.request_nacos(uri)
|
||||
|
||||
if not success:
|
||||
logger.warning("failed to get mcp server list response")
|
||||
return 0, mcp_servers
|
||||
|
||||
total_count = data['totalCount']
|
||||
|
||||
async def _to_mcp_server(m: dict) -> McpServer | None:
|
||||
"""
|
||||
Fetch the mcp server unless the server is disabled(enabled=false)
|
||||
or it's description field is None.
|
||||
"""
|
||||
if not m["enabled"]:
|
||||
return None
|
||||
name = m["name"]
|
||||
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"]
|
||||
|
||||
s = await self.get_mcp_server(id, name)
|
||||
return s if s.description else None
|
||||
else:
|
||||
return None
|
||||
|
||||
tasks = [ _to_mcp_server(m) for m in data['pageItems']]
|
||||
tasks = [t for t in tasks if t is not None]
|
||||
if tasks:
|
||||
# use asyncio.gather to run the tasks concurrently
|
||||
# and wait for all of them to complete
|
||||
# this is more efficient than using await for each task
|
||||
# because it allows multiple tasks to run at the same time
|
||||
# instead of waiting for each one to finish before starting the next
|
||||
mcp_servers = await asyncio.gather(*tasks)
|
||||
mcp_servers = [s for s in mcp_servers if s is not None]
|
||||
|
||||
return total_count, list(mcp_servers)
|
||||
|
||||
async def get_mcp_servers(self) -> list[McpServer]:
|
||||
"""Loading the remote MCP servers from Nacos Server.
|
||||
|
||||
This asynchronous method retrieves a list of MCP servers from the Nacos Server.
|
||||
It uses pagination to continuously fetch server information until all server information is obtained.
|
||||
The method first initializes the list of MCP servers and pagination parameters, then enters a loop to call
|
||||
the get_mcp_servers_by_page method to get the total number of servers and server list for the current page.
|
||||
If the total number of servers is 0 or the server list is empty, it means there are no servers available, and the loop ends.
|
||||
Otherwise, the server list for the current page is added to the result list. If the number of servers collected reaches the total number,
|
||||
it means all server information has been collected, and the loop ends. Otherwise, the page number is incremented and the loop continues until all servers are collected.
|
||||
|
||||
Returns:
|
||||
list[McpServer]: A list of MCP servers.
|
||||
"""
|
||||
mcp_servers, page_no, page_size = [], 1, 100
|
||||
|
||||
while True:
|
||||
total_count, servers = await self.get_mcp_servers_by_page(page_no, page_size)
|
||||
if total_count == 0 or not servers:
|
||||
break
|
||||
|
||||
mcp_servers.extend(servers)
|
||||
if len(mcp_servers) >= total_count:
|
||||
break
|
||||
|
||||
# continue to looping
|
||||
page_no += 1
|
||||
|
||||
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], mcp_version: str, id: str) -> bool:
|
||||
"""
|
||||
Update the tools list for a specified MCP.
|
||||
|
||||
This asynchronous method updates the tools associated with a specific MCP by sending
|
||||
an HTTP PUT request to the Nacos server. It first retrieves the current configuration
|
||||
of the MCP using a GET request. If the retrieval is unsuccessful, it logs a warning
|
||||
and returns False. If successful, it parses the tool parameters and sends a PUT request
|
||||
to update the tools list.
|
||||
|
||||
Args:
|
||||
mcp_name (str): The name of the MCP for which the tools list needs to be updated.
|
||||
tools (list[Tool]): A list of Tool objects representing the new tools configuration.
|
||||
id (str): The id of the MCP server.
|
||||
|
||||
Returns:
|
||||
bool: True if the update was successful, otherwise False.
|
||||
"""
|
||||
params = {}
|
||||
if self.namespaceId != "":
|
||||
params['namespaceId'] = self.namespaceId
|
||||
if id != "":
|
||||
params['mcpId'] = id
|
||||
else:
|
||||
params['mcpName'] = mcp_name
|
||||
|
||||
uri = f'/nacos/v3/admin/ai/mcp?'+urllib.parse.urlencode(params)
|
||||
|
||||
# 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": mcp_version}
|
||||
|
||||
if self.namespaceId != "":
|
||||
data["namespaceId"] = self.namespaceId
|
||||
|
||||
params = _parse_tool_params(data, mcp_name, tools)
|
||||
|
||||
logger.info(f"Trying to update mcp tools with params {json.dumps(params, ensure_ascii=False)}")
|
||||
|
||||
success, _ = await self.request_nacos(f"/nacos/v3/admin/ai/mcp?",
|
||||
method='PUT',
|
||||
data=params,
|
||||
content_type=CONTENT_TYPE_URLENCODED)
|
||||
|
||||
logger.info(f"Update mcp tools, name: {mcp_name}, result: {success}")
|
||||
|
||||
return success
|
||||
|
||||
async def request_nacos(self, uri,
|
||||
method='GET',
|
||||
data=None,
|
||||
content_type=CONTENT_TYPE_JSON) -> tuple[bool, dict]:
|
||||
"""
|
||||
Asynchronously send a request to the NACOS server.
|
||||
|
||||
This method sends an HTTP request to the NACOS server using the specified URI, HTTP method, and data.
|
||||
It handles exceptions and logs warnings for any errors encountered during the request or response parsing with
|
||||
json.
|
||||
|
||||
Args:
|
||||
uri (str): The URI path for the request.
|
||||
method (str): The HTTP method to use. Supported methods are 'GET', 'POST', 'PUT', and 'DELETE'. Defaults to 'GET'.
|
||||
data (dict, optional): The data to send with the request. Required for 'POST', 'PUT', and 'DELETE' methods. Defaults to None.
|
||||
content_type (str, optional): The transmitting http body using encoding methd.
|
||||
|
||||
Returns:
|
||||
tuple[bool, dict]: A tuple containing:
|
||||
- A boolean indicating whether the request was successful.
|
||||
- The parsed JSON response data if the request was successful, otherwise None.
|
||||
|
||||
Raises:
|
||||
ValueError: If an invalid HTTP method is provided.
|
||||
"""
|
||||
|
||||
try:
|
||||
url = f"{self.schema}://{self.nacosAddr}{uri}"
|
||||
headers = {"Content-Type": content_type,
|
||||
"charset": "utf-8",
|
||||
"userName": self.userName,
|
||||
"password": self.passwd}
|
||||
self._inject_auth_info(headers)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
if method == "GET":
|
||||
response = await client.get(url, headers=headers)
|
||||
elif method == "POST":
|
||||
response = await client.post(url, headers=headers, data=data)
|
||||
elif method == "PUT":
|
||||
response = await client.put(url, headers=headers, data=data)
|
||||
elif method == "DELETE":
|
||||
response = await client.delete(url, headers=headers)
|
||||
else:
|
||||
raise ValueError("Invalid method")
|
||||
except Exception as e:
|
||||
logger.warning(f"failed to request with NACOS server, uri: {uri}, error: {e}", exc_info=e)
|
||||
return False, {}
|
||||
|
||||
code = response.status_code
|
||||
if code != 200:
|
||||
logger.warning(f"failed to request with NACOS server, uri: {uri}, code: {code}, response: {response.content}")
|
||||
return False, {}
|
||||
|
||||
try:
|
||||
return True, json.loads(response.content.decode("utf-8")).get("data")
|
||||
except Exception as e:
|
||||
logger.warning(f"failed to parse response with NACOS server, uri: {uri}, error: {e}")
|
||||
return False, {}
|
||||
|
||||
|
||||
def _parse_tool_params(data, mcp_name, tools) -> dict[str, str]:
|
||||
tool_list = map(
|
||||
lambda tool: {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.inputSchema
|
||||
},
|
||||
tools
|
||||
)
|
||||
|
||||
endpoint_specification = None
|
||||
if data['protocol'] != "stdio":
|
||||
endpoint_specification = {
|
||||
'data': data.get('remoteServerConfig', {}).get('serviceRef'),
|
||||
'type': 'REF'
|
||||
}
|
||||
|
||||
tool_spec = data.get('toolSpec') or {}
|
||||
tool_spec['tools'] = list(tool_list)
|
||||
|
||||
del data['backendEndpoints']
|
||||
|
||||
return {
|
||||
'mcpName': mcp_name,
|
||||
'serverSpecification': json.dumps(data, ensure_ascii=False),
|
||||
'endpointSpecification': json.dumps(endpoint_specification or {}, ensure_ascii=False),
|
||||
'toolSpecification': json.dumps(tool_spec, ensure_ascii=False),
|
||||
'latest':'True'
|
||||
}
|
||||
|
||||
def _parse_mcp_detail(mcp_server, config, searching_name):
|
||||
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("/"):
|
||||
path = f"/{path}"
|
||||
url = f"{http_schema}://{endpoint.address}:{endpoint.port}{path}"
|
||||
|
||||
mcp_servers = mcp_server.agentConfig.setdefault("mcpServers", {})
|
||||
dct = {
|
||||
"name": searching_name,
|
||||
"description": '',
|
||||
"url": url,
|
||||
"protocol": mcp_server.agentConfig["protocol"],
|
||||
}
|
||||
|
||||
mcp_servers[searching_name] = dct
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
#-*- coding: utf-8 -*-
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any
|
||||
from .logger import NacosMcpRouteLogger
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputProperty:
|
||||
type: str
|
||||
description: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "InputProperty":
|
||||
if data is None or len(data) == 0:
|
||||
return InputProperty(type="", description="")
|
||||
return cls(
|
||||
type=data["type"],
|
||||
description=data["description"]
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class InputSchema:
|
||||
type: str
|
||||
properties: Dict[str, InputProperty]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "InputSchema":
|
||||
if data is None or len(data) == 0:
|
||||
return InputSchema(type="", properties={})
|
||||
return cls(
|
||||
type=data["type"],
|
||||
properties={k: InputProperty.from_dict(v) for k, v in data["properties"].items()}
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
name: str
|
||||
description: str
|
||||
input_schema: dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Tool":
|
||||
return cls(
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
input_schema=data["inputSchema"]
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class ToolMeta:
|
||||
invoke_context: Dict[str, Any]
|
||||
enabled: bool
|
||||
templates: Dict[str, str]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ToolMeta":
|
||||
return cls(
|
||||
invoke_context=data.get("invokeContext", {}),
|
||||
enabled=data.get("enabled", True),
|
||||
templates=data.get("templates", {})
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class ToolSpec:
|
||||
tools: List[Tool]
|
||||
tools_meta: Dict[str, ToolMeta]
|
||||
tools_dict: Dict[str, Tool]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ToolSpec":
|
||||
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_dict={}
|
||||
)
|
||||
for tool in tool_spec.tools:
|
||||
tool_spec.tools_dict[tool.name] = tool
|
||||
|
||||
return tool_spec
|
||||
|
||||
# ------------------ 主结构 ------------------
|
||||
@dataclass
|
||||
class ServiceRef:
|
||||
namespace_id: str
|
||||
group_name: str
|
||||
service_name: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ServiceRef":
|
||||
if data is None or len(data) == 0:
|
||||
return ServiceRef(namespace_id="", group_name="", service_name="")
|
||||
return cls(
|
||||
namespace_id=data["namespaceId"],
|
||||
group_name=data["groupName"],
|
||||
service_name=data["serviceName"]
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class RemoteServerConfig:
|
||||
service_ref: ServiceRef
|
||||
export_path: str
|
||||
credentials: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "RemoteServerConfig":
|
||||
if data is None or len(data) == 0:
|
||||
return RemoteServerConfig(service_ref=ServiceRef.from_dict({}), export_path="", credentials={})
|
||||
return cls(
|
||||
service_ref=ServiceRef.from_dict(data["serviceRef"]),
|
||||
export_path=data["exportPath"],
|
||||
credentials=data.get("credentials", {})
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class BackendEndpoint:
|
||||
address: str
|
||||
port: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "BackendEndpoint":
|
||||
if data is None or len(data) == 0:
|
||||
return BackendEndpoint(address="", port=-1)
|
||||
return cls(
|
||||
address=data["address"],
|
||||
port=data["port"]
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class NacosMcpServerConfig:
|
||||
name: str
|
||||
protocol: str
|
||||
description: str | None
|
||||
version: str
|
||||
id: str | None
|
||||
remote_server_config: RemoteServerConfig
|
||||
local_server_config: Dict[str, Any] = field(default_factory=dict)
|
||||
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={}, tools_dict={}))
|
||||
front_protocol: str | None = None
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "NacosMcpServerConfig":
|
||||
tool_spec_data = data.get("toolSpec")
|
||||
backend_endpoints_data = data.get("backendEndpoints")
|
||||
try:
|
||||
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"]),
|
||||
local_server_config=data.get("localServerConfig", {}) if data.get("localServerConfig") else {},
|
||||
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={}, tools_dict={}),
|
||||
id=data["id"] if data.get("id") else None
|
||||
)
|
||||
except Exception as e:
|
||||
NacosMcpRouteLogger.get_logger().warning("failed to parse NacosMcpServerConfig from data: %s", data, exc_info=e)
|
||||
raise Exception("failed to parse NacosMcpServerConfig from data")
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, string: str) -> "NacosMcpServerConfig":
|
||||
return cls.from_dict(json.loads(string))
|
||||
|
||||
def get_tool_description(self) -> str:
|
||||
des = "" if self.description is None else self.description
|
||||
for tool in self.tool_spec.tools:
|
||||
if tool.description is not None:
|
||||
des += "\n" + tool.description
|
||||
|
||||
return des
|
|
@ -0,0 +1,558 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import typing
|
||||
from importlib.metadata import version as get_version
|
||||
|
||||
import anyio
|
||||
from mcp import types
|
||||
from mcp.client.stdio import get_default_environment
|
||||
from mcp.server import Server
|
||||
|
||||
from .constants import TRANSPORT_TYPE_STDIO, MODE_ROUTER, MODE_PROXY
|
||||
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, 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: 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, 制定完成任务的步骤。注意:任务描述及关键字需要同时包含中文和英文。",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["task_description", "key_words"],
|
||||
"properties": {
|
||||
"task_description": {
|
||||
"type": "string",
|
||||
"description": "用户中文和英文任务描述,中英文描述各占单独一行。如果任务描述只包含中文,请同时输入英文描述,反之亦然。",
|
||||
},
|
||||
"key_words": {
|
||||
"type": "string",
|
||||
"description": "用户任务关键字,可以为多个,最多为4个,包含中英文关键字,英文逗号分隔"
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
types.Tool(
|
||||
name="add_mcp_server",
|
||||
description="安装指定的mcp server",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["mcp_server_name"],
|
||||
"properties": {
|
||||
"mcp_server_name": {
|
||||
"type": "string",
|
||||
"description": "MCP Server名称"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
types.Tool(
|
||||
name="use_tool",
|
||||
description="使用某个MCP Server的工具",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["mcp_server_name", "mcp_tool_name", "params"],
|
||||
"properties": {
|
||||
"mcp_server_name": {
|
||||
"type": "string",
|
||||
"description": "需要使用的MCP Server名称"
|
||||
},
|
||||
"mcp_tool_name": {
|
||||
"type": "string",
|
||||
"description": "需要使用的MCP Server工具名称"
|
||||
},
|
||||
"params": {
|
||||
"type": "string",
|
||||
"description": "需要使用的MCP Server工具的参数"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
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():
|
||||
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}")
|
||||
|
||||
|
||||
async def search_mcp_server(task_description: str, key_words: str) -> str:
|
||||
"""
|
||||
Name:
|
||||
search_mcp_server
|
||||
|
||||
Description:
|
||||
执行任务前首先使用本工具。根据任务描述及关键字搜索mcp server,制定完成任务的步骤。
|
||||
|
||||
Args:
|
||||
task_description (string): 用户任务描述,使用中文
|
||||
key_words (string): 字符串数组,用户任务关键字,可以为多个,英文逗号分隔,最多为2个
|
||||
"""
|
||||
try:
|
||||
if mcp_updater is None:
|
||||
return "服务初始化中,请稍后再试"
|
||||
|
||||
router_logger.info(f"Searching tools for {task_description}, key words: {key_words}")
|
||||
mcp_servers1 = []
|
||||
keywords = key_words.split(",")
|
||||
for key_word in keywords:
|
||||
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:
|
||||
mcp_servers2 = await mcp_updater.getMcpServer(task_description, 5 - len(mcp_servers1))
|
||||
mcp_servers1.extend(mcp_servers2 or [])
|
||||
|
||||
result = {}
|
||||
for mcpServer in mcp_servers1:
|
||||
mname = str(mcpServer.get_name())
|
||||
dct = dict(name=mname,
|
||||
description=mcpServer.get_description())
|
||||
|
||||
result[mname] = dct
|
||||
|
||||
router_logger.info(f"Found {len(result)} server(s) totally")
|
||||
content = json.dumps(result, ensure_ascii=False)
|
||||
|
||||
json_string = ("## 获取" + task_description + "的步骤如下:\n"
|
||||
+ "### 1. 当前可用的mcp server列表为:" + content
|
||||
+ "\n### 2. 从当前可用的mcp server列表中选择你需要的mcp server调add_mcp_server工具安装mcp server")
|
||||
|
||||
return json_string
|
||||
except Exception as e:
|
||||
msg = f"failed to search mcp server for {task_description}"
|
||||
router_logger.warning(msg, exc_info=e)
|
||||
return f"Error: {msg}"
|
||||
|
||||
|
||||
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 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]
|
||||
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 + ", please use add_mcp_server to install mcp server"
|
||||
|
||||
async def add_mcp_server(mcp_server_name: str, client_headers: dict[str, str] = {}) -> str:
|
||||
"""
|
||||
安装指定的mcp server
|
||||
:param mcp_server_name: mcp server名称
|
||||
:return: mcp server安装结果
|
||||
"""
|
||||
try:
|
||||
if nacos_http_client is None or mcp_updater is None:
|
||||
return "服务初始化中,请稍后再试"
|
||||
|
||||
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"
|
||||
|
||||
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:
|
||||
disabled_tools[tool_name] = True
|
||||
|
||||
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 = {}
|
||||
if 'mcpServers' not in mcp_server.agentConfig or mcp_server.agentConfig['mcpServers'] is None:
|
||||
mcp_server.agentConfig['mcpServers'] = {}
|
||||
|
||||
mcp_servers = mcp_server.agentConfig["mcpServers"]
|
||||
for key, value in mcp_servers.items():
|
||||
server_config = value
|
||||
if 'env' in server_config:
|
||||
for k in server_config['env']:
|
||||
env[k] = server_config['env'][k]
|
||||
server_config['env'] = env
|
||||
if 'headers' not in server_config:
|
||||
server_config['headers'] = {}
|
||||
router_logger.info(f"add mcp server: {mcp_server_name}, config:{mcp_server.agentConfig}")
|
||||
server = CustomServer(name=mcp_server_name, config=mcp_server.agentConfig)
|
||||
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_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 disabled_tools:
|
||||
continue
|
||||
dct = {}
|
||||
dct['name'] = tool.name
|
||||
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, 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工具代理使用"
|
||||
return result
|
||||
except Exception as e:
|
||||
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() -> int:
|
||||
|
||||
match transport_type:
|
||||
case 'stdio':
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async def arun():
|
||||
async with stdio_server() as streams:
|
||||
await mcp_app.run(
|
||||
streams[0], streams[1], mcp_app.create_initialization_options()
|
||||
)
|
||||
|
||||
anyio.run(arun)
|
||||
|
||||
return 0
|
||||
case 'sse':
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
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
|
||||
) as streams:
|
||||
await mcp_app.run(
|
||||
streams[0], streams[1], mcp_app.create_initialization_options()
|
||||
)
|
||||
@contextlib.asynccontextmanager
|
||||
async def sse_lifespan(app: Starlette) -> AsyncIterator[None]:
|
||||
"""Context manager for session manager."""
|
||||
try:
|
||||
if mode == MODE_PROXY:
|
||||
if not await init_proxied_mcp():
|
||||
raise NacosMcpRouterException("failed to init mcp server")
|
||||
yield
|
||||
for mcp in mcp_servers_dict.values():
|
||||
await mcp.cleanup()
|
||||
finally:
|
||||
router_logger.info("Application shutting down...")
|
||||
|
||||
|
||||
starlette_app = Starlette(
|
||||
debug=True,
|
||||
routes=[
|
||||
Route("/sse", endpoint=handle_sse, methods=["GET"]),
|
||||
Mount("/messages/", app=sse_transport.handle_post_message),
|
||||
],
|
||||
lifespan= sse_lifespan,
|
||||
)
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(starlette_app, host="0.0.0.0", port=sse_port)
|
||||
return 0
|
||||
case 'streamable_http':
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from starlette.types import Scope
|
||||
from starlette.types import Receive
|
||||
from starlette.types import Send
|
||||
|
||||
streamable_port = int(os.getenv("PORT", "8000"))
|
||||
session_manager = StreamableHTTPSessionManager(
|
||||
app=mcp_app,
|
||||
event_store=None,
|
||||
json_response=False,
|
||||
stateless=True,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
sse_transport = SseServerTransport("/messages/")
|
||||
|
||||
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."""
|
||||
async with session_manager.run():
|
||||
try:
|
||||
if mode == MODE_PROXY:
|
||||
if not await init_proxied_mcp():
|
||||
raise NacosMcpRouterException("failed to init mcp server")
|
||||
yield
|
||||
|
||||
for mcp in mcp_servers_dict.values():
|
||||
await mcp.cleanup()
|
||||
finally:
|
||||
router_logger.info("Application shutting down...")
|
||||
|
||||
starlette_app = Starlette(
|
||||
debug=True,
|
||||
routes=[
|
||||
Mount("/mcp", app=handle_streamable_http),
|
||||
Mount("/messages/", app=sse_transport.handle_post_message),
|
||||
],
|
||||
lifespan=lifespan,
|
||||
)
|
||||
import uvicorn
|
||||
uvicorn.run(starlette_app, host="0.0.0.0", port=streamable_port)
|
||||
return 0
|
||||
case _:
|
||||
router_logger.error("unknown transport type: " + transport_type)
|
||||
return 1
|
||||
|
||||
|
||||
def create_mcp_app() -> Server:
|
||||
@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, client_headers=headers)
|
||||
return result.content
|
||||
else:
|
||||
match name:
|
||||
case "search_mcp_server":
|
||||
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"], 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, headers)
|
||||
return [types.TextContent(type="text", text=content)]
|
||||
case _:
|
||||
return [types.TextContent(type="text", text="not implemented tool")]
|
||||
|
||||
@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(headers)
|
||||
else:
|
||||
return router_tools()
|
||||
|
||||
return mcp_app
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if init() != 0:
|
||||
return 1
|
||||
create_mcp_app()
|
||||
return start_server()
|
||||
|
||||
|
||||
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", "")
|
||||
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}, "
|
||||
f"nacos_password: {nacos_password}, "
|
||||
f"mode: {mode}, "
|
||||
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"version: {version_number}"
|
||||
)
|
||||
|
||||
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_ROUTER:
|
||||
chroma_db_service = ChromaDb()
|
||||
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)
|
||||
raise e
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class NacosMcpRouterException(Exception):
|
||||
msg: str | None = None
|
||||
def __init__(self, msg: str):
|
||||
self.msg = msg
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.msg}'
|
||||
|
||||
def get_error_message(self) -> str | None:
|
||||
return self.msg
|
|
@ -0,0 +1,304 @@
|
|||
#-*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Optional, Any
|
||||
|
||||
import chromadb
|
||||
import mcp.types
|
||||
from chromadb import Metadata
|
||||
from chromadb.config import Settings
|
||||
from chromadb.api.types import OneOrMany, ID, Document, GetResult, QueryResult
|
||||
from mcp import ClientSession
|
||||
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'] 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:
|
||||
self.name: str = name
|
||||
self.config: dict[str, Any] = config
|
||||
self.stdio_context: Any | None = None
|
||||
self.session: ClientSession | None = None
|
||||
self._cleanup_lock: asyncio.Lock = asyncio.Lock()
|
||||
self.exit_stack: AsyncExitStack = AsyncExitStack()
|
||||
self._initialized_event = asyncio.Event()
|
||||
self._shutdown_event = asyncio.Event()
|
||||
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
|
||||
if "mcpServers" in self.config:
|
||||
mcp_servers = self.config["mcpServers"]
|
||||
for key, value in mcp_servers.items():
|
||||
server_config = value
|
||||
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()
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
async def request_for_shutdown(self):
|
||||
self._shutdown_event.set()
|
||||
|
||||
async def wait_for_shutdown_request(self):
|
||||
await self._shutdown_event.wait()
|
||||
|
||||
async def list_tools(self) -> list[mcp.types.Tool]:
|
||||
return await self.list_tools_with_headers(client_headers={})
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
retries: int = 2,
|
||||
delay: float = 1.0,
|
||||
client_headers: dict[str, str] = {}
|
||||
) -> Any:
|
||||
|
||||
attempt = 0
|
||||
while attempt < retries:
|
||||
try:
|
||||
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)
|
||||
if self._protocol == 'stdio':
|
||||
if self.session is not None:
|
||||
await self.session.initialize()
|
||||
try:
|
||||
result = await self.call_tool(tool_name, arguments, client_headers)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up server resources."""
|
||||
async with self._cleanup_lock:
|
||||
try:
|
||||
await self.exit_stack.aclose()
|
||||
self.session = None
|
||||
self.stdio_context = None
|
||||
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
|
||||
client: ClientSession
|
||||
session: ClientSession
|
||||
mcp_config_detail: NacosMcpServerConfig
|
||||
agentConfig: dict[str, Any]
|
||||
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:
|
||||
return self.description
|
||||
def agent_config(self) -> dict:
|
||||
return self.agentConfig
|
||||
def to_dict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"agentConfig": self.agent_config(),
|
||||
}
|
||||
|
||||
class ChromaDb:
|
||||
def __init__(self) -> None:
|
||||
self.dbClient = chromadb.PersistentClient(path=os.path.expanduser("~") + "/.nacos_mcp_router/chroma_db",
|
||||
settings=Settings(
|
||||
anonymized_telemetry=False,
|
||||
))
|
||||
self._collectionId = "nacos_mcp_router-collection"
|
||||
self._collection = self.dbClient.get_or_create_collection(name=self._collectionId)
|
||||
self.preIds = []
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def get(self, id: list[str]) -> GetResult:
|
||||
return self._collection.get(ids=id)
|
|
@ -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()
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import unittest, os, asyncio
|
||||
import time
|
||||
from mcp import Tool
|
||||
from ..nacos_mcp_router.nacos_http_client import NacosHttpClient
|
||||
|
||||
|
||||
class TestAsyncGeneratorsPerformance(unittest.TestCase):
|
||||
def setUp(self):
|
||||
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
|
||||
|
||||
async def method_await_for(self, items):
|
||||
return [await self.asynchronize(m) for m in items]
|
||||
|
||||
async def method_gather(self, items):
|
||||
tasks = [self.asynchronize(m) for m in items]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
def test_performance_comparison(self):
|
||||
items = list(range(10)) # Example input list
|
||||
|
||||
# Measure performance of method_await_for
|
||||
start_time = time.perf_counter()
|
||||
result_await_for = asyncio.run(self.method_await_for(items))
|
||||
duration_await_for = time.perf_counter() - start_time
|
||||
|
||||
# Measure performance of method_gather
|
||||
start_time = time.perf_counter()
|
||||
result_gather = asyncio.run(self.method_gather(items))
|
||||
duration_gather = time.perf_counter() - start_time
|
||||
|
||||
# Assert results are the same
|
||||
self.assertEqual(result_await_for, result_gather)
|
||||
|
||||
# Print performance results
|
||||
print(f"method_await_for duration: {duration_await_for:.4f} seconds")
|
||||
print(f"method_gather duration: {duration_gather:.4f} seconds")
|
||||
|
||||
#@patch('httpx.AsyncClient.get', new_callable=AsyncMock)
|
||||
def test_get_mcp_server_by_name_success(self):
|
||||
mcp_server = asyncio.run(self.client.get_mcp_server(id="", name="Puppeteer"))
|
||||
self.assertEqual(mcp_server.name, "Puppeteer")
|
||||
self.assertTrue('Puppeteer' in mcp_server.description, "Check puppeteer is in the returned value.")
|
||||
|
||||
def test_get_mcp_server_by_name_failure(self):
|
||||
mcp_server = asyncio.run(self.client.get_mcp_server(id="", name="non_existent_mcp"))
|
||||
self.assertEqual(mcp_server.name, "non_existent_mcp")
|
||||
self.assertEqual(mcp_server.description, "")
|
||||
self.assertEqual(mcp_server.agentConfig, {})
|
||||
|
||||
def test_update_mcp_tools_success(self):
|
||||
tool = Tool(name="Puppeteer", description="Test Tool-UPDATED", inputSchema={})
|
||||
success = asyncio.run(self.client.update_mcp_tools("Puppeteer", [tool], "1.0.0", ""))
|
||||
self.assertTrue(success)
|
||||
|
||||
def test_update_mcp_tools_failure(self):
|
||||
|
||||
tool = Tool(name="test_tool", description="Test Tool", inputSchema={})
|
||||
success = asyncio.run(self.client.update_mcp_tools("non_existent_mcp", [tool],"1.0.0", ""))
|
||||
self.assertFalse(success)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -0,0 +1,21 @@
|
|||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp import ClientSession
|
||||
|
||||
|
||||
async def main():
|
||||
# Connect to a streamable HTTP server
|
||||
async with streamablehttp_client(url="http://127.0.0.1:8000/mcp") as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
# Create a session using the client streams
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the connection
|
||||
await session.initialize()
|
||||
tool_list = await session.list_tools()
|
||||
print(tool_list)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(main())
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,60 @@
|
|||
# Changelog
|
||||
|
||||
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)
|
||||
|
||||
### [1.0.10](https://github.com/nacos-group/nacos-mcp-router/compare/v1.0.9...v1.0.10) (2025-05-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **memory-vector:** 增加日志记录功能 ([fb49ffb](https://github.com/nacos-group/nacos-mcp-router/commit/fb49ffb60f728bd027664785278088b7269ee3c3))
|
||||
|
||||
### [1.0.9](https://github.com/nacos-group/nacos-mcp-router/compare/v1.0.6...v1.0.9) (2025-05-14)
|
||||
|
||||
### [1.0.8](https://github.com/nacos-group/nacos-mcp-router/compare/v1.0.6...v1.0.8) (2025-05-14)
|
|
@ -0,0 +1,102 @@
|
|||
# nacos-mcp-router-typescript
|
||||
|
||||
## 项目简介
|
||||
|
||||
`nacos-mcp-router-typescript` 是基于 TypeScript 实现的 Nacos MCP Router。它用于对接 Nacos 配置中心,实现多模型上下文协议(MCP)的服务注册、管理与工具调用,支持通过关键字和任务描述智能检索和调度 MCP 服务。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- **Nacos 配置对接**:通过 HTTP 客户端与 Nacos 服务端交互,支持服务注册、发现与配置管理。
|
||||
- **MCP 服务管理**:集成 MCP 协议,支持服务的注册、检索、安装与工具调用。
|
||||
- **智能检索与调度**:支持通过关键字和任务描述,智能检索可用的 MCP 服务,并自动补全推荐。
|
||||
- **工具注册与调用**:内置 `SearchMcpServer`、`AddMcpServer`、`UseTool` 等工具,便于自动化流程编排。
|
||||
- **日志与监控**:集成 winston 日志系统,支持日志分级与按天轮转。
|
||||
|
||||
## 安装与依赖
|
||||
|
||||
### 环境要求
|
||||
- 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
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
- 配置mcp server
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nacos-mcp-router": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"nacos-mcp-router@latest"
|
||||
],
|
||||
"env": {
|
||||
"NACOS_USERNAME": "nacos",
|
||||
"NACOS_PASSWORD": "nacos_password",
|
||||
"NACOS_SERVER_ADDR": "localhost:8848"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置环境变量
|
||||
|
||||
可通过 `.env` 文件或环境变量配置 Nacos 相关参数:
|
||||
|
||||
- `NACOS_SERVER_ADDR`:Nacos 服务地址(默认:localhost:8848)
|
||||
- `NACOS_USERNAME`:Nacos 用户名(默认:nacos)
|
||||
- `NACOS_PASSWORD`:Nacos nacos_password
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `src/`:核心源码
|
||||
- `index.ts`:项目入口
|
||||
- `router.ts`:MCP 路由与工具注册
|
||||
- `nacos_http_client.ts`:Nacos HTTP 客户端
|
||||
- `mcp_manager.ts`:MCP 服务管理
|
||||
- `router_types.ts`:类型定义与辅助
|
||||
- `simpleSseServer.ts`:简单 SSE 服务
|
||||
- `logger.ts`:日志模块
|
||||
- `test/`:测试用例
|
||||
|
||||
## 主要接口与工具
|
||||
|
||||
- `SearchMcpServer`:根据任务描述和关键字检索 MCP 服务
|
||||
- `AddMcpServer`:安装指定的 MCP 服务
|
||||
- `UseTool`:调用指定 MCP 服务上的工具
|
||||
|
||||
## 许可证
|
||||
|
||||
ISC
|
|
@ -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 服务器,同时保持数据的实时性。
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
{
|
||||
"name": "nacos-mcp-router",
|
||||
"version": "1.2.0",
|
||||
"description": "Nacos MCP Router TypeScript implementation",
|
||||
"main": "dist/stdio.js",
|
||||
"bin": {
|
||||
"nacos-mcp-router": "./dist/stdio.js"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsc && chmod +x dist/stdio.js",
|
||||
"build:watch": "tsc -w",
|
||||
"start": "ts-node src/stdio.ts",
|
||||
"dev-stdio": "ts-node src/stdio.ts",
|
||||
"dev-sse": "ts-node src/simpleSseServer.ts",
|
||||
"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": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"axios": "^1.9.0",
|
||||
"chromadb": "^2.3.0",
|
||||
"chromadb-default-embed": "^2.14.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"hnswlib-node": "^3.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"winston": "^3.17.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-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,109 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 脚本开始
|
||||
echo -e "${GREEN}===== 开始自动化发布流程 ====${NC}"
|
||||
|
||||
# 检查是否有未提交的更改
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo -e "${RED}错误: 有未提交的更改,请先提交或 stash。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取当前分支
|
||||
current_branch=$(git symbolic-ref --short HEAD)
|
||||
if [[ "$current_branch" != "main" && "$current_branch" != "master" ]]; then
|
||||
echo -e "${YELLOW}警告: 当前分支不是 main/master,确定要发布吗?${NC}"
|
||||
read -p "按 Enter 继续,或按 Ctrl+C 取消..."
|
||||
fi
|
||||
|
||||
# 检查npm登录状态
|
||||
npm whoami || { echo -e "${RED}请先登录npm: npm login${NC}"; exit 1; }
|
||||
|
||||
# 检查是否为私有包
|
||||
is_private=$(jq -r '.private' package.json)
|
||||
if [[ "$is_private" == "true" ]]; then
|
||||
echo -e "${RED}错误: package.json 中 private 为 true,无法发布。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 构建项目
|
||||
echo -e "${GREEN}正在构建项目...${NC}"
|
||||
npm run build || { echo -e "${RED}构建失败,请检查构建脚本。${NC}"; exit 1; }
|
||||
|
||||
# 运行测试
|
||||
# echo -e "${GREEN}正在运行测试...${NC}"
|
||||
# npm test || { echo -e "${RED}测试失败,请修复测试用例。${NC}"; exit 1; }
|
||||
|
||||
# 选择版本更新类型
|
||||
echo -e "${GREEN}请选择版本更新类型:${NC}"
|
||||
select update_type in "patch" "minor" "major" "custom"; do
|
||||
case $update_type in
|
||||
patch|minor|major)
|
||||
echo -e "${GREEN}将更新版本: ${update_type}${NC}"
|
||||
break
|
||||
;;
|
||||
custom)
|
||||
read -p "请输入自定义版本号: " custom_version
|
||||
update_type="custom $custom_version"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}无效选择${NC}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 更新版本号
|
||||
echo -e "${GREEN}正在更新版本号...${NC}"
|
||||
if [[ "$update_type" == "custom"* ]]; then
|
||||
custom_version=$(echo $update_type | cut -d' ' -f2)
|
||||
npm version $custom_version -m "chore(release): 发布 v%s"
|
||||
else
|
||||
npm version $update_type -m "chore(release): 发布 v%s"
|
||||
fi
|
||||
|
||||
# 获取新版本号
|
||||
new_version=$(jq -r '.version' package.json)
|
||||
echo -e "${GREEN}新版本号: v$new_version${NC}"
|
||||
|
||||
# 生成变更日志 (需要安装 standard-version)
|
||||
if command -v standard-version &> /dev/null; then
|
||||
echo -e "${GREEN}正在生成变更日志...${NC}"
|
||||
standard-version --skip.bump --skip.tag || { echo -e "${YELLOW}生成变更日志失败,继续发布...${NC}"; }
|
||||
else
|
||||
echo -e "${YELLOW}未安装 standard-version,跳过变更日志生成。${NC}"
|
||||
echo -e "${YELLOW}安装方法: npm install -g standard-version${NC}"
|
||||
fi
|
||||
|
||||
# 提交变更
|
||||
git add package.json package-lock.json
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
git add CHANGELOG.md
|
||||
fi
|
||||
git commit -m "chore(release): 准备发布 v$new_version"
|
||||
|
||||
# 推送到GitHub的pr分支
|
||||
echo -e "${GREEN}正在推送到GitHub的pr分支...${NC}"
|
||||
git push github $current_branch
|
||||
# 创建并推送tag
|
||||
echo -e "${GREEN}正在创建版本标签...${NC}"
|
||||
git tag -a "v$new_version" -m "Release v$new_version"
|
||||
git push github "v$new_version"
|
||||
|
||||
# 发布到npm
|
||||
echo -e "${GREEN}正在发布到npm...${NC}"
|
||||
if [[ "$is_private" == "false" ]]; then
|
||||
npm publish --access public
|
||||
else
|
||||
npm publish
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}===== 发布成功! ====${NC}"
|
||||
echo -e "${GREEN}包版本: v$new_version${NC}"
|
||||
echo -e "${GREEN}npm地址: https://www.npmjs.com/package/$(jq -r '.name' package.json)${NC}"
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
import 'dotenv/config';
|
||||
|
||||
export const config = {
|
||||
nacos: {
|
||||
serverAddr: process.env.NACOS_SERVER_ADDR || 'localhost:8848',
|
||||
username: process.env.NACOS_USERNAME || "nacos",
|
||||
password: process.env.NACOS_PASSWORD || "nacos_password",
|
||||
},
|
||||
};
|
|
@ -0,0 +1,74 @@
|
|||
import winston from 'winston'; // 日志滚动
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import 'winston-daily-rotate-file';
|
||||
import fs from 'fs';
|
||||
|
||||
export class NacosMcpRouteLogger {
|
||||
private static logger: winston.Logger | null = null;
|
||||
|
||||
private static setupLogger(): void {
|
||||
const logDir = path.join(os.homedir(), 'logs', 'nacos_mcp_router');
|
||||
const logFile = path.join(logDir, 'router.log');
|
||||
|
||||
try {
|
||||
// 确保日志目录存在
|
||||
if (fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
// logger.error(`Failed to create log directory: ${logDir}`, err);
|
||||
// throw err;
|
||||
}
|
||||
|
||||
const formatter = winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
winston.format.printf(({ timestamp, level, message }) => {
|
||||
return `${timestamp} | nacos_mcp_router | ${level.padEnd(8)} | ${message}`;
|
||||
})
|
||||
);
|
||||
|
||||
NacosMcpRouteLogger.logger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: formatter,
|
||||
transports: [
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: logFile,
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '10m', // 10MB
|
||||
maxFiles: '5', // 保留5个备份文件
|
||||
zippedArchive: true,
|
||||
format: formatter
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
public static getLogger(): winston.Logger {
|
||||
if (!NacosMcpRouteLogger.logger) {
|
||||
NacosMcpRouteLogger.setupLogger();
|
||||
}
|
||||
return NacosMcpRouteLogger.logger || winston.createLogger();
|
||||
}
|
||||
|
||||
public static info(message: string, ...args: any[]): void {
|
||||
NacosMcpRouteLogger.getLogger().info(message, ...args);
|
||||
}
|
||||
|
||||
public static error(message: string, ...args: any[]): void {
|
||||
NacosMcpRouteLogger.getLogger().error(message, ...args);
|
||||
}
|
||||
|
||||
public static warn(message: string, ...args: any[]): void {
|
||||
NacosMcpRouteLogger.getLogger().warn(message, ...args);
|
||||
}
|
||||
|
||||
public static debug(message: string, ...args: any[]): void {
|
||||
NacosMcpRouteLogger.getLogger().debug(message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const logger = NacosMcpRouteLogger.getLogger();
|
|
@ -0,0 +1,234 @@
|
|||
import { logger } from "./logger";
|
||||
import { NacosHttpClient } from "./nacos_http_client";
|
||||
import { VectorDB, CustomServer, NacosMcpServer } from "./router_types";
|
||||
import { md5 } from "./md5";
|
||||
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export class McpManager {
|
||||
private nacosClient: NacosHttpClient;
|
||||
private vectorDbService: VectorDB;
|
||||
private update_interval: number;
|
||||
private _cache: Map<string, NacosMcpServer> = new Map();
|
||||
private mcp_server_config_version: Map<string, string> = new Map();
|
||||
private healthyMcpServers: Map<string, CustomServer> = new Map(); // 存活的nacos mcp servers
|
||||
|
||||
constructor(
|
||||
nacosClient: NacosHttpClient,
|
||||
vectorDbService: VectorDB,
|
||||
update_interval: number
|
||||
) {
|
||||
this.nacosClient = nacosClient;
|
||||
this.vectorDbService = vectorDbService;
|
||||
this.update_interval = update_interval;
|
||||
this.updateNow();
|
||||
this.asyncUpdater();
|
||||
}
|
||||
|
||||
private async updateNow(): Promise<void> {
|
||||
try {
|
||||
const mcpServers = await this.nacosClient.getMcpServers();
|
||||
logger.debug(`get mcp server list from nacos, size: ${mcpServers.length}`);
|
||||
|
||||
if (mcpServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const docs: string[] = [];
|
||||
const ids: string[] = [];
|
||||
const cache = new Map<string, NacosMcpServer>();
|
||||
|
||||
for (const mcpServer of mcpServers) {
|
||||
let description = mcpServer.getDescription();
|
||||
if (mcpServer.mcpConfigDetail) {
|
||||
description = mcpServer.mcpConfigDetail.getToolDescription();
|
||||
}
|
||||
|
||||
const serverName = mcpServer.getName();
|
||||
cache.set(serverName, mcpServer);
|
||||
|
||||
const md5Str = md5(description);
|
||||
if (
|
||||
!this.mcp_server_config_version.has(serverName) ||
|
||||
this.mcp_server_config_version.get(serverName) !== md5Str
|
||||
) {
|
||||
this.mcp_server_config_version.set(serverName, md5Str);
|
||||
ids.push(serverName);
|
||||
docs.push(description);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`updated mcp server cache, size: ${cache.size}`);
|
||||
const mcpServerNames = Array.from(cache.keys());
|
||||
logger.debug(`updated mcp server names: ${mcpServerNames.join(", ")}`);
|
||||
|
||||
this._cache = cache;
|
||||
|
||||
if (ids.length > 0) {
|
||||
await this.vectorDbService.updateData(
|
||||
ids,
|
||||
docs as any,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to update MCP servers:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async asyncUpdater(): Promise<void> {
|
||||
let retryDelay = this.update_interval;
|
||||
while (true) {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
await this.updateNow();
|
||||
retryDelay = this.update_interval; // 重置间隔
|
||||
} catch (error) {
|
||||
logger.error("更新失败,将在", retryDelay / 1000, "秒后重试", error);
|
||||
retryDelay = Math.min(retryDelay * 2, 60000); // 最大1分钟
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getMcpServer(queryTexts: string, count: number): Promise<NacosMcpServer[]> {
|
||||
try {
|
||||
const result = await this.vectorDbService.query(
|
||||
queryTexts,
|
||||
count,
|
||||
);
|
||||
const ids = result.ids;
|
||||
const mcpServers: NacosMcpServer[] = [];
|
||||
logger.info(`get mcp server from vector db, ids: ${ids}`);
|
||||
|
||||
for (const id of ids) {
|
||||
const mcpServer = this._cache.get(id);
|
||||
if (mcpServer !== undefined) {
|
||||
mcpServers.push(mcpServer);
|
||||
}
|
||||
}
|
||||
return mcpServers;
|
||||
} catch (error) {
|
||||
logger.error("Failed to get MCP servers:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchMcpByKeyword(keyword: string): Promise<NacosMcpServer[]> {
|
||||
const servers: NacosMcpServer[] = [];
|
||||
logger.info(`cache size: ${this._cache.size}`);
|
||||
|
||||
for (const mcpServer of this._cache.values()) {
|
||||
let description = mcpServer.getDescription();
|
||||
if (mcpServer.mcpConfigDetail) {
|
||||
description = mcpServer.mcpConfigDetail.getToolDescription();
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
if (description.includes(keyword)) {
|
||||
// TODO: 如果mcpServer.mcpConfigDetail.getToolDescription()与keyword的模糊匹配优化(description.includes(keyword)是精确匹配)
|
||||
servers.push(mcpServer);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`result mcp servers search by keywords: ${servers.length}`);
|
||||
return servers;
|
||||
}
|
||||
|
||||
async getMcpServerByName(mcpName: string): Promise<NacosMcpServer | undefined> {
|
||||
return this._cache.get(mcpName);
|
||||
}
|
||||
|
||||
async useTool(mcpServerName: string, toolName: string, params: Record<string, any>): Promise<any> {
|
||||
const mcpServer = this.healthyMcpServers.get(mcpServerName)
|
||||
if (!mcpServer) {
|
||||
throw new McpError(ErrorCode.InternalError, `MCP server ${mcpServerName} not found`);
|
||||
}
|
||||
|
||||
if (await mcpServer.healthy()) {
|
||||
const enrichedParams = {
|
||||
...params,
|
||||
};
|
||||
const response = await mcpServer.executeTool(toolName, enrichedParams);
|
||||
return response.content;
|
||||
} else {
|
||||
this.healthyMcpServers.delete(mcpServerName);
|
||||
return "mcp server is not healthy, use search_mcp_server to get mcp servers";
|
||||
}
|
||||
}
|
||||
|
||||
async addMcpServer(mcpServerName: string) {
|
||||
let mcpServer: NacosMcpServer | undefined = await this.nacosClient.getMcpServerByName(mcpServerName);
|
||||
if (!mcpServer) {
|
||||
mcpServer = this._cache.get(mcpServerName);
|
||||
}
|
||||
if (!mcpServer || mcpServer.description === '' || !mcpServer.description) {
|
||||
throw new McpError(ErrorCode.InternalError, `MCP server ${mcpServerName} not found`);
|
||||
}
|
||||
|
||||
const disableTools: Record<string, boolean> = {};
|
||||
const toolMeta = mcpServer.mcpConfigDetail?.toolSpec?.toolsMeta;
|
||||
if (toolMeta) {
|
||||
for (const [toolName, meta] of Object.entries(toolMeta)) {
|
||||
if (!meta.enabled) {
|
||||
disableTools[toolName] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.healthyMcpServers.has(mcpServerName)) {
|
||||
const env: any = process.env || {};
|
||||
if (!mcpServer.agentConfig) {
|
||||
mcpServer.agentConfig = {};
|
||||
}
|
||||
if (!mcpServer.agentConfig.mcpServers || mcpServer.agentConfig.mcpServers === null) {
|
||||
mcpServer.agentConfig.mcpServers = {};
|
||||
}
|
||||
|
||||
const mcpServers = mcpServer.agentConfig.mcpServers;
|
||||
for (const [key, value] of Object.entries(mcpServers)) {
|
||||
const serverConfig = value as Record<string, any>;
|
||||
if (serverConfig.env) {
|
||||
for (const [k, v] of Object.entries(serverConfig.env)) {
|
||||
env[k] = v;
|
||||
}
|
||||
}
|
||||
serverConfig.env = env;
|
||||
if (!serverConfig.headers) {
|
||||
serverConfig.headers = {};
|
||||
}
|
||||
}
|
||||
|
||||
const server = new CustomServer(mcpServerName, mcpServer.agentConfig, mcpServer.mcpConfigDetail?.protocol || 'stdio');
|
||||
// await server.waitForInitialization();
|
||||
await server.start(mcpServerName);
|
||||
// TODO: StreamableHttpTransport 无SessionId
|
||||
if (await server.healthy()) {
|
||||
this.healthyMcpServers.set(mcpServerName, server);
|
||||
}
|
||||
}
|
||||
|
||||
const server = this.healthyMcpServers.get(mcpServerName);
|
||||
if (!server) {
|
||||
throw new McpError(ErrorCode.InternalError, `Failed to initialize MCP server ${mcpServerName}`);
|
||||
}
|
||||
|
||||
const tools = await server.listTools();
|
||||
const toolList: any[] = [];
|
||||
for (const tool of tools) {
|
||||
if (disableTools[tool.name]) {
|
||||
continue;
|
||||
}
|
||||
const dct: Record<string, any> = {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
};
|
||||
toolList.push(dct);
|
||||
}
|
||||
|
||||
await this.nacosClient.updateMcpTools(mcpServerName, tools);
|
||||
|
||||
return `1. ${mcpServerName}安装完成, tool 列表为: ${JSON.stringify(toolList, null, 2)}2. ${mcpServerName}的工具需要通过nacos-mcp-router的UseTool工具代理使用`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
export function md5(str: string): string {
|
||||
return createHash('md5').update(str).digest('hex');
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
import { HierarchicalNSW } from 'hnswlib-node';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { logger } from './logger';
|
||||
|
||||
type Metadata = Record<string, any>;
|
||||
|
||||
let pipeline: any;
|
||||
async function getPipeline() {
|
||||
if (!pipeline) {
|
||||
pipeline = (await import('@xenova/transformers')).pipeline;
|
||||
}
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
export class MemoryVectorDB {
|
||||
private index: HierarchicalNSW;
|
||||
private metadatas: Metadata[] = [];
|
||||
private extractor: any = null;
|
||||
private readonly numDimensions: number;
|
||||
private readonly maxElements: number;
|
||||
private readonly spaceType: 'cosine' | 'l2' | 'ip';
|
||||
private readonly indexFile: string;
|
||||
private readonly metadataFile: string;
|
||||
private readonly modelName: string;
|
||||
|
||||
constructor(options: {
|
||||
numDimensions: number,
|
||||
maxElements?: number,
|
||||
spaceType?: 'cosine' | 'l2' | 'ip',
|
||||
indexFile?: string,
|
||||
metadataFile?: string,
|
||||
modelName?: string,
|
||||
clearOnStart?: boolean
|
||||
}) {
|
||||
this.numDimensions = options.numDimensions;
|
||||
this.maxElements = options.maxElements || 10000;
|
||||
this.spaceType = options.spaceType || 'cosine';
|
||||
this.indexFile = options.indexFile || path.join(os.tmpdir(), 'nacos-mcp-router', 'my_hnsw_index.bin');
|
||||
this.metadataFile = options.metadataFile || path.join(os.tmpdir(), 'nacos-mcp-router', 'my_hnsw_metadata.json');
|
||||
this.modelName = options.modelName || 'Xenova/all-MiniLM-L6-v2';
|
||||
|
||||
if (options.clearOnStart) {
|
||||
if (fs.existsSync(this.indexFile)) {
|
||||
fs.unlinkSync(this.indexFile);
|
||||
logger.info(`[MemoryVectorDB] 已清除索引文件: ${this.indexFile}`);
|
||||
}
|
||||
if (fs.existsSync(this.metadataFile)) {
|
||||
fs.unlinkSync(this.metadataFile);
|
||||
logger.info(`[MemoryVectorDB] 已清除元数据文件: ${this.metadataFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.index = new HierarchicalNSW(this.spaceType, this.numDimensions);
|
||||
|
||||
if (fs.existsSync(this.indexFile) && fs.existsSync(this.metadataFile)) {
|
||||
logger.info(`[MemoryVectorDB] 加载已有索引: ${this.indexFile} 和元数据: ${this.metadataFile}`);
|
||||
this.index.readIndexSync(this.indexFile);
|
||||
this.metadatas = JSON.parse(fs.readFileSync(this.metadataFile, 'utf-8'));
|
||||
} else {
|
||||
logger.info(`[MemoryVectorDB] 初始化新索引, 最大元素数: ${this.maxElements}`);
|
||||
this.index.initIndex(this.maxElements);
|
||||
}
|
||||
}
|
||||
|
||||
private async getEmbedding(text: string): Promise<number[]> {
|
||||
if (!this.extractor) {
|
||||
const _pipeline = await getPipeline();
|
||||
this.extractor = await _pipeline('feature-extraction', this.modelName);
|
||||
}
|
||||
const output = await this.extractor(text, { pooling: 'mean', normalize: true });
|
||||
return Array.from(output.data);
|
||||
}
|
||||
|
||||
public async add(text: string, metadata: Metadata = {}) {
|
||||
logger.info(`[MemoryVectorDB] 添加文本到向量库: ${text.slice(0, 30)}...`);
|
||||
const vector = await this.getEmbedding(text);
|
||||
const label = this.index.getCurrentCount();
|
||||
this.index.addPoint(vector, label);
|
||||
this.metadatas[label] = { ...metadata, text };
|
||||
logger.info(`[MemoryVectorDB] 添加完成,label: ${label}`);
|
||||
}
|
||||
|
||||
public async search(query: string, k: number = 5) {
|
||||
logger.info(`[MemoryVectorDB] 搜索: ${query.slice(0, 30)}...,topK=${k}`);
|
||||
const queryVector = await this.getEmbedding(query);
|
||||
const results = this.index.searchKnn(queryVector, k);
|
||||
logger.info(`[MemoryVectorDB] 搜索完成,返回${results.neighbors.length}条结果`);
|
||||
return results.neighbors.map((label: number, i: number) => ({
|
||||
metadata: this.metadatas[label],
|
||||
label,
|
||||
distance: results.distances[i],
|
||||
similarity: 1 - results.distances[i]
|
||||
}));
|
||||
}
|
||||
|
||||
public save() {
|
||||
// 确保父目录存在
|
||||
const indexDir = path.dirname(this.indexFile);
|
||||
const metadataDir = path.dirname(this.metadataFile);
|
||||
if (!fs.existsSync(indexDir)) {
|
||||
fs.mkdirSync(indexDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(metadataDir)) {
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
}
|
||||
this.index.writeIndexSync(this.indexFile);
|
||||
fs.writeFileSync(this.metadataFile, JSON.stringify(this.metadatas, null, 2));
|
||||
logger.info(`[MemoryVectorDB] 索引和元数据已保存到: ${this.indexFile}, ${this.metadataFile}`);
|
||||
}
|
||||
|
||||
public load() {
|
||||
if (fs.existsSync(this.indexFile) && fs.existsSync(this.metadataFile)) {
|
||||
this.index.readIndexSync(this.indexFile);
|
||||
this.metadatas = JSON.parse(fs.readFileSync(this.metadataFile, 'utf-8'));
|
||||
logger.info(`[MemoryVectorDB] 已加载索引和元数据`);
|
||||
} else {
|
||||
logger.info(`[MemoryVectorDB] 未找到索引或元数据文件,无法加载`);
|
||||
}
|
||||
}
|
||||
|
||||
public getCount() {
|
||||
return this.index.getCurrentCount();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
import axios, { AxiosInstance } from 'axios';
|
||||
import { NacosMcpServer } from './router_types';
|
||||
import { logger } from './logger';
|
||||
import { NacosMcpServerConfigImpl, Tool } from './nacos_mcp_server_config';
|
||||
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export class NacosHttpClient {
|
||||
private readonly nacosAddr: string;
|
||||
private readonly userName: string;
|
||||
private readonly passwd: string;
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor(nacosAddr: string, userName: string, passwd: string) {
|
||||
if (!nacosAddr) {
|
||||
throw new Error('nacosAddr cannot be an empty string');
|
||||
}
|
||||
if (!userName) {
|
||||
throw new Error('userName cannot be an empty string');
|
||||
}
|
||||
if (!passwd) {
|
||||
throw new Error('passwd cannot be an empty string');
|
||||
}
|
||||
|
||||
this.nacosAddr = nacosAddr;
|
||||
this.userName = userName;
|
||||
this.passwd = passwd;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: `http://${this.nacosAddr}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'charset': 'utf-8',
|
||||
'userName': this.userName,
|
||||
'password': this.passwd
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async isReady(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
this.client.get('/nacos/v3/admin/ai/mcp/list').then((response) => {
|
||||
if (response.status === 200) {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getMcpServerByName(name: string): Promise<NacosMcpServer> {
|
||||
const url = `/nacos/v3/admin/ai/mcp?mcpName=${name}`;
|
||||
const mcpServer = new NacosMcpServer(name, '', {});
|
||||
|
||||
try {
|
||||
const response = await this.client.get(url);
|
||||
if (response.status === 200) {
|
||||
const data = response.data.data;
|
||||
const config = NacosMcpServerConfigImpl.fromDict(data);
|
||||
const server = new NacosMcpServer(
|
||||
config.name,
|
||||
config.description || '',
|
||||
config.localServerConfig
|
||||
);
|
||||
server.mcpConfigDetail = config;
|
||||
|
||||
if (config.protocol !== 'stdio' && config.backendEndpoints.length > 0) {
|
||||
const endpoint = config.backendEndpoints[0];
|
||||
const httpSchema = endpoint.port === 443 ? 'https' : 'http';
|
||||
let url = `${httpSchema}://${endpoint.address}:${endpoint.port}${config.remoteServerConfig.exportPath}`;
|
||||
|
||||
if (!config.remoteServerConfig.exportPath.startsWith('/')) {
|
||||
url = `${httpSchema}://${endpoint.address}:${endpoint.port}/${config.remoteServerConfig.exportPath}`;
|
||||
}
|
||||
|
||||
if (!server.agentConfig.mcpServers) {
|
||||
server.agentConfig.mcpServers = {};
|
||||
}
|
||||
|
||||
server.agentConfig.mcpServers[server.name] = {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
url: url
|
||||
};
|
||||
}
|
||||
return server;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warning(`failed to get mcp server ${name}, response: ${error}`);
|
||||
}
|
||||
return mcpServer;
|
||||
}
|
||||
|
||||
async getMcpServers(): Promise<NacosMcpServer[]> {
|
||||
const mcpServers: NacosMcpServer[] = [];
|
||||
try {
|
||||
const pageSize = 100;
|
||||
const pageNo = 1;
|
||||
const url = `/nacos/v3/admin/ai/mcp/list?pageNo=${pageNo}&pageSize=${pageSize}`;
|
||||
|
||||
const response = await this.client.get(url);
|
||||
if (response.status !== 200) {
|
||||
logger.warning(`failed to get mcp server list, url ${url}, response: ${response.data}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const mcpServerDict of response.data.data.pageItems) {
|
||||
if (mcpServerDict.enabled) {
|
||||
const mcpName = mcpServerDict.name;
|
||||
const mcpServer = await this.getMcpServerByName(mcpName);
|
||||
|
||||
if (mcpServer.description) {
|
||||
mcpServers.push(mcpServer);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error getting mcp servers:', error);
|
||||
throw new McpError(ErrorCode.InternalError, `Failed to get mcp servers: ${error}`)
|
||||
}
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
async updateMcpTools(mcpName: string, tools: Tool[]): Promise<boolean> {
|
||||
try {
|
||||
const url = `/nacos/v3/admin/ai/mcp?mcpName=${mcpName}`;
|
||||
const response = await this.client.get(url);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.data.data;
|
||||
const toolList = tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
}));
|
||||
|
||||
const endpointSpecification: Record<string, any> = {};
|
||||
if (data.protocol !== 'stdio') {
|
||||
endpointSpecification.data = data.remoteServerConfig.serviceRef;
|
||||
endpointSpecification.type = 'REF';
|
||||
}
|
||||
|
||||
if (!data.toolSpec) {
|
||||
data.toolSpec = {};
|
||||
}
|
||||
|
||||
data.toolSpec.tools = toolList;
|
||||
const params: Record<string, any> = {
|
||||
mcpName: mcpName
|
||||
};
|
||||
|
||||
const toolSpecification = data.toolSpec;
|
||||
delete data.toolSpec;
|
||||
delete data.backendEndpoints;
|
||||
|
||||
params.serverSpecification = JSON.stringify(data);
|
||||
params.endpointSpecification = JSON.stringify(endpointSpecification);
|
||||
params.toolSpecification = JSON.stringify(toolSpecification);
|
||||
|
||||
logger.info(`update mcp tools, params ${JSON.stringify(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'
|
||||
}
|
||||
});
|
||||
|
||||
if (updateResponse.status === 200) {
|
||||
return true;
|
||||
} else {
|
||||
logger.warning(`failed to update mcp tools list, caused: ${updateResponse.data}`);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.warning(`failed to update mcp tools list, caused: ${response.data}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating mcp tools:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,293 @@
|
|||
import { logger } from './logger';
|
||||
|
||||
export interface InputProperty {
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class InputPropertyImpl implements InputProperty {
|
||||
type: string;
|
||||
description: string;
|
||||
|
||||
constructor(type: string, description: string) {
|
||||
this.type = type;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any> | null): InputProperty {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return new InputPropertyImpl('', '');
|
||||
}
|
||||
return new InputPropertyImpl(data.type, data.description);
|
||||
}
|
||||
}
|
||||
|
||||
export interface InputSchema {
|
||||
type: string;
|
||||
properties: Record<string, InputProperty>;
|
||||
}
|
||||
|
||||
export class InputSchemaImpl implements InputSchema {
|
||||
type: string;
|
||||
properties: Record<string, InputProperty>;
|
||||
|
||||
constructor(type: string, properties: Record<string, InputProperty>) {
|
||||
this.type = type;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any> | null): InputSchema {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return new InputSchemaImpl('', {});
|
||||
}
|
||||
const properties: Record<string, InputProperty> = {};
|
||||
for (const [key, value] of Object.entries(data.properties)) {
|
||||
properties[key] = InputPropertyImpl.fromDict(value as Record<string, any>);
|
||||
}
|
||||
return new InputSchemaImpl(data.type, properties);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: InputSchema;
|
||||
}
|
||||
|
||||
export class ToolImpl implements Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: InputSchema;
|
||||
|
||||
constructor(name: string, description: string, inputSchema: InputSchema) {
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.inputSchema = inputSchema;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any>): Tool {
|
||||
return new ToolImpl(
|
||||
data.name,
|
||||
data.description,
|
||||
InputSchemaImpl.fromDict(data.inputSchema)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolMeta {
|
||||
invokeContext: Record<string, any>;
|
||||
enabled: boolean;
|
||||
templates: Record<string, string>;
|
||||
}
|
||||
|
||||
export class ToolMetaImpl implements ToolMeta {
|
||||
invokeContext: Record<string, any>;
|
||||
enabled: boolean;
|
||||
templates: Record<string, string>;
|
||||
|
||||
constructor(invokeContext: Record<string, any>, enabled: boolean, templates: Record<string, string>) {
|
||||
this.invokeContext = invokeContext;
|
||||
this.enabled = enabled;
|
||||
this.templates = templates;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any>): ToolMeta {
|
||||
return new ToolMetaImpl(
|
||||
data.invokeContext || {},
|
||||
data.enabled ?? true,
|
||||
data.templates || {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolSpec {
|
||||
tools: Tool[];
|
||||
toolsMeta: Record<string, ToolMeta>;
|
||||
}
|
||||
|
||||
export class ToolSpecImpl implements ToolSpec {
|
||||
tools: Tool[];
|
||||
toolsMeta: Record<string, ToolMeta>;
|
||||
|
||||
constructor(tools: Tool[], toolsMeta: Record<string, ToolMeta>) {
|
||||
this.tools = tools;
|
||||
this.toolsMeta = toolsMeta;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any>): ToolSpec {
|
||||
return new ToolSpecImpl(
|
||||
(data.tools || []).map((t: any) => ToolImpl.fromDict(t)),
|
||||
Object.fromEntries(
|
||||
Object.entries(data.toolsMeta || {}).map(([k, v]) => [k, ToolMetaImpl.fromDict(v as Record<string, any>)])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceRef {
|
||||
namespaceId: string;
|
||||
groupName: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
export class ServiceRefImpl implements ServiceRef {
|
||||
namespaceId: string;
|
||||
groupName: string;
|
||||
serviceName: string;
|
||||
|
||||
constructor(namespaceId: string, groupName: string, serviceName: string) {
|
||||
this.namespaceId = namespaceId;
|
||||
this.groupName = groupName;
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any> | null): ServiceRef {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return new ServiceRefImpl('', '', '');
|
||||
}
|
||||
return new ServiceRefImpl(
|
||||
data.namespaceId,
|
||||
data.groupName,
|
||||
data.serviceName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoteServerConfig {
|
||||
serviceRef: ServiceRef;
|
||||
exportPath: string;
|
||||
credentials: Record<string, any>;
|
||||
}
|
||||
|
||||
export class RemoteServerConfigImpl implements RemoteServerConfig {
|
||||
serviceRef: ServiceRef;
|
||||
exportPath: string;
|
||||
credentials: Record<string, any>;
|
||||
|
||||
constructor(serviceRef: ServiceRef, exportPath: string, credentials: Record<string, any>) {
|
||||
this.serviceRef = serviceRef;
|
||||
this.exportPath = exportPath;
|
||||
this.credentials = credentials;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any> | null): RemoteServerConfig {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return new RemoteServerConfigImpl(ServiceRefImpl.fromDict({}), '', {});
|
||||
}
|
||||
return new RemoteServerConfigImpl(
|
||||
ServiceRefImpl.fromDict(data.serviceRef),
|
||||
data.exportPath,
|
||||
data.credentials || {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BackendEndpoint {
|
||||
address: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export class BackendEndpointImpl implements BackendEndpoint {
|
||||
address: string;
|
||||
port: number;
|
||||
|
||||
constructor(address: string, port: number) {
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any> | null): BackendEndpoint {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return new BackendEndpointImpl('', -1);
|
||||
}
|
||||
return new BackendEndpointImpl(data.address, data.port);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export class NacosMcpServerConfigImpl implements NacosMcpServerConfig {
|
||||
name: string;
|
||||
protocol: string;
|
||||
description: string | null;
|
||||
version: string;
|
||||
remoteServerConfig: RemoteServerConfig;
|
||||
localServerConfig: Record<string, any>;
|
||||
enabled: boolean;
|
||||
capabilities: string[];
|
||||
backendEndpoints: BackendEndpoint[];
|
||||
toolSpec: ToolSpec;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
protocol: string,
|
||||
description: string | null,
|
||||
version: string,
|
||||
remoteServerConfig: RemoteServerConfig,
|
||||
localServerConfig: Record<string, any>,
|
||||
enabled: boolean,
|
||||
capabilities: string[],
|
||||
backendEndpoints: BackendEndpoint[],
|
||||
toolSpec: ToolSpec
|
||||
) {
|
||||
this.name = name;
|
||||
this.protocol = protocol;
|
||||
this.description = description;
|
||||
this.version = version;
|
||||
this.remoteServerConfig = remoteServerConfig;
|
||||
this.localServerConfig = localServerConfig;
|
||||
this.enabled = enabled;
|
||||
this.capabilities = capabilities;
|
||||
this.backendEndpoints = backendEndpoints;
|
||||
this.toolSpec = toolSpec;
|
||||
}
|
||||
|
||||
static fromDict(data: Record<string, any>): NacosMcpServerConfig {
|
||||
const toolSpecData = data.toolSpec;
|
||||
const backendEndpointsData = data.backendEndpoints;
|
||||
|
||||
try {
|
||||
return new NacosMcpServerConfigImpl(
|
||||
data.name,
|
||||
data.protocol,
|
||||
data.description,
|
||||
data.version,
|
||||
RemoteServerConfigImpl.fromDict(data.remoteServerConfig),
|
||||
data.localServerConfig || {},
|
||||
data.enabled ?? true,
|
||||
data.capabilities || [],
|
||||
backendEndpointsData ? backendEndpointsData.map((e: any) => BackendEndpointImpl.fromDict(e)) : [],
|
||||
toolSpecData ? ToolSpecImpl.fromDict(toolSpecData) : new ToolSpecImpl([], {})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(`failed to parse NacosMcpServerConfig from data: ${JSON.stringify(data)}`, error);
|
||||
throw new Error('failed to parse NacosMcpServerConfig from data');
|
||||
}
|
||||
}
|
||||
|
||||
static fromString(string: string): NacosMcpServerConfig {
|
||||
return NacosMcpServerConfigImpl.fromDict(JSON.parse(string));
|
||||
}
|
||||
|
||||
getToolDescription(): string {
|
||||
let des = this.description || '';
|
||||
for (const tool of this.toolSpec.tools) {
|
||||
if (tool.description) {
|
||||
des += '\n' + tool.description;
|
||||
}
|
||||
}
|
||||
return des;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,244 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { NacosHttpClient } from "./nacos_http_client";
|
||||
import { McpManager } from "./mcp_manager";
|
||||
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";
|
||||
|
||||
export interface RouterConfig {
|
||||
nacos: {
|
||||
serverAddr: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
mcp: {
|
||||
host: string;
|
||||
port: number;
|
||||
authToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
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) {
|
||||
const {serverAddr, username, password} = config.nacos;
|
||||
this.nacosClient = new NacosHttpClient(serverAddr, username, password);
|
||||
}
|
||||
|
||||
private async registerMcpTools() {
|
||||
if (!this.mcpServer) {
|
||||
throw new McpError(ErrorCode.InternalError, "MCP server not initialized");
|
||||
}
|
||||
try {
|
||||
this.mcpServer.tool(
|
||||
"SearchMcpServer",
|
||||
`根据任务描述及关键字搜索mcp server,制定完成任务的步骤;Args:task_description: 用户任务描述,使用中文;key_words: 字符串数组,用户任务关键字,使用中文,可以为多个,最多为2个`,
|
||||
{ taskDescription: z.string(), keyWords: z.string().array().nonempty({
|
||||
message: "Can't be empty!",
|
||||
}).max(2) },
|
||||
async ({ taskDescription, keyWords }) => {
|
||||
try {
|
||||
const mcpServers1: NacosMcpServer[] = await this.searchMcpServer(taskDescription,keyWords);
|
||||
|
||||
// 构建结果
|
||||
const result: Record<string, { name: string; description: string }> = {};
|
||||
for (const mcpServer of mcpServers1) {
|
||||
result[mcpServer.getName()] = {
|
||||
name: mcpServer.getName(),
|
||||
description: mcpServer.getDescription()
|
||||
};
|
||||
}
|
||||
|
||||
const content = JSON.stringify(result, null, 2);
|
||||
const jsonString = `## 获取${taskDescription}的步骤如下:
|
||||
### 1. 当前可用的mcp server列表为:
|
||||
${content}
|
||||
### 2. 从当前可用的mcp server列表中选择你需要的mcp server调AddMcpServer工具安装mcp server`;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: jsonString
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`failed to search_mcp_server: ${taskDescription}`, error);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `failed to search mcp server for ${taskDescription}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
this.mcpServer.tool(
|
||||
"UseTool",
|
||||
'使用指定MCP服务器上的工具。需要先通过AddMcpServer安装MCP服务器,然后才能使用其工具。',
|
||||
{ mcpServerName: z.string(), toolName: z.string(), params: z.record(z.string(), z.any()) },
|
||||
async ({ mcpServerName, toolName, params }) => {
|
||||
try {
|
||||
const result = await this.mcpManager!.useTool(mcpServerName, toolName, params);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(result)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to use tool ${toolName} from server ${mcpServerName}:`, error);
|
||||
// throw new McpError(ErrorCode.InternalError, `Failed to use tool ${toolName} from server ${mcpServerName}`);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Failed to use tool ${toolName} from server ${mcpServerName}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
this.mcpServer.tool(
|
||||
"AddMcpServer",
|
||||
`安装指定的mcp server, return mcp server安装结果`,
|
||||
{ mcpServerName: z.string() },
|
||||
async ({ mcpServerName }) => {
|
||||
try {
|
||||
const result = await this.mcpManager!.addMcpServer(mcpServerName);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(result)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add mcp server ${mcpServerName}:`, error);
|
||||
throw new McpError(ErrorCode.InternalError, `Failed to add mcp server ${mcpServerName}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to register MCP tools:", error);
|
||||
throw new McpError(ErrorCode.InternalError, "Failed to register MCP tools:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
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();
|
||||
await this.vectorDB.isReady();
|
||||
logger.info(`vectorDB is ready, collectionId: ${this.vectorDB._collectionId}`);
|
||||
}
|
||||
const isReady = await this.nacosClient.isReady();
|
||||
if (!isReady) {
|
||||
throw new McpError(ErrorCode.InternalError, "Nacos client is not ready or not connected, please check the nacos server conifg");
|
||||
}
|
||||
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({
|
||||
name: MCP_SERVER_NAME,
|
||||
version: "1.0.0",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`registerMcpTools`);
|
||||
this.registerMcpTools();
|
||||
if (replaceTransport) {
|
||||
this.mcpServer!.connect(replaceTransport);
|
||||
} else {
|
||||
const transport = new StdioServerTransport();
|
||||
logger.info(`transport: ${transport}`);
|
||||
await this.mcpServer!.connect(transport);
|
||||
logger.info(`mcpServer is connected, transport: ${JSON.stringify(transport)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to start Nacos MCP Router:", error);
|
||||
// throw error;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,358 @@
|
|||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { logger } from './logger';
|
||||
import { MemoryVectorDB } from './memory_vector';
|
||||
import { NacosMcpServerConfigImpl } from './nacos_mcp_server_config';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { CallToolResultSchema, ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
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,
|
||||
env: config.env
|
||||
});
|
||||
}
|
||||
|
||||
function _sseTransportContext(config: Record<string, any>): SSEClientTransport {
|
||||
return new SSEClientTransport(new URL(config.url), {
|
||||
// headers: config.headers,
|
||||
// timeout: 10
|
||||
});
|
||||
}
|
||||
|
||||
function _streamableHttpTransportContext(config: Record<string, any>): StreamableHTTPClientTransport {
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
sessionId: config.sessionId
|
||||
});
|
||||
}
|
||||
|
||||
export class CustomServer {
|
||||
private name: string;
|
||||
private config: Record<string, any>;
|
||||
private _transportContextFactory: (config: Record<string, any>) => Transport;
|
||||
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;
|
||||
this.protocol = protocol;
|
||||
|
||||
logger.info(`mcp server config: ${JSON.stringify(config)}, protocol: ${protocol}`);
|
||||
|
||||
this._transportContextFactory = _stdioTransportContext;
|
||||
if (protocol === 'mcp-sse') {
|
||||
this._transportContextFactory = _sseTransportContext;
|
||||
} else if (protocol === 'mcp-streamble') {
|
||||
this._transportContextFactory = _streamableHttpTransportContext;
|
||||
}
|
||||
|
||||
// 全局保持一个client 切换连接?
|
||||
// this.client = new Client({
|
||||
// name: this.name,
|
||||
// version: '1.0.0'
|
||||
// })
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析服务器键,处理别名和错误情况
|
||||
* @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
|
||||
this.client = new Client({
|
||||
name: this.name,
|
||||
version: '1.0.0'
|
||||
});
|
||||
this.client.onerror = (error) => {
|
||||
logger.error('\x1b[31mClient error:', error, '\x1b[0m');
|
||||
}
|
||||
|
||||
// Set up notification handlers
|
||||
this.client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
|
||||
notificationCount++;
|
||||
logger.info(`Notification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`);
|
||||
// Re-display the prompt
|
||||
// process.stdout.write('> ');
|
||||
});
|
||||
|
||||
this.client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => {
|
||||
logger.info(`Resource list changed notification received!`);
|
||||
try {
|
||||
if (!this.client) {
|
||||
logger.error('Client disconnected, cannot fetch resources');
|
||||
return;
|
||||
}
|
||||
const resourcesResult = await this.client.request({
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
}, ListResourcesResultSchema);
|
||||
logger.info('Available resources count:', resourcesResult.resources.length);
|
||||
} catch {
|
||||
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[this.selectedServerKey!],
|
||||
sessionId: this.sessionId // StreamableHttpTransport 需要Client保存sessionId
|
||||
});
|
||||
} else {
|
||||
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由哪里初始化
|
||||
if (transport instanceof StreamableHTTPClientTransport) {
|
||||
this.sessionId = transport.sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
async healthy(): Promise<boolean> {
|
||||
try {
|
||||
logger.info(`check health, client: ${this.client}`);
|
||||
// 检查客户端是否已初始化
|
||||
if (!this.client) {
|
||||
return false;
|
||||
}
|
||||
const result = await this.client?.ping();
|
||||
logger.info(`check health, result: ${JSON.stringify(result)}`);
|
||||
return true;
|
||||
|
||||
// 检查 transport 是否存在
|
||||
// const transport = this.client.transport;
|
||||
// if (!transport) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// logger.info(`check health, transport: ${JSON.stringify(transport)}`);
|
||||
|
||||
// // 检查 transport 类型并进行相应的健康检查
|
||||
// if (transport instanceof StdioClientTransport) {
|
||||
// // 对于 Stdio transport,检查进程是否仍在运行
|
||||
// return transport['_process']?.killed === false;
|
||||
// } else if (transport instanceof StreamableHTTPClientTransport) {
|
||||
// // 对于 StreamableHTTPClientTransport,检查 sessionId 是否存在
|
||||
// return transport.sessionId !== undefined;
|
||||
// } else if (transport instanceof SSEClientTransport) {
|
||||
// // 对于其他类型的 transport,使用通用检查
|
||||
// const isHealthy = !!transport['_endpoint']?.searchParams.get('sessionId');
|
||||
// logger.info(`transport: ${transport['_endpoint']?.searchParams.get('sessionId')}, isHealthy: ${isHealthy}`);
|
||||
// return isHealthy;
|
||||
// }
|
||||
// return false;
|
||||
} catch (e) {
|
||||
logger.error(`Error checking health for server ${this.name}:`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// async requestForShutdown(): Promise<void> {
|
||||
// // this._shutdownEvent = Promise.resolve();
|
||||
// await this.client.close();
|
||||
// }
|
||||
|
||||
async listTools(): Promise<any[]> {
|
||||
if (!this.client || !(await this.healthy())) {
|
||||
throw new Error(`Server ${this.name} is not initialized`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the client.listTools() method which is a convenience wrapper
|
||||
// around client.request() for the tools/list endpoint
|
||||
const toolsResult = await this.client.listTools();
|
||||
return toolsResult.tools;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to list tools for server ${this.name}:`, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async executeTool(
|
||||
toolName: string,
|
||||
params: Record<string, any>,
|
||||
retries: number = 2,
|
||||
delay: number = 1.0
|
||||
): Promise<any> {
|
||||
if (!this.client || !(await this.healthy())) {
|
||||
throw new Error(`Server ${this.name} not initialized`);
|
||||
}
|
||||
|
||||
const executeWithRetry = async (attempt: number): Promise<any> => {
|
||||
try {
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 10000));
|
||||
|
||||
const result = await Promise.race([timeoutPromise, this.client!.request({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: params
|
||||
}
|
||||
}, CallToolResultSchema)]);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (attempt >= retries) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Tool execution failed for ${toolName} on server ${this.name}, attempt ${attempt}/${retries}`,
|
||||
e
|
||||
);
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, delay * 1000));
|
||||
|
||||
// Try to reconnect if needed
|
||||
if (!(await this.healthy())) {
|
||||
logger.info(`Reconnecting to server ${this.name} before retry`);
|
||||
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);
|
||||
}
|
||||
|
||||
// Recursive retry
|
||||
return executeWithRetry(attempt + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return executeWithRetry(1);
|
||||
}
|
||||
|
||||
// async cleanup(): Promise<void> {
|
||||
// await this._cleanupLock;
|
||||
// try {
|
||||
// await this.exitStack.aclose();
|
||||
// this.session = null;
|
||||
// this.stdioContext = null;
|
||||
// } catch (e) {
|
||||
// console.error(`Error during cleanup of server ${this.name}:`, e);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
export class NacosMcpServer {
|
||||
name: string;
|
||||
description: string;
|
||||
mcpConfigDetail: NacosMcpServerConfigImpl | null;
|
||||
agentConfig: Record<string, any>;
|
||||
|
||||
constructor(name: string, description: string, agentConfig: Record<string, any>) {
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.agentConfig = agentConfig;
|
||||
this.mcpConfigDetail = null;
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return this.description;
|
||||
}
|
||||
|
||||
getAgentConfig(): Record<string, any> {
|
||||
return this.agentConfig;
|
||||
}
|
||||
|
||||
toDict(): Record<string, any> {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
agentConfig: this.getAgentConfig()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// MemoryVectorDb 兼容接口实现
|
||||
export class VectorDB {
|
||||
private db: MemoryVectorDB;
|
||||
public _collectionId: string;
|
||||
|
||||
constructor() {
|
||||
this._collectionId = `nacos_mcp_router-collection-${process.pid}`;
|
||||
this.db = new MemoryVectorDB({ numDimensions: 384, clearOnStart: true });
|
||||
}
|
||||
|
||||
public async start() {
|
||||
// MemoryVectorDB 初始化已在构造函数完成
|
||||
// 可根据需要预加载或其他操作
|
||||
return;
|
||||
}
|
||||
|
||||
public async isReady(): Promise<boolean> {
|
||||
// MemoryVectorDB 无需等待服务启动,直接返回 true
|
||||
return true;
|
||||
}
|
||||
|
||||
async getCollectionCount(): Promise<number> {
|
||||
return this.db.getCount();
|
||||
}
|
||||
|
||||
updateData(
|
||||
ids: string[],
|
||||
documents?: string[],
|
||||
metadatas?: Record<string, any>[]
|
||||
): void {
|
||||
if (!documents) return;
|
||||
documents.forEach((doc, i) => {
|
||||
this.db.add(doc, { id: ids[i], ...(metadatas ? metadatas[i] : {}) });
|
||||
});
|
||||
this.db.save();
|
||||
}
|
||||
|
||||
async query(query: string, count: number): Promise<any> {
|
||||
const results = await this.db.search(query, count);
|
||||
return {
|
||||
ids: [results.map(r => r.metadata.id)],
|
||||
documents: [results.map(r => r.metadata.text)],
|
||||
metadatas: [results.map(r => r.metadata)],
|
||||
distances: [results.map(r => r.distance)],
|
||||
included: []
|
||||
};
|
||||
}
|
||||
|
||||
async get(ids: string[]): Promise<any> {
|
||||
// 简单实现:根据 id 查找元数据
|
||||
const all = this.db['metadatas'] || [];
|
||||
const found = all.filter((m: any) => ids.includes(m.id));
|
||||
return {
|
||||
ids,
|
||||
documents: found.map((m: any) => m.text),
|
||||
metadatas: found,
|
||||
included: []
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { RouterConfig } from './router';
|
||||
import { Router } from './router';
|
||||
import { config } from './config';
|
||||
import { logger } from './logger';
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Store transports by session ID
|
||||
const transports: Record<string, SSEServerTransport> = {};
|
||||
|
||||
// SSE endpoint for establishing the stream
|
||||
app.get('/mcp', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Create a new SSE transport for the client
|
||||
// The endpoint for POST messages is '/messages'
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
|
||||
// Store the transport by session ID
|
||||
const sessionId = transport.sessionId;
|
||||
transports[sessionId] = transport;
|
||||
|
||||
// Set up onclose handler to clean up transport when closed
|
||||
transport.onclose = () => {
|
||||
delete transports[sessionId];
|
||||
};
|
||||
|
||||
const router = new Router(config as RouterConfig);
|
||||
await router.start(transport);
|
||||
} catch (error) {
|
||||
console.error('Error establishing SSE stream:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error establishing SSE stream');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Messages endpoint for receiving client JSON-RPC requests
|
||||
app.post('/messages', async (req: Request, res: Response) => {
|
||||
logger.info('Received POST request to /messages');
|
||||
|
||||
// Extract session ID from URL query parameter
|
||||
// In the SSE protocol, this is added by the client based on the endpoint event
|
||||
const sessionId = req.query.sessionId as string | undefined;
|
||||
|
||||
if (!sessionId) {
|
||||
logger.error('No session ID provided in request URL');
|
||||
res.status(400).send('Missing sessionId parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
if (!transport) {
|
||||
logger.error(`No active transport found for session ID: ${sessionId}`);
|
||||
res.status(404).send('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle the POST message with the transport
|
||||
await transport.handlePostMessage(req, res, req.body);
|
||||
} catch (error) {
|
||||
logger.error('Error handling request:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error handling request');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const PORT = 3001;
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Handle server shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('Shutting down server...');
|
||||
|
||||
// Close all active transports to properly clean up resources
|
||||
for (const sessionId in transports) {
|
||||
try {
|
||||
logger.info(`Closing transport for session ${sessionId}`);
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
logger.error(`Error closing transport for session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
logger.info('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
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);
|
||||
// router.start();
|
||||
logger.info(`nacos mcp router start`);
|
||||
await router.start();
|
||||
logger.info('Nacos MCP Router started successfully');
|
||||
} catch (error) {
|
||||
const msg = formatReason(error);
|
||||
logger.error(`Failed to start Nacos MCP Router: ${msg}`);
|
||||
setTimeout(() => process.exit(1), 100);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
|
@ -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,30 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const nacosAddr = 'localhost:8848';
|
||||
const userName = 'nacos';
|
||||
const passwd = 'nacos_password';
|
||||
|
||||
|
||||
async function main() {
|
||||
let config = {
|
||||
method: 'get',
|
||||
// maxBodyLength: Infinity,
|
||||
url: `http://${nacosAddr}/nacos/v3/admin/ai/mcp/list?pageNo=1&pageSize=100`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'charset': 'utf-8',
|
||||
'userName': userName,
|
||||
'password': passwd
|
||||
}
|
||||
};
|
||||
|
||||
axios.request(config)
|
||||
.then((response) => {
|
||||
console.log(JSON.stringify(response.data));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./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", "test"]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["test/**/*.ts"]
|
||||
}
|
Loading…
Reference in New Issue