提交qiming-mcp-proxy
This commit is contained in:
98
qiming-mcp-proxy/mcp-proxy/Cargo.toml
Normal file
98
qiming-mcp-proxy/mcp-proxy/Cargo.toml
Normal file
@@ -0,0 +1,98 @@
|
||||
[package]
|
||||
name = "mcp-stdio-proxy"
|
||||
# 主入口版本,与 workspace 对外发布版本一致
|
||||
version = "0.1.68"
|
||||
edition = "2024"
|
||||
authors = ["nuwax-ai"]
|
||||
description = "MCP (Model Context Protocol) proxy server and CLI tool for protocol conversion and remote service access"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/nuwax-ai/mcp-proxy"
|
||||
keywords = ["mcp", "proxy", "protocol", "sse", "cli"]
|
||||
categories = ["web-programming", "command-line-utilities", "network-programming"]
|
||||
readme = "README.md"
|
||||
exclude = [
|
||||
"logs/",
|
||||
"tmp/",
|
||||
"*.log",
|
||||
"target/",
|
||||
"benches/",
|
||||
]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
# 共享类型和工具
|
||||
mcp-common = { version = "0.1.28", path = "../mcp-common", features = ["otlp"] }
|
||||
|
||||
# 国际化支持
|
||||
rust-i18n = { workspace = true }
|
||||
# 新增:协议特定的代理库
|
||||
mcp-streamable-proxy = { version = "0.1.28", path = "../mcp-streamable-proxy" }
|
||||
mcp-sse-proxy = { version = "0.1.28", path = "../mcp-sse-proxy" }
|
||||
axum = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "net", "rt", "rt-multi-thread", "signal", "io-util"] }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tracing-appender = { workspace = true }
|
||||
tracing-opentelemetry = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
opentelemetry-jaeger = { workspace = true }
|
||||
opentelemetry-semantic-conventions = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true }
|
||||
hostname = { workspace = true }
|
||||
log = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
http = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
backtrace = { workspace = true }
|
||||
tracing-futures = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
# 执行js/ts/python代码,通过 uv/deno 命令方式执行
|
||||
run_code_rmcp = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
async-trait.workspace = true
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
moka = { workspace = true, features = ["future"] }
|
||||
|
||||
# 进程组管理(跨平台子进程清理)
|
||||
# 添加 process-group 特性用于 Unix 平台的进程组管理
|
||||
# 添加 job-object 特性用于 Windows 平台的进程树管理
|
||||
process-wrap = { version = "9.0", features = ["tokio1", "process-group", "job-object"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_System_Threading"] }
|
||||
|
||||
[[bin]]
|
||||
name = "mcp-proxy"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.6"
|
||||
env_logger = "0.11"
|
||||
futures-util = "0.3.31"
|
||||
|
||||
|
||||
[[bench]]
|
||||
name = "run_code_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "run_code_advanced_bench"
|
||||
harness = false
|
||||
22
qiming-mcp-proxy/mcp-proxy/LICENSE-MIT
Normal file
22
qiming-mcp-proxy/mcp-proxy/LICENSE-MIT
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 nuwax-ai
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
388
qiming-mcp-proxy/mcp-proxy/README.md
Normal file
388
qiming-mcp-proxy/mcp-proxy/README.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# mcp-stdio-proxy
|
||||
|
||||
**[English](README.md)** | **[简体中文](README_zh-CN.md)**
|
||||
|
||||
---
|
||||
|
||||
# mcp-stdio-proxy
|
||||
|
||||
MCP (Model Context Protocol) client proxy tool that converts remote MCP services (SSE/Streamable HTTP) to local stdio interface.
|
||||
|
||||
> **Package Name**: `mcp-stdio-proxy`
|
||||
> **Command Name**: `mcp-proxy` (shorter)
|
||||
|
||||
## Core Features
|
||||
|
||||
`mcp-proxy` is a lightweight client proxy tool that solves one core problem:
|
||||
|
||||
**Enabling stdio-only MCP clients to access remote SSE/HTTP MCP services.**
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
Remote MCP Service (SSE/HTTP) ←→ mcp-proxy ←→ Local Application (stdio)
|
||||
```
|
||||
|
||||
- **Input**: Remote MCP service URL (supports SSE or Streamable HTTP protocols)
|
||||
- **Output**: Local stdio interface (standard input/output)
|
||||
- **Purpose**: Protocol conversion + transparent proxy
|
||||
|
||||
## Features
|
||||
|
||||
- 🔄 **Protocol Conversion**: Auto-detect and convert SSE/Streamable HTTP → stdio
|
||||
- 🌐 **Remote Access**: Enable local applications to access remote MCP services
|
||||
- 🔍 **Auto Protocol Detection**: Intelligently identify service protocol types
|
||||
- 🔐 **Authentication Support**: Custom Authorization header and HTTP headers
|
||||
- ⚡ **Lightweight & Efficient**: No extra configuration needed, works out of the box
|
||||
|
||||
## Installation
|
||||
|
||||
### Install from crates.io (Recommended)
|
||||
|
||||
```bash
|
||||
cargo install mcp-stdio-proxy
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nuwax-ai/mcp-proxy.git
|
||||
cd mcp-proxy/mcp-proxy
|
||||
cargo build --release
|
||||
# Binary located at: target/release/mcp-proxy
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Convert remote SSE service to stdio
|
||||
mcp-proxy convert https://example.com/mcp/sse
|
||||
|
||||
# Or use simplified syntax (backward compatible)
|
||||
mcp-proxy https://example.com/mcp/sse
|
||||
```
|
||||
|
||||
### Complete Example with Authentication
|
||||
|
||||
```bash
|
||||
# Use Bearer token authentication
|
||||
mcp-proxy convert https://api.example.com/mcp/sse \
|
||||
--auth "Bearer your-api-token"
|
||||
|
||||
# Add custom headers
|
||||
mcp-proxy convert https://api.example.com/mcp/sse \
|
||||
-H "Authorization=Bearer token" \
|
||||
-H "X-Custom-Header=value"
|
||||
```
|
||||
|
||||
### Use with MCP Clients
|
||||
|
||||
```bash
|
||||
# Pipe mcp-proxy output to your MCP client
|
||||
mcp-proxy convert https://remote-server.com/mcp \
|
||||
--auth "Bearer token" | \
|
||||
your-mcp-client
|
||||
|
||||
# Or use in MCP client configuration
|
||||
# Example (Claude Desktop configuration):
|
||||
{
|
||||
"mcpServers": {
|
||||
"remote-service": {
|
||||
"command": "mcp-proxy",
|
||||
"args": [
|
||||
"convert",
|
||||
"https://remote-server.com/mcp/sse",
|
||||
"--auth",
|
||||
"Bearer your-token"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Command Details
|
||||
|
||||
### 1. `convert` - Protocol Conversion (Core Command)
|
||||
|
||||
Convert remote MCP service to local stdio interface.
|
||||
|
||||
```bash
|
||||
mcp-proxy convert <URL> [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Description | Default |
|
||||
|--------|-------|-------------|---------|
|
||||
| `--auth <TOKEN>` | `-a` | Authentication header (e.g., "Bearer token") | - |
|
||||
| `--header <KEY=VALUE>` | `-H` | Custom HTTP headers (can be used multiple times) | - |
|
||||
| `--timeout <SECONDS>` | - | Connection timeout in seconds | 30 |
|
||||
| `--retries <NUM>` | - | Number of retries | 3 |
|
||||
| `--verbose` | `-v` | Verbose output (show debug info) | false |
|
||||
| `--quiet` | `-q` | Quiet mode (errors only) | false |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Basic conversion
|
||||
mcp-proxy convert https://api.example.com/mcp/sse
|
||||
|
||||
# With authentication and timeout
|
||||
mcp-proxy convert https://api.example.com/mcp/sse \
|
||||
--auth "Bearer sk-1234567890" \
|
||||
--timeout 60 \
|
||||
--retries 5
|
||||
|
||||
# Add multiple custom headers
|
||||
mcp-proxy convert https://api.example.com/mcp \
|
||||
-H "Authorization=Bearer token" \
|
||||
-H "X-API-Key=your-key" \
|
||||
-H "X-Request-ID=abc123"
|
||||
|
||||
# Verbose mode (view connection process)
|
||||
mcp-proxy convert https://api.example.com/mcp/sse --verbose
|
||||
```
|
||||
|
||||
### 2. `check` - Service Status Check
|
||||
|
||||
Check if remote MCP service is available, verify connectivity and protocol support.
|
||||
|
||||
```bash
|
||||
mcp-proxy check <URL> [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Description | Default |
|
||||
|--------|-------|-------------|---------|
|
||||
| `--auth <TOKEN>` | `-a` | Authentication header | - |
|
||||
| `--timeout <SECONDS>` | - | Timeout in seconds | 10 |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
mcp-proxy check https://api.example.com/mcp/sse
|
||||
|
||||
# With authentication
|
||||
mcp-proxy check https://api.example.com/mcp/sse \
|
||||
--auth "Bearer token" \
|
||||
--timeout 5
|
||||
```
|
||||
|
||||
**Exit Codes:**
|
||||
- `0`: Service is healthy
|
||||
- `Non-zero`: Service unavailable or check failed
|
||||
|
||||
### 3. `detect` - Protocol Detection
|
||||
|
||||
Automatically detect the protocol type used by remote MCP service.
|
||||
|
||||
```bash
|
||||
mcp-proxy detect <URL> [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--auth <TOKEN>` | `-a` | Authentication header |
|
||||
| `--quiet` | `-q` | Quiet mode (output protocol type only) |
|
||||
|
||||
**Output:**
|
||||
- `SSE` - Server-Sent Events protocol
|
||||
- `Streamable HTTP` - Streamable HTTP protocol
|
||||
- `Stdio` - Standard input/output protocol (not applicable for remote services)
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Detect protocol type
|
||||
mcp-proxy detect https://api.example.com/mcp/sse
|
||||
|
||||
# Use in scripts
|
||||
PROTOCOL=$(mcp-proxy detect https://api.example.com/mcp --quiet)
|
||||
if [ "$PROTOCOL" = "SSE" ]; then
|
||||
echo "Detected SSE protocol"
|
||||
fi
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Case 1: Claude Desktop with Remote MCP Service
|
||||
|
||||
Claude Desktop only supports stdio protocol MCP services. Use `mcp-proxy` to access remote services.
|
||||
|
||||
**Configuration Example** (`~/Library/Application Support/Claude/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"remote-database": {
|
||||
"command": "mcp-proxy",
|
||||
"args": [
|
||||
"convert",
|
||||
"https://your-server.com/mcp/database",
|
||||
"--auth",
|
||||
"Bearer your-token-here"
|
||||
]
|
||||
},
|
||||
"remote-search": {
|
||||
"command": "mcp-proxy",
|
||||
"args": ["https://search-api.com/mcp/sse"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Case 2: Health Check in CI/CD Pipeline
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Check MCP service status before deployment
|
||||
|
||||
echo "Checking MCP service..."
|
||||
if mcp-proxy check https://api.example.com/mcp --timeout 5; then
|
||||
echo "✅ MCP service is healthy, continuing deployment"
|
||||
# Execute deployment script
|
||||
./deploy.sh
|
||||
else
|
||||
echo "❌ MCP service unavailable, aborting deployment"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Case 3: Cross-Network Enterprise Internal MCP Service
|
||||
|
||||
```bash
|
||||
# Access internal MCP service via VPN or jump host
|
||||
mcp-proxy convert https://internal-mcp.company.com/api/sse \
|
||||
--auth "Bearer ${MCP_TOKEN}" \
|
||||
--timeout 120 | \
|
||||
local-mcp-client
|
||||
```
|
||||
|
||||
### Case 4: Development and Testing
|
||||
|
||||
```bash
|
||||
# Quick test remote MCP service
|
||||
mcp-proxy convert https://test-api.com/mcp/sse --verbose
|
||||
|
||||
# View detailed connection and communication logs
|
||||
RUST_LOG=debug mcp-proxy convert https://api.com/mcp/sse -v
|
||||
```
|
||||
|
||||
## Supported Protocols
|
||||
|
||||
`mcp-proxy` can connect to remote MCP services using the following protocols:
|
||||
|
||||
| Protocol | Description | Status |
|
||||
|----------|-------------|--------|
|
||||
| **SSE** | Server-Sent Events, unidirectional real-time push | ✅ Fully Supported |
|
||||
| **Streamable HTTP** | Bidirectional streaming HTTP communication | ✅ Fully Supported |
|
||||
|
||||
**Output Protocol**: Always **stdio** (standard input/output)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `RUST_LOG` | Log level | `RUST_LOG=debug mcp-proxy convert ...` |
|
||||
| `HTTP_PROXY` | HTTP proxy | `HTTP_PROXY=http://proxy:8080` |
|
||||
| `HTTPS_PROXY` | HTTPS proxy | `HTTPS_PROXY=http://proxy:8080` |
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: Why do I need mcp-proxy?
|
||||
|
||||
**A:** Many MCP clients (like Claude Desktop) only support local stdio protocol services. If your MCP service is deployed on a remote server using SSE or HTTP protocols, you need `mcp-proxy` as a protocol conversion bridge.
|
||||
|
||||
### Q: What's the difference between mcp-proxy and MCP server?
|
||||
|
||||
**A:**
|
||||
- **MCP Server**: Backend service that provides specific functionality (database access, file operations, etc.)
|
||||
- **mcp-proxy**: Pure client proxy tool that only does protocol conversion, provides no business functionality
|
||||
|
||||
### Q: Does it support bidirectional communication?
|
||||
|
||||
**A:** Yes! Whether using SSE or Streamable HTTP protocol, `mcp-proxy` supports full bidirectional communication (request/response).
|
||||
|
||||
### Q: How to debug connection issues?
|
||||
|
||||
**A:** Use `--verbose` option and `RUST_LOG` environment variable:
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug mcp-proxy convert https://api.com/mcp --verbose
|
||||
```
|
||||
|
||||
### Q: Does it support self-signed SSL certificates?
|
||||
|
||||
**A:** Current version uses system default certificate verification. For self-signed certificate support, please submit an Issue.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Timeout
|
||||
|
||||
```bash
|
||||
# Increase timeout
|
||||
mcp-proxy convert https://slow-api.com/mcp --timeout 120
|
||||
```
|
||||
|
||||
### Authentication Failed
|
||||
|
||||
```bash
|
||||
# Check token format, ensure "Bearer " prefix
|
||||
mcp-proxy convert https://api.com/mcp --auth "Bearer your-token-here"
|
||||
|
||||
# Or use custom header
|
||||
mcp-proxy convert https://api.com/mcp -H "Authorization=Bearer your-token"
|
||||
```
|
||||
|
||||
### Protocol Detection Failed
|
||||
|
||||
```bash
|
||||
# View detailed error message
|
||||
mcp-proxy detect https://api.com/mcp --verbose
|
||||
|
||||
# Check service status
|
||||
mcp-proxy check https://api.com/mcp
|
||||
```
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Operating System**: Linux, macOS, Windows
|
||||
- **Rust Version**: 1.70+ (only required for building from source)
|
||||
- **Network**: Ability to access target MCP service
|
||||
|
||||
## License
|
||||
|
||||
This project is dual-licensed under MIT or Apache-2.0.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
|
||||
- **GitHub Repository**: https://github.com/nuwax-ai/mcp-proxy
|
||||
- **Issue Tracker**: https://github.com/nuwax-ai/mcp-proxy/issues
|
||||
- **Feature Discussions**: https://github.com/nuwax-ai/mcp-proxy/discussions
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [MCP Official Documentation](https://modelcontextprotocol.io/)
|
||||
- [rmcp - Rust MCP Implementation](https://crates.io/crates/rmcp)
|
||||
- [MCP Servers List](https://github.com/modelcontextprotocol/servers)
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.1.18
|
||||
|
||||
- ✅ SSE and Streamable HTTP protocol conversion support
|
||||
- ✅ Auto protocol detection
|
||||
- ✅ Authentication and custom headers support
|
||||
- ✅ Service status check command
|
||||
- ✅ Protocol detection command
|
||||
- ✅ OpenTelemetry integration with OTLP
|
||||
- ✅ Background health checks
|
||||
- ✅ Run code execution via external processes
|
||||
388
qiming-mcp-proxy/mcp-proxy/README_zh-CN.md
Normal file
388
qiming-mcp-proxy/mcp-proxy/README_zh-CN.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# mcp-stdio-proxy
|
||||
|
||||
**[English](README.md)** | **[简体中文](README_zh-CN.md)**
|
||||
|
||||
---
|
||||
|
||||
# mcp-stdio-proxy
|
||||
|
||||
MCP (Model Context Protocol) 客户端代理工具,将远程 MCP 服务(SSE/Streamable HTTP)转换为本地 stdio 接口。
|
||||
|
||||
> **包名**:`mcp-stdio-proxy`
|
||||
> **命令名**:`mcp-proxy`(更简短)
|
||||
|
||||
## 核心功能
|
||||
|
||||
`mcp-proxy` 是一个轻量级的客户端代理工具,解决了一个核心问题:
|
||||
|
||||
**让只支持 stdio 协议的 MCP 客户端能够访问远程的 SSE/HTTP MCP 服务。**
|
||||
|
||||
### 工作原理
|
||||
|
||||
```
|
||||
远程 MCP 服务 (SSE/HTTP) ←→ mcp-proxy ←→ 本地应用 (stdio)
|
||||
```
|
||||
|
||||
- **输入**:远程 MCP 服务 URL(支持 SSE 或 Streamable HTTP 协议)
|
||||
- **输出**:本地 stdio 接口(标准输入/输出)
|
||||
- **作用**:协议转换 + 透明代理
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🔄 **协议转换**:自动检测并转换 SSE/Streamable HTTP → stdio
|
||||
- 🌐 **远程访问**:让本地应用能够访问远程 MCP 服务
|
||||
- 🔍 **自动协议检测**:智能识别服务端协议类型
|
||||
- 🔐 **认证支持**:支持自定义 Authorization header 和其他 HTTP headers
|
||||
- ⚡ **轻量高效**:无需额外配置,开箱即用
|
||||
|
||||
## 安装
|
||||
|
||||
### 从 crates.io 安装(推荐)
|
||||
|
||||
```bash
|
||||
cargo install mcp-stdio-proxy
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nuwax-ai/mcp-proxy.git
|
||||
cd mcp-proxy/mcp-proxy
|
||||
cargo build --release
|
||||
# 二进制文件位于: target/release/mcp-proxy
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基本用法
|
||||
|
||||
```bash
|
||||
# 将远程 SSE 服务转换为 stdio
|
||||
mcp-proxy convert https://example.com/mcp/sse
|
||||
|
||||
# 或使用简化语法(向后兼容)
|
||||
mcp-proxy https://example.com/mcp/sse
|
||||
```
|
||||
|
||||
### 带认证的完整示例
|
||||
|
||||
```bash
|
||||
# 使用 Bearer token 认证
|
||||
mcp-proxy convert https://api.example.com/mcp/sse \
|
||||
--auth "Bearer your-api-token"
|
||||
|
||||
# 添加自定义 headers
|
||||
mcp-proxy convert https://api.example.com/mcp/sse \
|
||||
-H "Authorization=Bearer token" \
|
||||
-H "X-Custom-Header=value"
|
||||
```
|
||||
|
||||
### 配合 MCP 客户端使用
|
||||
|
||||
```bash
|
||||
# 将 mcp-proxy 输出管道到你的 MCP 客户端
|
||||
mcp-proxy convert https://remote-server.com/mcp \
|
||||
--auth "Bearer token" | \
|
||||
your-mcp-client
|
||||
|
||||
# 或在 MCP 客户端配置中使用
|
||||
# 配置文件示例(如 Claude Desktop 配置):
|
||||
{
|
||||
"mcpServers": {
|
||||
"remote-service": {
|
||||
"command": "mcp-proxy",
|
||||
"args": [
|
||||
"convert",
|
||||
"https://remote-server.com/mcp/sse",
|
||||
"--auth",
|
||||
"Bearer your-token"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 命令详解
|
||||
|
||||
### 1. `convert` - 协议转换(核心命令)
|
||||
|
||||
将远程 MCP 服务转换为本地 stdio 接口。
|
||||
|
||||
```bash
|
||||
mcp-proxy convert <URL> [选项]
|
||||
```
|
||||
|
||||
**选项:**
|
||||
|
||||
| 选项 | 简写 | 说明 | 默认值 |
|
||||
|------|------|------|--------|
|
||||
| `--auth <TOKEN>` | `-a` | 认证 header(如: "Bearer token") | - |
|
||||
| `--header <KEY=VALUE>` | `-H` | 自定义 HTTP headers(可多次使用) | - |
|
||||
| `--timeout <SECONDS>` | - | 连接超时时间(秒) | 30 |
|
||||
| `--retries <NUM>` | - | 重试次数 | 3 |
|
||||
| `--verbose` | `-v` | 详细输出(显示调试信息) | false |
|
||||
| `--quiet` | `-q` | 静默模式(只输出错误) | false |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 基本转换
|
||||
mcp-proxy convert https://api.example.com/mcp/sse
|
||||
|
||||
# 带认证和超时设置
|
||||
mcp-proxy convert https://api.example.com/mcp/sse \
|
||||
--auth "Bearer sk-1234567890" \
|
||||
--timeout 60 \
|
||||
--retries 5
|
||||
|
||||
# 添加多个自定义 headers
|
||||
mcp-proxy convert https://api.example.com/mcp \
|
||||
-H "Authorization=Bearer token" \
|
||||
-H "X-API-Key=your-key" \
|
||||
-H "X-Request-ID=abc123"
|
||||
|
||||
# 详细模式(查看连接过程)
|
||||
mcp-proxy convert https://api.example.com/mcp/sse --verbose
|
||||
```
|
||||
|
||||
### 2. `check` - 服务状态检查
|
||||
|
||||
检查远程 MCP 服务是否可用,验证连接性和协议支持。
|
||||
|
||||
```bash
|
||||
mcp-proxy check <URL> [选项]
|
||||
```
|
||||
|
||||
**选项:**
|
||||
|
||||
| 选项 | 简写 | 说明 | 默认值 |
|
||||
|------|------|------|--------|
|
||||
| `--auth <TOKEN>` | `-a` | 认证 header | - |
|
||||
| `--timeout <SECONDS>` | - | 超时时间(秒) | 10 |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 检查服务状态
|
||||
mcp-proxy check https://api.example.com/mcp/sse
|
||||
|
||||
# 带认证检查
|
||||
mcp-proxy check https://api.example.com/mcp/sse \
|
||||
--auth "Bearer token" \
|
||||
--timeout 5
|
||||
```
|
||||
|
||||
**退出码:**
|
||||
- `0`:服务正常
|
||||
- `非 0`:服务不可用或检查失败
|
||||
|
||||
### 3. `detect` - 协议检测
|
||||
|
||||
自动检测远程 MCP 服务使用的协议类型。
|
||||
|
||||
```bash
|
||||
mcp-proxy detect <URL> [选项]
|
||||
```
|
||||
|
||||
**选项:**
|
||||
|
||||
| 选项 | 简写 | 说明 |
|
||||
|------|------|------|
|
||||
| `--auth <TOKEN>` | `-a` | 认证 header |
|
||||
| `--quiet` | `-q` | 静默模式(只输出协议类型) |
|
||||
|
||||
**输出:**
|
||||
- `SSE` - Server-Sent Events 协议
|
||||
- `Streamable HTTP` - Streamable HTTP 协议
|
||||
- `Stdio` - 标准输入输出协议(不适用于远程服务)
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 检测协议类型
|
||||
mcp-proxy detect https://api.example.com/mcp/sse
|
||||
|
||||
# 在脚本中使用
|
||||
PROTOCOL=$(mcp-proxy detect https://api.example.com/mcp --quiet)
|
||||
if [ "$PROTOCOL" = "SSE" ]; then
|
||||
echo "检测到 SSE 协议"
|
||||
fi
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:Claude Desktop 集成远程 MCP 服务
|
||||
|
||||
Claude Desktop 只支持 stdio 协议的 MCP 服务,使用 `mcp-proxy` 可以让它访问远程服务。
|
||||
|
||||
**配置文件示例** (`~/Library/Application Support/Claude/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"remote-database": {
|
||||
"command": "mcp-proxy",
|
||||
"args": [
|
||||
"convert",
|
||||
"https://your-server.com/mcp/database",
|
||||
"--auth",
|
||||
"Bearer your-token-here"
|
||||
]
|
||||
},
|
||||
"remote-search": {
|
||||
"command": "mcp-proxy",
|
||||
"args": ["https://search-api.com/mcp/sse"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2:CI/CD 流水线中的健康检查
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# 部署前检查 MCP 服务状态
|
||||
|
||||
echo "检查 MCP 服务..."
|
||||
if mcp-proxy check https://api.example.com/mcp --timeout 5; then
|
||||
echo "✅ MCP 服务正常,继续部署"
|
||||
# 执行部署脚本
|
||||
./deploy.sh
|
||||
else
|
||||
echo "❌ MCP 服务不可用,中止部署"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### 场景 3:跨网络访问企业内部 MCP 服务
|
||||
|
||||
```bash
|
||||
# 通过 VPN 或跳板机访问内网 MCP 服务
|
||||
mcp-proxy convert https://internal-mcp.company.com/api/sse \
|
||||
--auth "Bearer ${MCP_TOKEN}" \
|
||||
--timeout 120 | \
|
||||
local-mcp-client
|
||||
```
|
||||
|
||||
### 场景 4:开发测试
|
||||
|
||||
```bash
|
||||
# 快速测试远程 MCP 服务
|
||||
mcp-proxy convert https://test-api.com/mcp/sse --verbose
|
||||
|
||||
# 查看详细的连接和通信日志
|
||||
RUST_LOG=debug mcp-proxy convert https://api.com/mcp/sse -v
|
||||
```
|
||||
|
||||
## 支持的协议
|
||||
|
||||
`mcp-proxy` 可以连接以下协议的远程 MCP 服务:
|
||||
|
||||
| 协议 | 说明 | 状态 |
|
||||
|------|------|------|
|
||||
| **SSE** | Server-Sent Events,单向实时推送 | ✅ 完全支持 |
|
||||
| **Streamable HTTP** | 双向流式 HTTP 通信 | ✅ 完全支持 |
|
||||
|
||||
**输出协议**:始终是 **stdio**(标准输入/输出)
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `RUST_LOG` | 日志级别 | `RUST_LOG=debug mcp-proxy convert ...` |
|
||||
| `HTTP_PROXY` | HTTP 代理 | `HTTP_PROXY=http://proxy:8080` |
|
||||
| `HTTPS_PROXY` | HTTPS 代理 | `HTTPS_PROXY=http://proxy:8080` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么需要 mcp-proxy?
|
||||
|
||||
**A:** 许多 MCP 客户端(如 Claude Desktop)只支持本地 stdio 协议的服务。如果你的 MCP 服务部署在远程服务器上使用 SSE 或 HTTP 协议,就需要 `mcp-proxy` 作为协议转换桥梁。
|
||||
|
||||
### Q: mcp-proxy 和 MCP 服务器有什么区别?
|
||||
|
||||
**A:**
|
||||
- **MCP 服务器**:提供具体功能(数据库访问、文件操作等)的后端服务
|
||||
- **mcp-proxy**:纯粹的客户端代理工具,只做协议转换,不提供任何业务功能
|
||||
|
||||
### Q: 支持双向通信吗?
|
||||
|
||||
**A:** 是的!无论是 SSE 还是 Streamable HTTP 协议,`mcp-proxy` 都支持完整的双向通信(请求/响应)。
|
||||
|
||||
### Q: 如何调试连接问题?
|
||||
|
||||
**A:** 使用 `--verbose` 选项和 `RUST_LOG` 环境变量:
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug mcp-proxy convert https://api.com/mcp --verbose
|
||||
```
|
||||
|
||||
### Q: 支持自签名 SSL 证书吗?
|
||||
|
||||
**A:** 当前版本使用系统默认的证书验证。如需支持自签名证书,请提交 Issue。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 连接超时
|
||||
|
||||
```bash
|
||||
# 增加超时时间
|
||||
mcp-proxy convert https://slow-api.com/mcp --timeout 120
|
||||
```
|
||||
|
||||
### 认证失败
|
||||
|
||||
```bash
|
||||
# 检查 token 格式,确保包含 "Bearer " 前缀
|
||||
mcp-proxy convert https://api.com/mcp --auth "Bearer your-token-here"
|
||||
|
||||
# 或使用自定义 header
|
||||
mcp-proxy convert https://api.com/mcp -H "Authorization=Bearer your-token"
|
||||
```
|
||||
|
||||
### 协议检测失败
|
||||
|
||||
```bash
|
||||
# 查看详细错误信息
|
||||
mcp-proxy detect https://api.com/mcp --verbose
|
||||
|
||||
# 检查服务状态
|
||||
mcp-proxy check https://api.com/mcp
|
||||
```
|
||||
|
||||
## 系统要求
|
||||
|
||||
- **操作系统**:Linux, macOS, Windows
|
||||
- **Rust 版本**:1.70+ (仅从源码构建时需要)
|
||||
- **网络**:能够访问目标 MCP 服务
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT 或 Apache-2.0 双许可证。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
- **GitHub 仓库**:https://github.com/nuwax-ai/mcp-proxy
|
||||
- **问题反馈**:https://github.com/nuwax-ai/mcp-proxy/issues
|
||||
- **功能建议**:https://github.com/nuwax-ai/mcp-proxy/discussions
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [MCP 官方文档](https://modelcontextprotocol.io/)
|
||||
- [rmcp - Rust MCP 实现](https://crates.io/crates/rmcp)
|
||||
- [MCP 服务器列表](https://github.com/modelcontextprotocol/servers)
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v0.1.18
|
||||
|
||||
- ✅ 支持 SSE 和 Streamable HTTP 协议转换
|
||||
- ✅ 自动协议检测
|
||||
- ✅ 认证和自定义 headers 支持
|
||||
- ✅ 服务状态检查命令
|
||||
- ✅ 协议检测命令
|
||||
- ✅ OpenTelemetry 集成,支持 OTLP
|
||||
- ✅ 后台健康检查
|
||||
- ✅ 通过外部进程执行代码
|
||||
66
qiming-mcp-proxy/mcp-proxy/benches/README.md
Normal file
66
qiming-mcp-proxy/mcp-proxy/benches/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 性能基准测试
|
||||
|
||||
这个目录包含使用 Criterion.rs 对 `/api/run_code_with_log` 端点进行性能测试的代码。
|
||||
|
||||
## 可用的基准测试
|
||||
|
||||
1. `run_code_bench` - 基本性能测试,测试三种语言(JS、TS、Python)的简单脚本执行性能
|
||||
2. `run_code_advanced_bench` - 高级性能测试,测试多种场景、不同复杂度的脚本和参数组合
|
||||
|
||||
## 如何运行测试
|
||||
|
||||
使用以下命令运行基本测试:
|
||||
|
||||
```bash
|
||||
cargo bench --bench run_code_bench
|
||||
```
|
||||
|
||||
使用以下命令运行高级测试:
|
||||
|
||||
```bash
|
||||
cargo bench --bench run_code_advanced_bench
|
||||
```
|
||||
|
||||
运行特定的测试场景:
|
||||
|
||||
```bash
|
||||
cargo bench --bench run_code_advanced_bench -- js_basic
|
||||
```
|
||||
|
||||
## 查看测试结果
|
||||
|
||||
测试结果将保存在 `target/criterion` 目录下,你可以在浏览器中打开 HTML 报告查看详细的结果:
|
||||
|
||||
```bash
|
||||
open target/criterion/report/index.html
|
||||
```
|
||||
|
||||
## 配置测试参数
|
||||
|
||||
在基准测试文件中可以修改以下参数,以适应不同性能的机器:
|
||||
|
||||
- `sample_size`: 每个测试场景运行的样本数量(默认为10)
|
||||
- `warm_up_time`: 预热时间(默认为20秒)
|
||||
- `measurement_time`: 测量时间(默认为10秒)
|
||||
- `significance_level`: 统计显著性水平(默认为0.05,即5%)
|
||||
|
||||
如果在较慢的机器上运行,可能需要进一步减少样本数或增加测量时间。可以通过命令行参数指定:
|
||||
|
||||
```bash
|
||||
# 使用命令行参数减少样本数
|
||||
cargo bench --bench run_code_bench -- --sample-size 5
|
||||
|
||||
# 使用命令行参数增加预热时间
|
||||
cargo bench --bench run_code_bench -- --warm-up-time 30
|
||||
|
||||
# 使用命令行参数增加测量时间
|
||||
cargo bench --bench run_code_bench -- --measurement-time 15
|
||||
```
|
||||
|
||||
## 测试脚本说明
|
||||
|
||||
测试使用了 `fixtures` 目录下的各种测试脚本:
|
||||
|
||||
- `test_js.js`、`test_ts.ts`、`test_python_simple.py` - 基本语言测试脚本
|
||||
- `test_js_params.js`、`test_ts_params.ts`、`test_python_params.py` - 带参数的测试脚本
|
||||
- `import_lodash_example.js`、`test_python_logging.py` - 复杂功能测试脚本
|
||||
195
qiming-mcp-proxy/mcp-proxy/benches/run_code_advanced_bench.rs
Normal file
195
qiming-mcp-proxy/mcp-proxy/benches/run_code_advanced_bench.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||
use mcp_stdio_proxy::{RunCodeMessageRequest, run_code_handler};
|
||||
use serde_json::json;
|
||||
use std::{collections::HashMap, fs};
|
||||
use tokio::runtime::Runtime;
|
||||
use uuid::Uuid;
|
||||
|
||||
// 测试脚本类型
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ScriptType {
|
||||
Js,
|
||||
Ts,
|
||||
Python,
|
||||
}
|
||||
|
||||
// 测试场景
|
||||
#[allow(dead_code)]
|
||||
struct TestScenario {
|
||||
name: &'static str,
|
||||
file_path: &'static str,
|
||||
script_type: ScriptType,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
// 获取所有测试场景
|
||||
fn get_test_scenarios() -> Vec<TestScenario> {
|
||||
vec![
|
||||
// 基本测试场景
|
||||
TestScenario {
|
||||
name: "js_basic",
|
||||
file_path: "fixtures/test_js.js",
|
||||
script_type: ScriptType::Js,
|
||||
description: "基本JavaScript执行",
|
||||
},
|
||||
TestScenario {
|
||||
name: "ts_basic",
|
||||
file_path: "fixtures/test_ts.ts",
|
||||
script_type: ScriptType::Ts,
|
||||
description: "基本TypeScript执行",
|
||||
},
|
||||
TestScenario {
|
||||
name: "python_basic",
|
||||
file_path: "fixtures/test_python_simple.py",
|
||||
script_type: ScriptType::Python,
|
||||
description: "基本Python执行",
|
||||
},
|
||||
// 参数传递测试场景
|
||||
TestScenario {
|
||||
name: "js_params",
|
||||
file_path: "fixtures/test_js_params.js",
|
||||
script_type: ScriptType::Js,
|
||||
description: "带参数的JavaScript执行",
|
||||
},
|
||||
TestScenario {
|
||||
name: "ts_params",
|
||||
file_path: "fixtures/test_ts_params.ts",
|
||||
script_type: ScriptType::Ts,
|
||||
description: "带参数的TypeScript执行",
|
||||
},
|
||||
TestScenario {
|
||||
name: "python_params",
|
||||
file_path: "fixtures/test_python_params.py",
|
||||
script_type: ScriptType::Python,
|
||||
description: "带参数的Python执行",
|
||||
},
|
||||
// 复杂测试场景
|
||||
TestScenario {
|
||||
name: "js_import",
|
||||
file_path: "fixtures/import_lodash_example.js",
|
||||
script_type: ScriptType::Js,
|
||||
description: "导入lodash的JavaScript执行",
|
||||
},
|
||||
TestScenario {
|
||||
name: "python_logging",
|
||||
file_path: "fixtures/test_python_logging.py",
|
||||
script_type: ScriptType::Python,
|
||||
description: "使用logging的Python执行",
|
||||
},
|
||||
TestScenario {
|
||||
name: "ts_complex",
|
||||
file_path: "fixtures/test_ts.ts",
|
||||
script_type: ScriptType::Ts,
|
||||
description: "复杂TypeScript执行",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// 读取测试脚本文件
|
||||
fn read_script_file(file_path: &str) -> String {
|
||||
fs::read_to_string(file_path).unwrap_or_else(|_| panic!("无法读取文件: {file_path}"))
|
||||
}
|
||||
|
||||
// 创建运行代码请求
|
||||
fn create_run_code_request(
|
||||
code: &str,
|
||||
script_type: ScriptType,
|
||||
with_complex_params: bool,
|
||||
) -> RunCodeMessageRequest {
|
||||
let mut json_param = HashMap::new();
|
||||
|
||||
if with_complex_params {
|
||||
// 创建复杂参数
|
||||
json_param.insert("input".to_string(), json!("测试输入"));
|
||||
json_param.insert("number".to_string(), json!(42));
|
||||
json_param.insert("boolean".to_string(), json!(true));
|
||||
json_param.insert("array".to_string(), json!([1, 2, 3, 4, 5]));
|
||||
json_param.insert(
|
||||
"object".to_string(),
|
||||
json!({
|
||||
"name": "测试对象",
|
||||
"properties": {
|
||||
"a": 1,
|
||||
"b": "string",
|
||||
"c": [true, false]
|
||||
}
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// 创建简单参数
|
||||
json_param.insert("input".to_string(), json!("测试输入"));
|
||||
}
|
||||
|
||||
RunCodeMessageRequest {
|
||||
json_param,
|
||||
code: code.to_string(),
|
||||
uid: Uuid::new_v4().to_string(),
|
||||
engine_type: match script_type {
|
||||
ScriptType::Js => "js".to_string(),
|
||||
ScriptType::Ts => "ts".to_string(),
|
||||
ScriptType::Python => "python".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 设置基准测试
|
||||
fn bench_run_code_advanced(c: &mut Criterion) {
|
||||
// 创建测试组
|
||||
let mut group = c.benchmark_group("run_code_handler_advanced");
|
||||
|
||||
// 设置采样大小和预热次数
|
||||
group.sample_size(10); // 减少到10次取平均值
|
||||
group.warm_up_time(std::time::Duration::from_secs(20)); // 20秒预热时间
|
||||
group.measurement_time(std::time::Duration::from_secs(10)); // 10秒测量时间
|
||||
|
||||
// 获取所有测试场景
|
||||
let scenarios = get_test_scenarios();
|
||||
|
||||
// 创建tokio运行时
|
||||
let rt = Runtime::new().unwrap();
|
||||
|
||||
// 遍历测试场景
|
||||
for scenario in scenarios {
|
||||
// 读取脚本内容
|
||||
let code = read_script_file(scenario.file_path);
|
||||
|
||||
// 设置吞吐量计数为脚本字节大小
|
||||
// 这样可以比较不同大小脚本的执行效率
|
||||
group.throughput(Throughput::Bytes(code.len() as u64));
|
||||
|
||||
// 测试使用简单参数
|
||||
group.bench_function(
|
||||
BenchmarkId::new(format!("{}_simple", scenario.name), "simple_params"),
|
||||
|b| {
|
||||
b.iter(|| {
|
||||
let req = create_run_code_request(&code, scenario.script_type, false);
|
||||
rt.block_on(async { run_code_handler(axum::Json(req)).await.unwrap() })
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 测试使用复杂参数
|
||||
group.bench_function(
|
||||
BenchmarkId::new(format!("{}_complex", scenario.name), "complex_params"),
|
||||
|b| {
|
||||
b.iter(|| {
|
||||
let req = create_run_code_request(&code, scenario.script_type, true);
|
||||
rt.block_on(async { run_code_handler(axum::Json(req)).await.unwrap() })
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
name = benches;
|
||||
config = Criterion::default()
|
||||
.significance_level(0.05) // 设置显著性水平为5%
|
||||
.sample_size(10) // 设置样本大小为10
|
||||
.warm_up_time(std::time::Duration::from_secs(20)) // 20秒预热时间
|
||||
.measurement_time(std::time::Duration::from_secs(10)); // 10秒测量时间
|
||||
targets = bench_run_code_advanced
|
||||
);
|
||||
criterion_main!(benches);
|
||||
90
qiming-mcp-proxy/mcp-proxy/benches/run_code_bench.rs
Normal file
90
qiming-mcp-proxy/mcp-proxy/benches/run_code_bench.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||
use mcp_stdio_proxy::{RunCodeMessageRequest, run_code_handler};
|
||||
use serde_json::json;
|
||||
use std::{collections::HashMap, fs};
|
||||
use tokio::runtime::Runtime;
|
||||
use uuid::Uuid;
|
||||
|
||||
// 测试脚本类型枚举
|
||||
enum ScriptType {
|
||||
Js,
|
||||
Ts,
|
||||
Python,
|
||||
}
|
||||
|
||||
// 读取测试脚本文件
|
||||
fn read_script_file(file_path: &str) -> String {
|
||||
fs::read_to_string(file_path).unwrap_or_else(|_| panic!("无法读取文件: {file_path}"))
|
||||
}
|
||||
|
||||
// 创建运行代码请求
|
||||
fn create_run_code_request(code: &str, script_type: ScriptType) -> RunCodeMessageRequest {
|
||||
let mut json_param = HashMap::new();
|
||||
json_param.insert("input".to_string(), json!("测试输入"));
|
||||
|
||||
RunCodeMessageRequest {
|
||||
json_param,
|
||||
code: code.to_string(),
|
||||
uid: Uuid::new_v4().to_string(),
|
||||
engine_type: match script_type {
|
||||
ScriptType::Js => "js".to_string(),
|
||||
ScriptType::Ts => "ts".to_string(),
|
||||
ScriptType::Python => "python".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 设置基准测试
|
||||
fn bench_run_code(c: &mut Criterion) {
|
||||
// 创建测试组
|
||||
let mut group = c.benchmark_group("run_code_handler");
|
||||
|
||||
// 设置采样配置
|
||||
group.sample_size(10); // 减少样本数量
|
||||
group.warm_up_time(std::time::Duration::from_secs(20)); // 增加预热时间为20秒
|
||||
group.measurement_time(std::time::Duration::from_secs(10)); // 增加测量时间
|
||||
|
||||
// 加载测试脚本
|
||||
let js_code = read_script_file("fixtures/test_js.js");
|
||||
let ts_code = read_script_file("fixtures/test_ts.ts");
|
||||
let py_code = read_script_file("fixtures/test_python_simple.py");
|
||||
|
||||
// 创建运行时
|
||||
let rt = Runtime::new().unwrap();
|
||||
|
||||
// 测试JS代码执行性能
|
||||
group.bench_function(BenchmarkId::new("javascript", "test_js.js"), |b| {
|
||||
b.iter(|| {
|
||||
let req = create_run_code_request(&js_code, ScriptType::Js);
|
||||
rt.block_on(async { run_code_handler(axum::Json(req)).await.unwrap() })
|
||||
});
|
||||
});
|
||||
|
||||
// 测试TS代码执行性能
|
||||
group.bench_function(BenchmarkId::new("typescript", "test_ts.ts"), |b| {
|
||||
b.iter(|| {
|
||||
let req = create_run_code_request(&ts_code, ScriptType::Ts);
|
||||
rt.block_on(async { run_code_handler(axum::Json(req)).await.unwrap() })
|
||||
});
|
||||
});
|
||||
|
||||
// 测试Python代码执行性能
|
||||
group.bench_function(BenchmarkId::new("python", "test_python_simple.py"), |b| {
|
||||
b.iter(|| {
|
||||
let req = create_run_code_request(&py_code, ScriptType::Python);
|
||||
rt.block_on(async { run_code_handler(axum::Json(req)).await.unwrap() })
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = benches;
|
||||
config = Criterion::default()
|
||||
.sample_size(10) // 减少样本数量
|
||||
.warm_up_time(std::time::Duration::from_secs(20)) // 设置20秒的预热时间
|
||||
.measurement_time(std::time::Duration::from_secs(10)); // 设置更长的测量时间
|
||||
targets = bench_run_code
|
||||
}
|
||||
criterion_main!(benches);
|
||||
5
qiming-mcp-proxy/mcp-proxy/build.rs
Normal file
5
qiming-mcp-proxy/mcp-proxy/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=locales/en.yml");
|
||||
println!("cargo:rerun-if-changed=locales/zh-CN.yml");
|
||||
println!("cargo:rerun-if-changed=locales/zh-TW.yml");
|
||||
}
|
||||
13
qiming-mcp-proxy/mcp-proxy/config.yml
Normal file
13
qiming-mcp-proxy/mcp-proxy/config.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
server:
|
||||
# The port to listen on for incoming connections
|
||||
port: 8085
|
||||
# The log level to use
|
||||
log:
|
||||
level: debug
|
||||
# The path to the log file
|
||||
path: logs
|
||||
# The number of log files to retain (default: 5)
|
||||
retain_days: 5
|
||||
mirror:
|
||||
npm_registry: ""
|
||||
pypi_index_url: ""
|
||||
23
qiming-mcp-proxy/mcp-proxy/creat_table.sql
Normal file
23
qiming-mcp-proxy/mcp-proxy/creat_table.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS mcp_plugin (
|
||||
id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
|
||||
name varchar(128) NOT NULL COMMENT '插件名称',
|
||||
command varchar(512) NOT NULL COMMENT '启动命令,用户输入,或者从json配置中解析获取',
|
||||
args JSON NULL COMMENT '启动参数(JSON格式),例如:[{"name":"port","value":"8000"}]',
|
||||
envs JSON NULL COMMENT '环境变量(JSON格式),例如:[{"name":"PORT","value":"8000"}]',
|
||||
mounts JSON NULL COMMENT '挂载目录(JSON格式),例如:[{"name":"/data/mcp","path":"/data/mcp"}]',
|
||||
port int NULL COMMENT '端口,例如:8000',
|
||||
external_url varchar(512) NULL COMMENT '外部访问路径,例如:http://192.168.1.1:8000',
|
||||
container_name varchar(128) NULL COMMENT '容器名称,例如:mcp-plugin',
|
||||
sse_path varchar(128) NULL COMMENT 'SSE路径,例如:/sse,注意不要与其他 Server 重复',
|
||||
config_json JSON NULL COMMENT '插件配置(JSON)',
|
||||
status tinyint DEFAULT 2 NOT NULL COMMENT 'MCP启动状态:1:RUNNING(运行中),2:STOPPED(已停止),3:ERROR(错误)',
|
||||
enabled tinyint DEFAULT 1 NOT NULL COMMENT '是否启用:1(启用),0(禁用)',
|
||||
-- 公共字段
|
||||
created datetime DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
|
||||
creator_id bigint NULL COMMENT '创建人id',
|
||||
creator_name varchar(64) NULL COMMENT '创建人',
|
||||
modified datetime DEFAULT CURRENT_TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
modified_id bigint NULL COMMENT '最后修改人id',
|
||||
modified_name varchar(64) NULL COMMENT '最后修改人',
|
||||
yn tinyint DEFAULT 1 NULL COMMENT '逻辑标记,1:有效;-1:无效'
|
||||
) DEFAULT CHARSET=utf8mb4 COMMENT='MCP插件服务表';
|
||||
24
qiming-mcp-proxy/mcp-proxy/fixtures/cow_say_hello.js
Normal file
24
qiming-mcp-proxy/mcp-proxy/fixtures/cow_say_hello.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as o from 'https://deno.land/x/cowsay/mod.ts'
|
||||
|
||||
// 使用类型注解时应确保使用的是支持该特性的环境或编译器(如 TypeScript)
|
||||
function add(input) {
|
||||
console.log("test Add", input.a, "+", input.b);
|
||||
|
||||
let m = o.say({
|
||||
text: 'hello every one'
|
||||
})
|
||||
console.log(m)
|
||||
|
||||
return input.a + input.b;
|
||||
}
|
||||
|
||||
|
||||
export default async function handler(input) {
|
||||
|
||||
let a = add(input)
|
||||
console.log("计算结果=" + a)
|
||||
|
||||
return {
|
||||
message: a,
|
||||
};
|
||||
}
|
||||
30
qiming-mcp-proxy/mcp-proxy/fixtures/import_axios_example.js
Normal file
30
qiming-mcp-proxy/mcp-proxy/fixtures/import_axios_example.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios from 'npm:axios';
|
||||
|
||||
// 使用axios发起HTTP请求的函数
|
||||
async function fetchData(url) {
|
||||
console.log(`正在请求: ${url}`);
|
||||
try {
|
||||
const response = await axios.get(url);
|
||||
console.log(`请求成功,状态码: ${response.status}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`请求失败: ${error.message}`);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(input) {
|
||||
// 默认URL或从输入参数获取
|
||||
const url = input.url || 'https://jsonplaceholder.typicode.com/todos/1';
|
||||
|
||||
console.log(`开始处理请求,URL: ${url}`);
|
||||
|
||||
// 发起请求并获取数据
|
||||
const data = await fetchData(url);
|
||||
|
||||
return {
|
||||
message: '请求完成',
|
||||
data: data,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { join, basename } from "https://deno.land/std/path/mod.ts";
|
||||
import { readLines } from "https://deno.land/std/io/mod.ts";
|
||||
import { format } from "https://deno.land/std/datetime/mod.ts";
|
||||
|
||||
// 使用Deno标准库处理路径和文件
|
||||
async function processPath(path) {
|
||||
console.log(`处理路径: ${path}`);
|
||||
|
||||
// 使用path模块处理路径
|
||||
const fileName = basename(path);
|
||||
console.log(`文件名: ${fileName}`);
|
||||
|
||||
const fullPath = join("/tmp", fileName);
|
||||
console.log(`完整路径: ${fullPath}`);
|
||||
|
||||
// 获取当前时间并格式化
|
||||
const now = new Date();
|
||||
const formattedDate = format(now, "yyyy-MM-dd HH:mm:ss");
|
||||
console.log(`当前时间: ${formattedDate}`);
|
||||
|
||||
return {
|
||||
originalPath: path,
|
||||
fileName: fileName,
|
||||
fullPath: fullPath,
|
||||
timestamp: formattedDate
|
||||
};
|
||||
}
|
||||
|
||||
// 模拟读取文件内容
|
||||
async function simulateFileReading() {
|
||||
const text = `这是第一行
|
||||
这是第二行
|
||||
这是第三行
|
||||
这是最后一行`;
|
||||
|
||||
// 使用TextEncoder和StringReader代替Deno.Buffer
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const data = encoder.encode(text);
|
||||
|
||||
// 创建一个可读流
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(data);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
// 将流转换为Reader
|
||||
const reader = stream.getReader();
|
||||
|
||||
console.log("开始读取文件内容:");
|
||||
|
||||
// 手动处理文本行
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
console.log(`第${i+1}行: ${lines[i]}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export default async function handler(input) {
|
||||
console.log("开始处理");
|
||||
|
||||
// 处理路径
|
||||
const path = input.path || "example.txt";
|
||||
const pathInfo = await processPath(path);
|
||||
|
||||
// 模拟读取文件
|
||||
const fileLines = await simulateFileReading();
|
||||
|
||||
return {
|
||||
message: '处理完成',
|
||||
pathInfo: pathInfo,
|
||||
fileContent: fileLines,
|
||||
linesCount: fileLines.length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 使用ES模块导入方式
|
||||
import { createHash } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
// 使用Node.js内置模块处理数据
|
||||
function hashData(data, algorithm = 'sha256') {
|
||||
console.log(`使用 ${algorithm} 算法处理数据`);
|
||||
|
||||
// 将数据转换为Buffer
|
||||
const buffer = typeof data === 'string'
|
||||
? Buffer.from(data)
|
||||
: Buffer.from(JSON.stringify(data));
|
||||
|
||||
// 创建哈希
|
||||
const hash = createHash(algorithm);
|
||||
hash.update(buffer);
|
||||
|
||||
// 获取哈希结果
|
||||
const digest = hash.digest('hex');
|
||||
console.log(`哈希结果: ${digest}`);
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
// 加密数据
|
||||
function encryptData(data, salt = 'default-salt') {
|
||||
console.log(`加密数据,使用盐值: ${salt}`);
|
||||
|
||||
// 组合数据和盐值
|
||||
const combined = `${data}:${salt}:${Date.now()}`;
|
||||
|
||||
// 生成多种哈希
|
||||
const sha256Hash = hashData(combined, 'sha256');
|
||||
const md5Hash = hashData(combined, 'md5');
|
||||
|
||||
return {
|
||||
original: data,
|
||||
salt,
|
||||
sha256: sha256Hash,
|
||||
md5: md5Hash,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export default async function handler(input) {
|
||||
console.log("开始处理加密任务");
|
||||
|
||||
// 获取输入数据
|
||||
const data = input.data || "这是需要加密的默认数据";
|
||||
const salt = input.salt || "custom-salt-" + Math.floor(Math.random() * 1000);
|
||||
|
||||
// 加密数据
|
||||
const encryptedResult = encryptData(data, salt);
|
||||
|
||||
return {
|
||||
message: '加密处理完成',
|
||||
result: encryptedResult
|
||||
};
|
||||
}
|
||||
79
qiming-mcp-proxy/mcp-proxy/fixtures/import_jsr_example.js
Normal file
79
qiming-mcp-proxy/mcp-proxy/fixtures/import_jsr_example.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { delay } from "jsr:@std/async";
|
||||
import { bold, red, green } from "jsr:@std/fmt/colors";
|
||||
|
||||
// 使用JSR标准库中的工具函数
|
||||
async function runTests(input) {
|
||||
console.log(bold("开始运行测试..."));
|
||||
|
||||
// 使用delay函数模拟异步操作
|
||||
console.log("等待1秒...");
|
||||
await delay(1000);
|
||||
|
||||
// 测试结果数组
|
||||
const results = [];
|
||||
|
||||
// 测试1: 加法运算
|
||||
try {
|
||||
const a = input.a || 5;
|
||||
const b = input.b || 3;
|
||||
const expected = input.expected || 8;
|
||||
|
||||
console.log(`测试加法: ${a} + ${b} = ${expected}`);
|
||||
assertEquals(a + b, expected);
|
||||
|
||||
console.log(green("✓ 加法测试通过"));
|
||||
results.push({ name: "加法测试", success: true });
|
||||
} catch (error) {
|
||||
console.log(red(`✗ 加法测试失败: ${error.message}`));
|
||||
results.push({ name: "加法测试", success: false, error: error.message });
|
||||
}
|
||||
|
||||
// 测试2: 字符串操作
|
||||
try {
|
||||
const str1 = input.str1 || "hello";
|
||||
const str2 = input.str2 || "world";
|
||||
const expectedStr = input.expectedStr || "hello world";
|
||||
|
||||
console.log(`测试字符串连接: "${str1}" + " " + "${str2}" = "${expectedStr}"`);
|
||||
assertEquals(`${str1} ${str2}`, expectedStr);
|
||||
|
||||
console.log(green("✓ 字符串测试通过"));
|
||||
results.push({ name: "字符串测试", success: true });
|
||||
} catch (error) {
|
||||
console.log(red(`✗ 字符串测试失败: ${error.message}`));
|
||||
results.push({ name: "字符串测试", success: false, error: error.message });
|
||||
}
|
||||
|
||||
// 等待再次展示结果
|
||||
console.log("处理结果中...");
|
||||
await delay(500);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export default async function handler(input) {
|
||||
console.log(bold("JSR依赖示例"));
|
||||
|
||||
// 运行测试
|
||||
const testResults = await runTests(input);
|
||||
|
||||
// 统计结果
|
||||
const passed = testResults.filter(r => r.success).length;
|
||||
const failed = testResults.filter(r => !r.success).length;
|
||||
|
||||
// 使用彩色输出显示结果
|
||||
console.log(bold("\n测试结果摘要:"));
|
||||
console.log(green(`通过: ${passed}`));
|
||||
console.log(failed > 0 ? red(`失败: ${failed}`) : green(`失败: ${failed}`));
|
||||
|
||||
return {
|
||||
message: '测试完成',
|
||||
results: testResults,
|
||||
summary: {
|
||||
total: testResults.length,
|
||||
passed,
|
||||
failed
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 首先创建一个本地模块,通过相对路径引入
|
||||
// 注意:这个示例假设有一个名为utils.js的本地模块
|
||||
|
||||
// 模拟本地utils.js模块的内容
|
||||
const utils = {
|
||||
formatNumber(num) {
|
||||
return num.toLocaleString('zh-CN');
|
||||
},
|
||||
|
||||
calculateTax(amount, rate = 0.1) {
|
||||
return amount * rate;
|
||||
},
|
||||
|
||||
generateId() {
|
||||
return `id-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||
},
|
||||
|
||||
formatDate(date = new Date()) {
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
};
|
||||
|
||||
// 使用本地模块的函数
|
||||
function processOrder(order) {
|
||||
console.log(`处理订单: ${order.id || utils.generateId()}`);
|
||||
|
||||
// 计算订单总额
|
||||
const total = order.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
console.log(`订单总额: ${utils.formatNumber(total)}`);
|
||||
|
||||
// 计算税费
|
||||
const taxRate = order.taxRate || 0.13;
|
||||
const tax = utils.calculateTax(total, taxRate);
|
||||
console.log(`税费(${taxRate * 100}%): ${utils.formatNumber(tax)}`);
|
||||
|
||||
// 计算最终金额
|
||||
const finalAmount = total + tax;
|
||||
console.log(`最终金额: ${utils.formatNumber(finalAmount)}`);
|
||||
|
||||
// 生成订单日期
|
||||
const orderDate = order.date ? new Date(order.date) : new Date();
|
||||
console.log(`订单日期: ${utils.formatDate(orderDate)}`);
|
||||
|
||||
return {
|
||||
id: order.id || utils.generateId(),
|
||||
items: order.items,
|
||||
subtotal: total,
|
||||
tax: tax,
|
||||
total: finalAmount,
|
||||
date: utils.formatDate(orderDate)
|
||||
};
|
||||
}
|
||||
|
||||
export default async function handler(input) {
|
||||
console.log("开始处理订单");
|
||||
|
||||
// 默认订单或从输入获取
|
||||
const order = input.order || {
|
||||
items: [
|
||||
{ name: "产品A", price: 100, quantity: 2 },
|
||||
{ name: "产品B", price: 50, quantity: 1 },
|
||||
{ name: "产品C", price: 200, quantity: 3 }
|
||||
],
|
||||
taxRate: 0.13
|
||||
};
|
||||
|
||||
// 处理订单
|
||||
const processedOrder = processOrder(order);
|
||||
|
||||
return {
|
||||
message: '订单处理完成',
|
||||
order: processedOrder
|
||||
};
|
||||
}
|
||||
43
qiming-mcp-proxy/mcp-proxy/fixtures/import_lodash_example.js
Normal file
43
qiming-mcp-proxy/mcp-proxy/fixtures/import_lodash_example.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import _ from 'npm:lodash';
|
||||
|
||||
// 使用lodash处理数据的函数
|
||||
function processData(input) {
|
||||
console.log("输入数据:", input);
|
||||
|
||||
// 使用lodash的chunk方法将数组分块
|
||||
if (Array.isArray(input.data)) {
|
||||
const chunks = _.chunk(input.data, input.chunkSize || 2);
|
||||
console.log("数据分块结果:", chunks);
|
||||
|
||||
// 使用lodash的map方法处理每个块
|
||||
const processedChunks = _.map(chunks, (chunk) => {
|
||||
return {
|
||||
items: chunk,
|
||||
sum: _.sum(chunk),
|
||||
average: _.mean(chunk)
|
||||
};
|
||||
});
|
||||
|
||||
return processedChunks;
|
||||
}
|
||||
|
||||
return { error: "输入数据不是数组" };
|
||||
}
|
||||
|
||||
export default async function handler(input) {
|
||||
console.log("开始处理数据");
|
||||
|
||||
// 从输入获取数据,或使用默认数据
|
||||
const data = input.data || [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
const chunkSize = input.chunkSize || 3;
|
||||
|
||||
// 处理数据
|
||||
const result = processData({ data, chunkSize });
|
||||
|
||||
return {
|
||||
message: '数据处理完成',
|
||||
originalData: data,
|
||||
processedData: result,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
26
qiming-mcp-proxy/mcp-proxy/fixtures/rfunction_python.py
Normal file
26
qiming-mcp-proxy/mcp-proxy/fixtures/rfunction_python.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# 发送请求到百度
|
||||
url = 'https://www.baidu.com'
|
||||
response = requests.get(url)
|
||||
|
||||
# 检查请求是否成功
|
||||
if response.status_code == 200:
|
||||
# 解析网页内容
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# 提取你想要的信息,例如标题
|
||||
title = soup.title.string
|
||||
else:
|
||||
print(f"请求失败,状态码:")
|
||||
|
||||
|
||||
# 入口函数不可修改,否则无法执行,args 为配置的入参
|
||||
def main(args: dict) -> dict:
|
||||
params = args.get("params")
|
||||
# 构建输出对象,出参中的key需与配置的出参保持一致
|
||||
ret = {
|
||||
"key": "value"
|
||||
}
|
||||
return ret
|
||||
20
qiming-mcp-proxy/mcp-proxy/fixtures/rfunction_test1.js
Normal file
20
qiming-mcp-proxy/mcp-proxy/fixtures/rfunction_test1.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// 使用类型注解时应确保使用的是支持该特性的环境或编译器(如 TypeScript)
|
||||
function add(input) {
|
||||
console.log("test Add", input.a, "+", input.b);
|
||||
|
||||
// 简单的字符串输出,不使用外部模块
|
||||
console.log("Hello from JavaScript!");
|
||||
|
||||
return input.a + input.b;
|
||||
}
|
||||
|
||||
function handler(input) {
|
||||
console.log("输入参数:", JSON.stringify(input));
|
||||
|
||||
let a = add(input);
|
||||
console.log("计算结果=" + a);
|
||||
|
||||
return {
|
||||
message: a,
|
||||
};
|
||||
}
|
||||
40
qiming-mcp-proxy/mcp-proxy/fixtures/rfunction_test2.py
Normal file
40
qiming-mcp-proxy/mcp-proxy/fixtures/rfunction_test2.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
import pandas as pd
|
||||
|
||||
import logging
|
||||
import json
|
||||
|
||||
# 创建一个简单的数据结构,不使用pandas
|
||||
data = {
|
||||
'Name': ['Alice', 'Bob', 'Charlie'],
|
||||
'Age': [25, 30, 35]
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
# 记录数据信息
|
||||
logging.info(f"Created data structure:\n{json.dumps(data, indent=2)}")
|
||||
|
||||
def main(args: dict) -> dict:
|
||||
logging.info(f"input args: {args}")
|
||||
|
||||
params = args.get("params")
|
||||
|
||||
# 处理params为None的情况
|
||||
if params is None:
|
||||
logging.warning("params is None, using default values")
|
||||
params = {"input": "default_value"}
|
||||
elif not isinstance(params, dict):
|
||||
logging.warning(f"params is not a dictionary: {type(params)}, using default values")
|
||||
params = {"input": str(params)}
|
||||
elif "input" not in params:
|
||||
logging.warning("input key not found in params, using default value")
|
||||
params["input"] = "default_value"
|
||||
|
||||
# 构建输出对象
|
||||
ret = {
|
||||
"key0": params['input'], # 使用input参数值
|
||||
"key1": ["hello", "world"], # 输出一个数组
|
||||
"key2": { # 输出一个Object
|
||||
"key21": "hi"
|
||||
},
|
||||
}
|
||||
return ret
|
||||
@@ -0,0 +1,10 @@
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("MCPServer")
|
||||
|
||||
@mcp.tool
|
||||
def hello(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000, path="/mcp")
|
||||
25
qiming-mcp-proxy/mcp-proxy/fixtures/test_js.js
Normal file
25
qiming-mcp-proxy/mcp-proxy/fixtures/test_js.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Sample JavaScript file with handler function
|
||||
|
||||
// Log some debug information
|
||||
console.log("Initializing JavaScript test...");
|
||||
|
||||
// Define a simple add function
|
||||
function add(a, b) {
|
||||
console.log(`Adding ${a} + ${b}`);
|
||||
return a + b;
|
||||
}
|
||||
|
||||
// Some processing
|
||||
const numbers = [1, 2, 3, 4, 5];
|
||||
console.log("Processing numbers:", numbers);
|
||||
|
||||
// Handler function that will be called to get the result
|
||||
function handler() {
|
||||
console.log("Handler function called");
|
||||
|
||||
// Calculate sum
|
||||
const sum = numbers.reduce((acc, num) => add(acc, num), 0);
|
||||
console.log("Final calculation completed");
|
||||
|
||||
return `The sum of [${numbers.join(", ")}] is ${sum}`;
|
||||
}
|
||||
39
qiming-mcp-proxy/mcp-proxy/fixtures/test_js_params.js
Normal file
39
qiming-mcp-proxy/mcp-proxy/fixtures/test_js_params.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// JavaScript测试文件,演示参数传递
|
||||
|
||||
// 打印一些调试信息
|
||||
console.log("JavaScript脚本开始执行...");
|
||||
|
||||
// 定义一个加法函数
|
||||
function add(a, b) {
|
||||
console.log(`正在计算: ${a} + ${b}`);
|
||||
return a + b;
|
||||
}
|
||||
|
||||
// 处理一些数据
|
||||
const numbers = [1, 2, 3, 4, 5];
|
||||
console.log(`处理数字列表: ${numbers}`);
|
||||
|
||||
/**
|
||||
* 处理函数,接收参数并返回结果
|
||||
* 注意:这个函数必须存在,并且会被框架调用来获取结果
|
||||
*/
|
||||
function handler(input) {
|
||||
console.log(`接收到的参数: ${JSON.stringify(input)}`);
|
||||
|
||||
// 从参数中获取值,提供默认值
|
||||
const a = input.a || 0;
|
||||
const b = input.b || 0;
|
||||
const name = input.name || "用户";
|
||||
|
||||
// 计算结果
|
||||
const result = add(a, b);
|
||||
console.log(`计算完成: ${a} + ${b} = ${result}`);
|
||||
|
||||
// 返回结果
|
||||
return {
|
||||
sum: result,
|
||||
numbers: numbers,
|
||||
greeting: `你好,${name}!`,
|
||||
message: `成功计算 ${a} + ${b} 的结果`
|
||||
};
|
||||
}
|
||||
38
qiming-mcp-proxy/mcp-proxy/fixtures/test_python.py
Normal file
38
qiming-mcp-proxy/mcp-proxy/fixtures/test_python.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sample Python file with handler function
|
||||
|
||||
import time
|
||||
time.time()
|
||||
# Debug print to confirm script execution
|
||||
print("DEBUGGING: Script is being executed!")
|
||||
|
||||
# Log some debug information
|
||||
print("Initializing Python test...")
|
||||
|
||||
# Define a simple function
|
||||
def multiply(a, b):
|
||||
print(f"Multiplying {a} * {b}")
|
||||
return a * b
|
||||
|
||||
# Some processing
|
||||
numbers = [1, 2, 3, 4, 5]
|
||||
print(f"Processing numbers: {numbers}")
|
||||
|
||||
# Handler function that will be called to get the result
|
||||
def handler():
|
||||
print("Handler function called")
|
||||
|
||||
# Calculate product
|
||||
product = 1
|
||||
for num in numbers:
|
||||
product = multiply(product, num)
|
||||
|
||||
print("Final calculation completed")
|
||||
|
||||
return f"The product of {numbers} is {product}"
|
||||
|
||||
# This part won't be executed when the code is run through the MCP runner
|
||||
if __name__ == "__main__":
|
||||
print("Running directly, calling handler...")
|
||||
result = handler()
|
||||
print(f"Result: {result}")
|
||||
36
qiming-mcp-proxy/mcp-proxy/fixtures/test_python_logging.py
Normal file
36
qiming-mcp-proxy/mcp-proxy/fixtures/test_python_logging.py
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
# 测试logging模块的日志捕获功能
|
||||
|
||||
import logging
|
||||
import json
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# 使用不同级别的日志
|
||||
def log_messages():
|
||||
logging.debug("这是一条DEBUG级别的日志")
|
||||
logging.info("这是一条INFO级别的日志")
|
||||
logging.warning("这是一条WARNING级别的日志")
|
||||
logging.error("这是一条ERROR级别的日志")
|
||||
logging.critical("这是一条CRITICAL级别的日志")
|
||||
|
||||
# 使用格式化
|
||||
data = {"name": "测试", "value": 42}
|
||||
logging.info(f"格式化的JSON数据: {json.dumps(data, ensure_ascii=False)}")
|
||||
|
||||
return "日志测试完成"
|
||||
|
||||
def handler(args):
|
||||
# 记录输入参数
|
||||
logging.info(f"收到参数: {args}")
|
||||
|
||||
# 生成一些日志
|
||||
result = log_messages()
|
||||
|
||||
# 返回结果
|
||||
return {
|
||||
"message": result,
|
||||
"log_count": 6, # 总共记录了6条日志
|
||||
"args": args
|
||||
}
|
||||
33
qiming-mcp-proxy/mcp-proxy/fixtures/test_python_params.py
Normal file
33
qiming-mcp-proxy/mcp-proxy/fixtures/test_python_params.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
# 打印一些调试信息
|
||||
print("Python脚本开始执行...")
|
||||
|
||||
# 定义一个简单的加法函数
|
||||
def add(a, b):
|
||||
print("正在计算: " + str(a) + " + " + str(b))
|
||||
return a + b
|
||||
|
||||
# 处理一些数据
|
||||
numbers = [1, 2, 3, 4, 5]
|
||||
print("处理数字列表: " + str(numbers))
|
||||
|
||||
# 入口函数,接收参数
|
||||
def main(args):
|
||||
print("接收到的参数: " + str(args))
|
||||
|
||||
# 从参数中获取值
|
||||
a = args.get("a", 0)
|
||||
b = args.get("b", 0)
|
||||
|
||||
# 计算结果
|
||||
result = add(a, b)
|
||||
print("计算完成: " + str(a) + " + " + str(b) + " = " + str(result))
|
||||
|
||||
# 返回结果
|
||||
return {
|
||||
"sum": result,
|
||||
"numbers": numbers,
|
||||
"message": "成功计算 " + str(a) + " + " + str(b) + " 的结果"
|
||||
}
|
||||
23
qiming-mcp-proxy/mcp-proxy/fixtures/test_python_simple.py
Normal file
23
qiming-mcp-proxy/mcp-proxy/fixtures/test_python_simple.py
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
# 简单的Python测试脚本,用于测试参数传递机制
|
||||
|
||||
def main(args: dict) -> dict:
|
||||
print(f"接收到的参数: {args}")
|
||||
|
||||
# 尝试通过两种方式获取参数
|
||||
direct_params = args.get("input", "direct_default")
|
||||
nested_params = None
|
||||
|
||||
params = args.get("params")
|
||||
if params:
|
||||
if isinstance(params, dict):
|
||||
nested_params = params.get("input", "nested_default")
|
||||
else:
|
||||
nested_params = str(params)
|
||||
|
||||
# 返回结果,展示两种方式获取的参数
|
||||
return {
|
||||
"direct_access": direct_params,
|
||||
"nested_access": nested_params,
|
||||
"args_structure": args
|
||||
}
|
||||
46
qiming-mcp-proxy/mcp-proxy/fixtures/test_python_types.py
Normal file
46
qiming-mcp-proxy/mcp-proxy/fixtures/test_python_types.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
# 打印一些调试信息
|
||||
print("Python类型测试脚本开始执行...")
|
||||
|
||||
# 入口函数,接收参数并返回不同类型的值
|
||||
def main(args):
|
||||
print("接收到的参数:", args)
|
||||
|
||||
# 获取要测试的类型
|
||||
test_type = args.get("type", "string")
|
||||
|
||||
# 根据参数返回不同类型的值
|
||||
if test_type == "string":
|
||||
print("返回字符串类型")
|
||||
return "这是一个字符串"
|
||||
|
||||
elif test_type == "number":
|
||||
print("返回数字类型")
|
||||
return 12345
|
||||
|
||||
elif test_type == "boolean":
|
||||
print("返回布尔类型")
|
||||
return True
|
||||
|
||||
elif test_type == "null":
|
||||
print("返回None类型")
|
||||
return None
|
||||
|
||||
elif test_type == "list":
|
||||
print("返回列表类型")
|
||||
return [1, 2, 3, "四", "五", True]
|
||||
|
||||
elif test_type == "dict":
|
||||
print("返回字典类型")
|
||||
return {
|
||||
"name": "测试用户",
|
||||
"age": 30,
|
||||
"is_active": True,
|
||||
"tags": ["python", "rust", "json"]
|
||||
}
|
||||
|
||||
else:
|
||||
print("未知类型,返回默认字符串")
|
||||
return "未知类型"
|
||||
41
qiming-mcp-proxy/mcp-proxy/fixtures/test_ts.ts
Normal file
41
qiming-mcp-proxy/mcp-proxy/fixtures/test_ts.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Sample TypeScript file with handler function
|
||||
|
||||
// Log some debug information
|
||||
console.log("Initializing TypeScript test...");
|
||||
|
||||
// Define a simple add function with type annotations
|
||||
function add(a: number, b: number): number {
|
||||
console.log(`Adding ${a} + ${b}`);
|
||||
return a + b;
|
||||
}
|
||||
|
||||
// Some processing with type annotations
|
||||
const numbers: number[] = [1, 2, 3, 4, 5];
|
||||
console.log("Processing numbers:", numbers);
|
||||
|
||||
// Interface for a person object
|
||||
interface Person {
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
// Create a person object
|
||||
const person: Person = {
|
||||
name: "TypeScript User",
|
||||
age: 30
|
||||
};
|
||||
console.log("Person object:", person);
|
||||
|
||||
/**
|
||||
* Handler function that will be called to get the result
|
||||
* 注意:这个函数必须存在,并且会被框架调用来获取结果
|
||||
*/
|
||||
function handler(): string {
|
||||
console.log("Handler function called");
|
||||
|
||||
// Calculate sum
|
||||
const sum = numbers.reduce((acc, num) => add(acc, num), 0);
|
||||
console.log("Final calculation completed");
|
||||
|
||||
return `Hello ${person.name}! The sum of [${numbers.join(", ")}] is ${sum}`;
|
||||
}
|
||||
46
qiming-mcp-proxy/mcp-proxy/fixtures/test_ts_params.ts
Normal file
46
qiming-mcp-proxy/mcp-proxy/fixtures/test_ts_params.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// TypeScript测试文件,演示参数传递
|
||||
|
||||
// 定义参数接口
|
||||
interface InputParams {
|
||||
a: number;
|
||||
b: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// 打印一些调试信息
|
||||
console.log("TypeScript脚本开始执行...");
|
||||
|
||||
// 定义一个带类型的加法函数
|
||||
function add(a: number, b: number): number {
|
||||
console.log(`正在计算: ${a} + ${b}`);
|
||||
return a + b;
|
||||
}
|
||||
|
||||
// 处理一些数据
|
||||
const numbers: number[] = [1, 2, 3, 4, 5];
|
||||
console.log(`处理数字列表: ${numbers}`);
|
||||
|
||||
/**
|
||||
* 处理函数,接收参数并返回结果
|
||||
* 注意:这个函数必须存在,并且会被框架调用来获取结果
|
||||
*/
|
||||
function handler(input: InputParams): object {
|
||||
console.log(`接收到的参数: ${JSON.stringify(input)}`);
|
||||
|
||||
// 从参数中获取值,提供默认值
|
||||
const a = input.a || 0;
|
||||
const b = input.b || 0;
|
||||
const name = input.name || "用户";
|
||||
|
||||
// 计算结果
|
||||
const result = add(a, b);
|
||||
console.log(`计算完成: ${a} + ${b} = ${result}`);
|
||||
|
||||
// 返回结果
|
||||
return {
|
||||
sum: result,
|
||||
numbers: numbers,
|
||||
greeting: `你好,${name}!`,
|
||||
message: `成功计算 ${a} + ${b} 的结果`
|
||||
};
|
||||
}
|
||||
468
qiming-mcp-proxy/mcp-proxy/locales/en.yml
Normal file
468
qiming-mcp-proxy/mcp-proxy/locales/en.yml
Normal file
@@ -0,0 +1,468 @@
|
||||
# ===========================================
|
||||
# Error Messages - mcp-proxy
|
||||
# ===========================================
|
||||
errors.mcp_proxy.service_not_found: "Service %{service} not found"
|
||||
errors.mcp_proxy.service_restart_cooldown: "Service %{service} is in restart cooldown, please try again later"
|
||||
errors.mcp_proxy.service_startup_in_progress: "Service %{service} is starting, please wait"
|
||||
errors.mcp_proxy.service_startup_failed: "Service startup failed: %{mcp_id}: %{reason}"
|
||||
errors.mcp_proxy.backend_connection: "Backend connection error: %{detail}"
|
||||
errors.mcp_proxy.config_parse: "Configuration parse error: %{detail}"
|
||||
errors.mcp_proxy.mcp_server_error: "MCP server error: %{detail}"
|
||||
errors.mcp_proxy.json_serialization: "JSON serialization error: %{detail}"
|
||||
errors.mcp_proxy.io_error: "IO error: %{detail}"
|
||||
errors.mcp_proxy.route_not_found: "Route not found: %{path}"
|
||||
errors.mcp_proxy.invalid_parameter: "Invalid request parameter: %{detail}"
|
||||
# ===========================================
|
||||
# Error Messages - document-parser
|
||||
# ===========================================
|
||||
errors.document_parser.config: "Configuration error: %{detail}"
|
||||
errors.document_parser.file: "File operation error: %{detail}"
|
||||
errors.document_parser.unsupported_format: "Unsupported file format: %{format}"
|
||||
errors.document_parser.parse: "Parse error: %{detail}"
|
||||
errors.document_parser.mineru: "MinerU error: %{detail}"
|
||||
errors.document_parser.markitdown: "MarkItDown error: %{detail}"
|
||||
errors.document_parser.oss: "OSS operation error: %{detail}"
|
||||
errors.document_parser.database: "Database error: %{detail}"
|
||||
errors.document_parser.network: "Network error: %{detail}"
|
||||
errors.document_parser.task: "Task error: %{detail}"
|
||||
errors.document_parser.internal: "Internal error: %{detail}"
|
||||
errors.document_parser.timeout: "Operation timeout: %{detail}"
|
||||
errors.document_parser.validation: "Validation error: %{detail}"
|
||||
errors.document_parser.environment: "Environment error: %{detail}"
|
||||
errors.document_parser.virtual_environment_path: "Virtual environment path error: %{detail}"
|
||||
errors.document_parser.permission: "Permission error: %{detail}"
|
||||
errors.document_parser.path: "Path error: %{detail}"
|
||||
errors.document_parser.queue: "Queue error: %{detail}"
|
||||
errors.document_parser.processing: "Processing error: %{detail}"
|
||||
# Error Suggestions - document-parser
|
||||
errors.document_parser.suggestions.config: "Check configuration file and environment variables"
|
||||
errors.document_parser.suggestions.file: "Check file path and permissions"
|
||||
errors.document_parser.suggestions.unsupported_format: "Check if file format is supported"
|
||||
errors.document_parser.suggestions.parse: "Check if file content is complete"
|
||||
errors.document_parser.suggestions.mineru: "Check MinerU environment configuration"
|
||||
errors.document_parser.suggestions.markitdown: "Check MarkItDown environment configuration"
|
||||
errors.document_parser.suggestions.oss: "Check OSS configuration and network connection"
|
||||
errors.document_parser.suggestions.database: "Check database connection and permissions"
|
||||
errors.document_parser.suggestions.network: "Check network connection and firewall settings"
|
||||
errors.document_parser.suggestions.task: "Check task parameters and status"
|
||||
errors.document_parser.suggestions.internal: "Contact technical support"
|
||||
errors.document_parser.suggestions.timeout: "Check network latency or increase timeout"
|
||||
errors.document_parser.suggestions.validation: "Check input parameter format"
|
||||
errors.document_parser.suggestions.environment: "Check system environment and dependency installation"
|
||||
errors.document_parser.suggestions.queue: "Check queue service status and configuration"
|
||||
errors.document_parser.suggestions.processing: "Check processing flow and data format"
|
||||
errors.document_parser.suggestions.virtual_environment_path: "Check virtual environment path and directory permissions"
|
||||
errors.document_parser.suggestions.permission: "Check file and directory permission settings"
|
||||
errors.document_parser.suggestions.path: "Check if path exists and is accessible"
|
||||
# ===========================================
|
||||
# Error Messages - oss-client
|
||||
# ===========================================
|
||||
errors.oss.config: "Configuration error: %{detail}"
|
||||
errors.oss.network: "Network error: %{detail}"
|
||||
errors.oss.file_not_found: "File not found: %{path}"
|
||||
errors.oss.permission: "Permission denied: %{detail}"
|
||||
errors.oss.io: "IO error: %{detail}"
|
||||
errors.oss.sdk: "OSS SDK error: %{detail}"
|
||||
errors.oss.file_size_exceeded: "File size exceeded: %{detail}"
|
||||
errors.oss.unsupported_file_type: "Unsupported file type: %{detail}"
|
||||
errors.oss.timeout: "Operation timeout: %{detail}"
|
||||
errors.oss.invalid_parameter: "Invalid parameter: %{detail}"
|
||||
# ===========================================
|
||||
# Error Messages - voice-cli
|
||||
# ===========================================
|
||||
errors.voice.config: "Configuration error: %{detail}"
|
||||
errors.voice.audio_processing: "Audio processing error: %{detail}"
|
||||
errors.voice.transcription: "Transcription error: %{detail}"
|
||||
errors.voice.model: "Model error: %{detail}"
|
||||
errors.voice.file_io: "File I/O error: %{detail}"
|
||||
errors.voice.http: "HTTP request error: %{detail}"
|
||||
errors.voice.serialization: "Serialization error: %{detail}"
|
||||
errors.voice.json: "JSON error: %{detail}"
|
||||
errors.voice.config_rs: "Config-rs error: %{detail}"
|
||||
errors.voice.daemon: "Daemon error: %{detail}"
|
||||
errors.voice.unsupported_format: "Audio format not supported: %{detail}"
|
||||
errors.voice.file_too_large: "File too large: %{size} bytes (max: %{max} bytes)"
|
||||
errors.voice.model_not_found: "Model not found: %{model}"
|
||||
errors.voice.invalid_model_name: "Invalid model name: %{model}"
|
||||
errors.voice.worker_pool: "Worker pool error: %{detail}"
|
||||
errors.voice.transcription_timeout: "Transcription timeout after %{seconds} seconds"
|
||||
errors.voice.transcription_failed: "Transcription failed: %{detail}"
|
||||
errors.voice.audio_conversion_failed: "Audio conversion failed: %{detail}"
|
||||
errors.voice.audio_probe_error: "Audio probe error: %{detail}"
|
||||
errors.voice.temp_file_error: "Temporary file error: %{detail}"
|
||||
errors.voice.multipart_error: "Multipart form error: %{detail}"
|
||||
errors.voice.missing_field: "Missing required field: %{field}"
|
||||
errors.voice.network: "Network error: %{detail}"
|
||||
errors.voice.storage: "Storage error: %{detail}"
|
||||
errors.voice.task_management_disabled: "Task management is disabled"
|
||||
errors.voice.not_found: "Resource not found: %{resource}"
|
||||
errors.voice.initialization: "Initialization error: %{detail}"
|
||||
errors.voice.tts: "TTS error: %{detail}"
|
||||
errors.voice.invalid_input: "Invalid input: %{detail}"
|
||||
errors.voice.io: "IO error: %{detail}"
|
||||
# ===========================================
|
||||
# CLI Messages - mcp-proxy startup
|
||||
# ===========================================
|
||||
cli.mirror.not_configured: "No mirror configured (npm/PyPI), using default sources"
|
||||
cli.mirror.npm: "npm mirror: %{url}"
|
||||
cli.mirror.pypi: "PyPI mirror: %{url}"
|
||||
cli.startup.service_starting: "MCP-Proxy starting..."
|
||||
cli.startup.version: "Version: %{version}"
|
||||
cli.startup.config_loaded: "Configuration loaded"
|
||||
cli.startup.port: "Port: %{port}"
|
||||
cli.startup.log_dir: "Log directory: %{path}"
|
||||
cli.startup.log_level: "Log level: %{level}"
|
||||
cli.startup.log_retain_days: "Log retention days: %{days}"
|
||||
cli.startup.success: "✅ Service started successfully, listening on: %{addr}"
|
||||
cli.startup.health_endpoint: "✅ Health check endpoint: http://%{addr}/health"
|
||||
cli.startup.mcp_list: "✅ MCP service list: http://%{addr}/mcp"
|
||||
cli.startup.schedule_task_started: "✅ MCP service status check scheduled task started"
|
||||
cli.startup.log_rotation_configured: "✅ Log rotation configured (keeping last %{count} log files)"
|
||||
cli.startup.system_info: "System information:"
|
||||
cli.startup.os: "Operating system: %{os}"
|
||||
cli.startup.arch: "Architecture: %{arch}"
|
||||
cli.startup.work_dir: "Working directory: %{path}"
|
||||
cli.startup.env_override: "Environment variable overrides:"
|
||||
cli.startup.env_port_override: "MCP_PROXY_PORT: %{port}"
|
||||
cli.startup.env_log_dir_override: "MCP_PROXY_LOG_DIR: %{dir}"
|
||||
cli.startup.env_log_level_override: "MCP_PROXY_LOG_LEVEL: %{level}"
|
||||
cli.startup.trying_bind: "Attempting to bind to address: %{addr}"
|
||||
cli.startup.bind_success: "Successfully bound to address: %{addr}"
|
||||
cli.startup.bind_failed: "Failed to bind address %{addr}: %{error}"
|
||||
cli.startup.init_state: "Initializing application state..."
|
||||
cli.startup.state_done: "Application state initialized"
|
||||
cli.startup.init_router: "Initializing router..."
|
||||
cli.startup.router_done: "Router initialized"
|
||||
cli.startup.warming_up: "\U0001F504 Warming up uv/deno environment dependencies..."
|
||||
cli.startup.warmup_success: "✅ uv/deno environment warmup complete"
|
||||
cli.startup.warmup_failed: "❌ uv/deno environment warmup failed: %{error}"
|
||||
cli.startup.http_server_starting: "\U0001F680 HTTP server starting, waiting for connections..."
|
||||
cli.startup.proxy_mode: "Command: proxy (HTTP server mode)"
|
||||
cli.startup.config_info: "Configuration info:"
|
||||
cli.startup.listen_port: "Listen port: %{port}"
|
||||
cli.startup.log_retention: "Log retention: %{days} days"
|
||||
# CLI Messages - mcp-proxy shutdown
|
||||
cli.shutdown.signal_received: "⚠️ Server received shutdown signal, cleaning up resources..."
|
||||
cli.shutdown.cleanup_success: "✅ Resource cleanup successful"
|
||||
cli.shutdown.cleanup_failed: "❌ Error during resource cleanup: %{error}"
|
||||
cli.shutdown.complete: "✅ Resource cleanup complete, service fully shut down"
|
||||
cli.shutdown.service_error: "❌ Service runtime error: %{error}"
|
||||
# CLI Messages - panic handling
|
||||
cli.panic.handler_started: "Program panic occurred, executing cleanup..."
|
||||
cli.panic.reason: "Panic reason: %{reason}"
|
||||
cli.panic.reason_unknown: "Panic reason: unknown"
|
||||
cli.panic.location: "Panic location: %{file}:%{line}"
|
||||
cli.panic.stack_trace: "Stack trace:"
|
||||
# ===========================================
|
||||
# CLI Messages - convert mode
|
||||
# ===========================================
|
||||
cli.convert.starting: "Starting URL mode processing"
|
||||
cli.convert.target_url: "Target URL: %{url}"
|
||||
cli.convert.protocol_specified: "Using specified protocol: %{protocol}"
|
||||
cli.convert.protocol_config: "Using configured protocol: %{protocol}"
|
||||
cli.convert.detecting_protocol: "Starting protocol auto-detection..."
|
||||
cli.convert.detect_failed: "Protocol detection failed: %{error}"
|
||||
cli.convert.using_protocol: "Using %{protocol} protocol mode"
|
||||
cli.convert.stdio_url_not_supported: "Stdio protocol does not support URL conversion"
|
||||
cli.convert.tool_whitelist: "Tool whitelist: %{tools}"
|
||||
cli.convert.tool_blacklist: "Tool blacklist: %{tools}"
|
||||
cli.convert.config_parsed: "Configuration parsed successfully"
|
||||
cli.convert.service_name: "MCP service name: %{name}"
|
||||
cli.convert.service_name_not_specified: "MCP service name: not specified (using direct URL)"
|
||||
cli.convert.cli_starting: "MCP-Proxy CLI starting"
|
||||
cli.convert.command: "Command: convert (stdio bridge mode)"
|
||||
cli.convert.version: "Version: %{version}"
|
||||
cli.convert.diagnostic_mode: "Diagnostic mode: %{enabled}"
|
||||
cli.convert.mode_direct_url: "Mode: direct URL mode"
|
||||
cli.convert.mode_remote_service: "Mode: remote service configuration mode"
|
||||
cli.convert.service_url: "Service URL: %{url}"
|
||||
cli.convert.config_protocol: "Configured protocol: %{protocol}"
|
||||
cli.convert.ping_config: "Ping interval: %{interval}s, Ping timeout: %{timeout}s"
|
||||
cli.convert.connecting_backend: "Connecting to backend service (timeout: %{timeout}s)..."
|
||||
cli.convert.detect_complete: "Protocol detection complete: %{protocol} (duration: %{duration})"
|
||||
# ===========================================
|
||||
# CLI Messages - SSE mode
|
||||
# ===========================================
|
||||
cli.sse.mode_starting: "SSE mode starting"
|
||||
cli.sse.connect_timeout: "Backend connection timeout (%{seconds}s)"
|
||||
cli.sse.connect_failed: "Backend connection failed: %{error}"
|
||||
cli.sse.connect_success: "Backend connected (duration: %{duration})"
|
||||
cli.sse.stdio_starting: "Starting stdio server..."
|
||||
cli.sse.stdio_started: "Stdio server started"
|
||||
cli.sse.waiting_events: "Waiting for stdio server events..."
|
||||
cli.sse.stdio_exit_eof: "Stdio server exited - reason: MCP client disconnected (stdin EOF)"
|
||||
cli.sse.watchdog_exit: "Watchdog task exited"
|
||||
cli.sse.normal_exit: "mcp-proxy convert (SSE mode) exited normally"
|
||||
cli.sse.watchdog_starting: "SSE Watchdog starting"
|
||||
cli.sse.max_retries: "Max retries: %{count} (0=unlimited)"
|
||||
cli.sse.monitoring_connection: "Monitoring initial connection..."
|
||||
cli.sse.initial_disconnect: "Initial connection disconnected: %{reason}"
|
||||
cli.sse.connection_alive: "Initial connection alive for: %{seconds}s"
|
||||
cli.sse.reconnect_attempt: "Reconnect attempt #%{attempt}/%{max}"
|
||||
cli.sse.backoff_time: "Backoff time: %{seconds}s"
|
||||
cli.sse.reconnect_success: "Reconnect successful (duration: %{duration})"
|
||||
cli.sse.monitoring_reconnect: "Monitoring reconnected connection..."
|
||||
cli.sse.reconnect_disconnect: "Disconnected after reconnect: %{reason}"
|
||||
cli.sse.reconnect_alive: "Reconnected connection alive for: %{seconds}s"
|
||||
cli.sse.max_retries_reached: "Max retries reached (%{count}), stopping reconnect"
|
||||
cli.sse.watchdog_exit_msg: "SSE Watchdog exited"
|
||||
# ===========================================
|
||||
# CLI Messages - Stream mode
|
||||
# ===========================================
|
||||
cli.stream.mode_starting: "Stream mode starting"
|
||||
cli.stream.connect_timeout: "Backend connection timeout (%{seconds}s)"
|
||||
cli.stream.connect_failed: "Backend connection failed: %{error}"
|
||||
cli.stream.connect_success: "Backend connected (duration: %{duration})"
|
||||
cli.stream.stdio_starting: "Starting stdio server..."
|
||||
cli.stream.stdio_started: "Stdio server started"
|
||||
cli.stream.waiting_events: "Waiting for stdio server events..."
|
||||
cli.stream.stdio_exit_eof: "Stdio server exited - reason: MCP client disconnected (stdin EOF)"
|
||||
cli.stream.watchdog_exit: "Watchdog task exited"
|
||||
cli.stream.normal_exit: "mcp-proxy convert (Stream mode) exited normally"
|
||||
# ===========================================
|
||||
# CLI Messages - Command mode
|
||||
# ===========================================
|
||||
cli.command.local_mode: "Mode: local command mode"
|
||||
cli.command.command: "Command: %{cmd} %{args}"
|
||||
cli.command.ctrl_c: "Received Ctrl+C signal, shutting down..."
|
||||
# ===========================================
|
||||
# CLI Messages - monitoring
|
||||
# ===========================================
|
||||
cli.monitoring.health: "Monitoring %{protocol} connection health"
|
||||
cli.monitoring.health_check_status: "\U0001F493 [%{protocol}][Health Check] Connection status: %{status} (connection check only, no list_tools), check #%{count}, alive: %{seconds}s"
|
||||
cli.monitoring.health_check_normal: "\U0001F493 [%{protocol}][Health Check] Backend service healthy (list_tools verified), Ping #%{count}, alive: %{seconds}s"
|
||||
# ===========================================
|
||||
# Diagnostic Messages
|
||||
# ===========================================
|
||||
diagnostic.report_header: "========== Diagnostic Report =========="
|
||||
diagnostic.connection_protocol: "Connection protocol: %{protocol}"
|
||||
diagnostic.service_url: "Service URL: %{url}"
|
||||
diagnostic.connection_duration: "Connection duration: %{seconds} seconds"
|
||||
diagnostic.disconnect_reason: "Disconnect reason: %{reason}"
|
||||
diagnostic.error_type: "Error type: %{error_type}"
|
||||
diagnostic.possible_causes: "Possible causes:"
|
||||
diagnostic.suggestions: "Suggestions:"
|
||||
# Diagnostic - 30s timeout analysis
|
||||
diagnostic.analysis.timeout_30s: "⚠️ Connection dropped at ~30 seconds, likely causes:"
|
||||
diagnostic.analysis.timeout_30s_cause1: "Server-side 30-second timeout limit"
|
||||
diagnostic.analysis.timeout_30s_cause2: "Load balancer (e.g., Nginx/ALB) default timeout"
|
||||
diagnostic.analysis.timeout_30s_cause3: "Cloud provider gateway timeout limit"
|
||||
# Diagnostic - Quick disconnect analysis
|
||||
diagnostic.analysis.quick_disconnect: "⚠️ Connection dropped quickly (%{seconds}s), possible causes:"
|
||||
diagnostic.analysis.quick_disconnect_cause1: "Authentication failed or invalid token"
|
||||
diagnostic.analysis.quick_disconnect_cause2: "Server rejected connection"
|
||||
diagnostic.analysis.quick_disconnect_cause3: "Network instability"
|
||||
# Diagnostic - Long connection analysis
|
||||
diagnostic.analysis.long_connection: "✅ Connection maintained for a long time (%{seconds}s), possible causes:"
|
||||
diagnostic.analysis.long_connection_cause1: "Tool call execution took too long"
|
||||
diagnostic.analysis.long_connection_cause2: "Network fluctuation caused disconnect"
|
||||
# Diagnostic suggestions
|
||||
diagnostic.suggestion.timeout_30s: "Contact service provider to increase timeout limit"
|
||||
diagnostic.suggestion.timeout_client: "Use --request-timeout parameter to set client timeout"
|
||||
diagnostic.suggestion.async_mode: "Consider using async processing mode (webhook callback)"
|
||||
diagnostic.suggestion.ping_interval: "Try increasing ping interval: --ping-interval %{seconds}"
|
||||
diagnostic.suggestion.ping_timeout: "Increase ping timeout: --ping-timeout %{seconds}"
|
||||
diagnostic.suggestion.ping_disable: "Or disable ping: --ping-interval 0"
|
||||
# ===========================================
|
||||
# Error Classification
|
||||
# ===========================================
|
||||
error_classify.timeout_30s: "30s timeout (possibly server limit)"
|
||||
error_classify.service_unavailable_503: "Service unavailable (503)"
|
||||
error_classify.internal_server_error_500: "Internal server error (500)"
|
||||
error_classify.bad_gateway_502: "Bad gateway (502)"
|
||||
error_classify.gateway_timeout_504: "Gateway timeout (504)"
|
||||
error_classify.unauthorized_401: "Unauthorized (401)"
|
||||
error_classify.forbidden_403: "Forbidden (403)"
|
||||
error_classify.not_found_404: "Not found (404)"
|
||||
error_classify.request_timeout_408: "Request timeout (408)"
|
||||
error_classify.timeout: "Timeout"
|
||||
error_classify.connection_refused: "Connection refused"
|
||||
error_classify.connection_reset: "Connection reset"
|
||||
error_classify.connection_closed: "Connection closed"
|
||||
error_classify.dns_failed: "DNS resolution failed"
|
||||
error_classify.ssl_tls_error: "SSL/TLS error"
|
||||
error_classify.network_error: "Network error"
|
||||
error_classify.session_error: "Session error"
|
||||
error_classify.unknown_error: "Unknown error"
|
||||
# ===========================================
|
||||
# CLI Messages - health command
|
||||
# ===========================================
|
||||
cli.health.checking: "\U0001F50D Health checking service: %{url}"
|
||||
cli.health.using_protocol: "\U0001F50D Using specified protocol: %{protocol}"
|
||||
cli.health.detecting_protocol: "\U0001F50D Detecting protocol..."
|
||||
cli.health.detected_protocol: "\U0001F50D Detected %{protocol} protocol"
|
||||
cli.health.healthy: "✅ Service healthy"
|
||||
cli.health.unhealthy: "❌ Service unhealthy"
|
||||
cli.health.stdio_not_supported: "health command does not support stdio protocol"
|
||||
# ===========================================
|
||||
# CLI Messages - check command
|
||||
# ===========================================
|
||||
cli.check.checking: "\U0001F50D Checking service: %{url}"
|
||||
cli.check.healthy: "✅ Service healthy, detected %{protocol} protocol"
|
||||
cli.check.failed: "❌ Service check failed: %{error}"
|
||||
# ===========================================
|
||||
# CLI Messages - document-parser
|
||||
# ===========================================
|
||||
doc_parser.startup.app_info: "=== %{name} v%{version} starting ==="
|
||||
doc_parser.startup.config_summary: "Configuration summary: %{summary}"
|
||||
doc_parser.startup.listening: "Service listening on: %{host}:%{port}"
|
||||
doc_parser.startup.checking_python: "Starting Python environment check and initialization..."
|
||||
doc_parser.startup.checking_venv: "Checking and activating virtual environment..."
|
||||
doc_parser.startup.venv_activate_failed: "Virtual environment auto-activation failed: %{error}"
|
||||
doc_parser.startup.venv_activate_manual: "Please manually activate virtual environment: source ./venv/bin/activate"
|
||||
doc_parser.startup.venv_activated: "Virtual environment auto-activated"
|
||||
doc_parser.startup.env_check_failed: "Environment check failed, will auto-install in background: %{error}"
|
||||
doc_parser.startup.mineru_not_installed: "MinerU dependencies not installed, starting background auto-install..."
|
||||
doc_parser.startup.markitdown_not_installed: "MarkItDown dependencies not installed, starting background auto-install..."
|
||||
doc_parser.startup.install_complete: "Background Python environment installation complete"
|
||||
doc_parser.startup.install_failed: "Background Python environment installation failed: %{error}"
|
||||
doc_parser.startup.install_task_started: "Python dependency installation task started (background), service will start normally"
|
||||
doc_parser.startup.mineru_ready: "MinerU dependencies installed, version: %{version}"
|
||||
doc_parser.startup.markitdown_ready: "MarkItDown dependencies installed"
|
||||
doc_parser.startup.python_ready: "Python environment check complete, all dependencies ready"
|
||||
doc_parser.startup.state_failed: "Failed to create application state: %{error}"
|
||||
doc_parser.startup.health_check_failed: "Application health check failed: %{error}"
|
||||
doc_parser.startup.state_ready: "Application state initialized successfully"
|
||||
doc_parser.startup.http_routes_ready: "HTTP routes initialized successfully"
|
||||
doc_parser.startup.background_tasks_started: "Background tasks started"
|
||||
doc_parser.startup.service_ready: "Service started successfully, waiting for connections..."
|
||||
doc_parser.shutdown.service_stopped: "Service stopped"
|
||||
doc_parser.shutdown.ctrl_c_received: "Received Ctrl+C signal, starting graceful shutdown..."
|
||||
doc_parser.shutdown.terminate_received: "Received terminate signal, starting graceful shutdown..."
|
||||
doc_parser.shutdown.closing: "Shutting down service..."
|
||||
# uv-init command
|
||||
doc_parser.uv_init.starting: "\U0001F680 Starting uv virtual environment and dependency initialization in current directory..."
|
||||
doc_parser.uv_init.current_dir: "\U0001F4C1 Current working directory: %{path}"
|
||||
doc_parser.uv_init.venv_path: "\U0001F4C1 Virtual environment will be created at: ./venv/"
|
||||
doc_parser.uv_init.validating_dir: "\U0001F50D Validating current directory settings..."
|
||||
doc_parser.uv_init.dir_valid: " ✅ Directory validation passed"
|
||||
doc_parser.uv_init.warnings_found: " ⚠️ Found %{count} warnings"
|
||||
doc_parser.uv_init.dir_invalid: " ❌ Directory validation failed, found %{count} issues"
|
||||
doc_parser.uv_init.auto_fixing: " \U0001F527 Attempting to auto-fix %{count} issues..."
|
||||
doc_parser.uv_init.fix_success: " ✅ %{message}"
|
||||
doc_parser.uv_init.fix_failed: " ❌ Cleanup failed: %{error}"
|
||||
doc_parser.uv_init.manual_fix_hint: " \U0001F4A1 Please manually resolve the following issues:"
|
||||
doc_parser.uv_init.dir_validation_failed: "Directory validation failed, please resolve the above issues and try again"
|
||||
doc_parser.uv_init.dir_validation_error: " ⚠️ Directory validation failed: %{error}"
|
||||
doc_parser.uv_init.continue_install: " Continuing installation, but may encounter issues..."
|
||||
# Environment check
|
||||
doc_parser.check.checking_env: "\U0001F50D Checking current environment status..."
|
||||
doc_parser.check.env_check_complete: " Environment check complete:"
|
||||
doc_parser.check.python_available: "✅ Available"
|
||||
doc_parser.check.python_unavailable: "❌ Unavailable"
|
||||
doc_parser.check.uv_available: "✅ Available"
|
||||
doc_parser.check.uv_unavailable: "❌ Unavailable"
|
||||
doc_parser.check.venv_active: "✅ Active"
|
||||
doc_parser.check.venv_inactive: "❌ Inactive"
|
||||
doc_parser.check.mineru_available: "✅ Available"
|
||||
doc_parser.check.mineru_unavailable: "❌ Unavailable"
|
||||
doc_parser.check.markitdown_available: "✅ Available"
|
||||
doc_parser.check.markitdown_unavailable: "❌ Unavailable"
|
||||
doc_parser.check.env_check_failed: " ⚠️ Environment check failed: %{error}"
|
||||
doc_parser.check.continue_install: " Continuing installation..."
|
||||
# Installation process
|
||||
doc_parser.install.all_ready: "✨ All dependencies are ready, no installation needed!"
|
||||
doc_parser.install.plan: "\U0001F4CB Installation plan:"
|
||||
doc_parser.install.install_uv: "Install uv tool"
|
||||
doc_parser.install.create_venv: "Create virtual environment (./venv/)"
|
||||
doc_parser.install.install_mineru: "Install MinerU dependencies"
|
||||
doc_parser.install.install_markitdown: "Install MarkItDown dependencies"
|
||||
doc_parser.install.starting: "⚙️ Starting Python environment and dependency setup..."
|
||||
doc_parser.install.wait_hint: "This may take a few minutes, please wait..."
|
||||
doc_parser.install.path_issues: "⚠️ Detected potential path issues:"
|
||||
doc_parser.install.auto_fixing: "\U0001F527 Attempting to auto-fix issues..."
|
||||
doc_parser.install.fixed: "✅ Fixed the following issues:"
|
||||
doc_parser.install.cannot_auto_fix: "Cannot auto-fix, please manually resolve the above issues"
|
||||
doc_parser.install.fix_failed: "❌ Auto-fix failed: %{error}"
|
||||
doc_parser.install.manual_fix_hint: "\U0001F4A1 Manual fix suggestions:"
|
||||
doc_parser.install.python_setup_complete: "✅ Python environment setup complete!"
|
||||
doc_parser.install.python_setup_failed: "❌ Python environment setup failed: %{error}"
|
||||
doc_parser.install.detailed_hints: "\U0001F4A1 Detailed troubleshooting suggestions:"
|
||||
doc_parser.install.diagnostic_commands: "\U0001F50D Diagnostic commands:"
|
||||
doc_parser.install.check_env: "Check environment status: document-parser check"
|
||||
doc_parser.install.view_logs: "View detailed logs: check logs/ directory"
|
||||
# Verification
|
||||
doc_parser.verify.starting: "\U0001F50D Verifying installation results..."
|
||||
doc_parser.verify.complete: "Verification complete:"
|
||||
doc_parser.verify.version: "Version: %{version}"
|
||||
doc_parser.verify.partial_issues: "⚠️ Some dependencies may have installation issues"
|
||||
doc_parser.verify.need_fix: "\U0001F527 Issues to resolve:"
|
||||
doc_parser.verify.suggestion: "Suggestion: %{suggestion}"
|
||||
doc_parser.verify.init_incomplete: "Environment initialization incomplete"
|
||||
doc_parser.verify.failed: "❌ Verification failed: %{error}"
|
||||
# Success messages
|
||||
doc_parser.success.init_complete: "\U0001F389 uv environment initialization complete!"
|
||||
doc_parser.success.all_ready: "✨ All dependencies are ready, you can now start the server"
|
||||
doc_parser.success.venv_activate: "\U0001F4CB Virtual environment activation commands:"
|
||||
doc_parser.success.start_server: "\U0001F680 Start server:"
|
||||
doc_parser.success.use_uv: "\U0001F527 Or use uv to run commands directly:"
|
||||
doc_parser.success.more_help: "\U0001F4DA More help:"
|
||||
doc_parser.success.tips: "\U0001F4A1 Tips:"
|
||||
doc_parser.success.venv_location: "Virtual environment location: ./venv/"
|
||||
doc_parser.success.python_path: "Python executable: ./venv/bin/python (Linux/macOS) or .\\venv\\Scripts\\python.exe (Windows)"
|
||||
doc_parser.success.troubleshoot_hint: "If issues occur, run 'document-parser troubleshoot' for detailed guide"
|
||||
# troubleshoot command
|
||||
doc_parser.troubleshoot.title: "\U0001F527 Document Parser Troubleshooting Guide"
|
||||
doc_parser.troubleshoot.env_overview: "\U0001F4CA Current Environment Overview:"
|
||||
doc_parser.troubleshoot.work_dir: "Working directory: %{path}"
|
||||
doc_parser.troubleshoot.venv_path: "Virtual environment: ./venv/"
|
||||
doc_parser.troubleshoot.os: "Operating system: %{os}"
|
||||
# Virtual environment issues
|
||||
doc_parser.troubleshoot.venv_problems: "\U0001F3E0 1. Virtual Environment Issues"
|
||||
doc_parser.troubleshoot.venv_create_failed: "❓ Issue: Virtual environment creation failed"
|
||||
doc_parser.troubleshoot.diagnostic_steps: "\U0001F50D Diagnostic steps:"
|
||||
doc_parser.troubleshoot.solutions: "\U0001F4A1 Solutions:"
|
||||
doc_parser.troubleshoot.venv_activate_failed: "❓ Issue: Virtual environment activation failed"
|
||||
# Dependency installation issues
|
||||
doc_parser.troubleshoot.deps_problems: "\U0001F4E6 2. Dependency Installation Issues"
|
||||
doc_parser.troubleshoot.uv_not_installed: "❓ Issue: UV tool not installed or unavailable"
|
||||
doc_parser.troubleshoot.mineru_markitdown_failed: "❓ Issue: MinerU or MarkItDown installation failed"
|
||||
# Network issues
|
||||
doc_parser.troubleshoot.network_problems: "\U0001F310 3. Network and Download Issues"
|
||||
doc_parser.troubleshoot.network_timeout: "❓ Issue: Network connection timeout or download failed"
|
||||
# System environment issues
|
||||
doc_parser.troubleshoot.system_problems: "⚙️ 4. System Environment Issues"
|
||||
doc_parser.troubleshoot.python_incompatible: "❓ Issue: Python version incompatible"
|
||||
doc_parser.troubleshoot.cuda_config: "❓ Issue: CUDA environment configuration (optional, for GPU acceleration)"
|
||||
# Diagnostic commands
|
||||
doc_parser.troubleshoot.diagnostic_commands: "\U0001F50D 5. Common Diagnostic Commands"
|
||||
doc_parser.troubleshoot.env_check: "Environment check:"
|
||||
doc_parser.troubleshoot.manual_verify: "Manual verification:"
|
||||
doc_parser.troubleshoot.view_logs: "Log viewing:"
|
||||
# Get help
|
||||
doc_parser.troubleshoot.more_help: "\U0001F198 6. Get More Help"
|
||||
doc_parser.troubleshoot.if_unsolved: "If the above methods don't resolve the issue:"
|
||||
# Real-time diagnosis
|
||||
doc_parser.troubleshoot.realtime_diagnosis: "\U0001F52C Real-time Environment Diagnosis"
|
||||
doc_parser.troubleshoot.env_good: "✅ Environment status good, all dependencies ready"
|
||||
doc_parser.troubleshoot.issues_found: "⚠️ Found the following issues:"
|
||||
doc_parser.troubleshoot.env_check_failed: "❌ Environment check failed: %{error}"
|
||||
doc_parser.troubleshoot.follow_guide: "Please follow the above guide for troubleshooting"
|
||||
# Tip
|
||||
doc_parser.troubleshoot.tip: "\U0001F4A1 Tip: Most issues can be resolved by re-running 'document-parser uv-init'"
|
||||
# Background cleanup
|
||||
doc_parser.background.cleanup_failed: "Failed to cleanup expired data: %{error}"
|
||||
doc_parser.background.cleanup_complete: "Background cleanup task completed"
|
||||
# Signal handling
|
||||
doc_parser.signal.ctrl_c_failed: "Failed to listen for Ctrl+C signal"
|
||||
doc_parser.signal.terminate_failed: "Failed to listen for terminate signal"
|
||||
# ===========================================
|
||||
# Common Messages
|
||||
# ===========================================
|
||||
common.yes: "Yes"
|
||||
common.no: "No"
|
||||
common.success: "Success"
|
||||
common.failed: "Failed"
|
||||
common.error: "Error"
|
||||
common.warning: "Warning"
|
||||
common.info: "Info"
|
||||
common.loading: "Loading..."
|
||||
common.please_wait: "Please wait..."
|
||||
common.retry: "Retry"
|
||||
common.cancel: "Cancel"
|
||||
common.confirm: "Confirm"
|
||||
common.back: "Back"
|
||||
common.next: "Next"
|
||||
common.previous: "Previous"
|
||||
common.done: "Done"
|
||||
common.skip: "Skip"
|
||||
468
qiming-mcp-proxy/mcp-proxy/locales/zh-CN.yml
Normal file
468
qiming-mcp-proxy/mcp-proxy/locales/zh-CN.yml
Normal file
@@ -0,0 +1,468 @@
|
||||
# ===========================================
|
||||
# 错误消息 - mcp-proxy
|
||||
# ===========================================
|
||||
errors.mcp_proxy.service_not_found: "服务 %{service} 未找到"
|
||||
errors.mcp_proxy.service_restart_cooldown: "服务 %{service} 在重启冷却期内,请稍后再试"
|
||||
errors.mcp_proxy.service_startup_in_progress: "服务 %{service} 正在启动中,请稍后再试"
|
||||
errors.mcp_proxy.service_startup_failed: "服务启动失败: %{mcp_id}: %{reason}"
|
||||
errors.mcp_proxy.backend_connection: "后端连接错误: %{detail}"
|
||||
errors.mcp_proxy.config_parse: "配置解析错误: %{detail}"
|
||||
errors.mcp_proxy.mcp_server_error: "MCP 服务器错误: %{detail}"
|
||||
errors.mcp_proxy.json_serialization: "JSON 序列化错误: %{detail}"
|
||||
errors.mcp_proxy.io_error: "IO 错误: %{detail}"
|
||||
errors.mcp_proxy.route_not_found: "路由未找到: %{path}"
|
||||
errors.mcp_proxy.invalid_parameter: "无效的请求参数: %{detail}"
|
||||
# ===========================================
|
||||
# 错误消息 - document-parser
|
||||
# ===========================================
|
||||
errors.document_parser.config: "配置错误: %{detail}"
|
||||
errors.document_parser.file: "文件操作错误: %{detail}"
|
||||
errors.document_parser.unsupported_format: "不支持的文件格式: %{format}"
|
||||
errors.document_parser.parse: "解析错误: %{detail}"
|
||||
errors.document_parser.mineru: "MinerU错误: %{detail}"
|
||||
errors.document_parser.markitdown: "MarkItDown错误: %{detail}"
|
||||
errors.document_parser.oss: "OSS操作错误: %{detail}"
|
||||
errors.document_parser.database: "数据库错误: %{detail}"
|
||||
errors.document_parser.network: "网络错误: %{detail}"
|
||||
errors.document_parser.task: "任务错误: %{detail}"
|
||||
errors.document_parser.internal: "内部错误: %{detail}"
|
||||
errors.document_parser.timeout: "操作超时: %{detail}"
|
||||
errors.document_parser.validation: "验证错误: %{detail}"
|
||||
errors.document_parser.environment: "环境错误: %{detail}"
|
||||
errors.document_parser.virtual_environment_path: "虚拟环境路径错误: %{detail}"
|
||||
errors.document_parser.permission: "权限错误: %{detail}"
|
||||
errors.document_parser.path: "路径错误: %{detail}"
|
||||
errors.document_parser.queue: "队列错误: %{detail}"
|
||||
errors.document_parser.processing: "处理错误: %{detail}"
|
||||
# 错误建议 - document-parser
|
||||
errors.document_parser.suggestions.config: "检查配置文件和环境变量"
|
||||
errors.document_parser.suggestions.file: "检查文件路径和权限"
|
||||
errors.document_parser.suggestions.unsupported_format: "检查文件格式是否支持"
|
||||
errors.document_parser.suggestions.parse: "检查文件内容是否完整"
|
||||
errors.document_parser.suggestions.mineru: "检查MinerU环境配置"
|
||||
errors.document_parser.suggestions.markitdown: "检查MarkItDown环境配置"
|
||||
errors.document_parser.suggestions.oss: "检查OSS配置和网络连接"
|
||||
errors.document_parser.suggestions.database: "检查数据库连接和权限"
|
||||
errors.document_parser.suggestions.network: "检查网络连接和防火墙设置"
|
||||
errors.document_parser.suggestions.task: "检查任务参数和状态"
|
||||
errors.document_parser.suggestions.internal: "联系技术支持"
|
||||
errors.document_parser.suggestions.timeout: "检查网络延迟或增加超时时间"
|
||||
errors.document_parser.suggestions.validation: "检查输入参数格式"
|
||||
errors.document_parser.suggestions.environment: "检查系统环境和依赖安装"
|
||||
errors.document_parser.suggestions.queue: "检查队列服务状态和配置"
|
||||
errors.document_parser.suggestions.processing: "检查处理流程和数据格式"
|
||||
errors.document_parser.suggestions.virtual_environment_path: "检查虚拟环境路径和目录权限"
|
||||
errors.document_parser.suggestions.permission: "检查文件和目录权限设置"
|
||||
errors.document_parser.suggestions.path: "检查路径是否存在和可访问"
|
||||
# ===========================================
|
||||
# 错误消息 - oss-client
|
||||
# ===========================================
|
||||
errors.oss.config: "配置错误: %{detail}"
|
||||
errors.oss.network: "网络错误: %{detail}"
|
||||
errors.oss.file_not_found: "文件不存在: %{path}"
|
||||
errors.oss.permission: "权限不足: %{detail}"
|
||||
errors.oss.io: "IO错误: %{detail}"
|
||||
errors.oss.sdk: "OSS SDK错误: %{detail}"
|
||||
errors.oss.file_size_exceeded: "文件大小超出限制: %{detail}"
|
||||
errors.oss.unsupported_file_type: "不支持的文件类型: %{detail}"
|
||||
errors.oss.timeout: "操作超时: %{detail}"
|
||||
errors.oss.invalid_parameter: "无效的参数: %{detail}"
|
||||
# ===========================================
|
||||
# 错误消息 - voice-cli
|
||||
# ===========================================
|
||||
errors.voice.config: "配置错误: %{detail}"
|
||||
errors.voice.audio_processing: "音频处理错误: %{detail}"
|
||||
errors.voice.transcription: "转录错误: %{detail}"
|
||||
errors.voice.model: "模型错误: %{detail}"
|
||||
errors.voice.file_io: "文件I/O错误: %{detail}"
|
||||
errors.voice.http: "HTTP请求错误: %{detail}"
|
||||
errors.voice.serialization: "序列化错误: %{detail}"
|
||||
errors.voice.json: "JSON错误: %{detail}"
|
||||
errors.voice.config_rs: "配置读取错误: %{detail}"
|
||||
errors.voice.daemon: "守护进程错误: %{detail}"
|
||||
errors.voice.unsupported_format: "不支持的音频格式: %{detail}"
|
||||
errors.voice.file_too_large: "文件过大: %{size} 字节 (最大: %{max} 字节)"
|
||||
errors.voice.model_not_found: "模型未找到: %{model}"
|
||||
errors.voice.invalid_model_name: "无效的模型名称: %{model}"
|
||||
errors.voice.worker_pool: "工作池错误: %{detail}"
|
||||
errors.voice.transcription_timeout: "转录超时 (%{seconds} 秒后)"
|
||||
errors.voice.transcription_failed: "转录失败: %{detail}"
|
||||
errors.voice.audio_conversion_failed: "音频转换失败: %{detail}"
|
||||
errors.voice.audio_probe_error: "音频探测错误: %{detail}"
|
||||
errors.voice.temp_file_error: "临时文件错误: %{detail}"
|
||||
errors.voice.multipart_error: "Multipart表单错误: %{detail}"
|
||||
errors.voice.missing_field: "缺少必填字段: %{field}"
|
||||
errors.voice.network: "网络错误: %{detail}"
|
||||
errors.voice.storage: "存储错误: %{detail}"
|
||||
errors.voice.task_management_disabled: "任务管理已禁用"
|
||||
errors.voice.not_found: "资源未找到: %{resource}"
|
||||
errors.voice.initialization: "初始化错误: %{detail}"
|
||||
errors.voice.tts: "TTS错误: %{detail}"
|
||||
errors.voice.invalid_input: "无效输入: %{detail}"
|
||||
errors.voice.io: "IO错误: %{detail}"
|
||||
# ===========================================
|
||||
# CLI 消息 - mcp-proxy 启动
|
||||
# ===========================================
|
||||
cli.mirror.not_configured: "未配置镜像源(npm/PyPI),将使用默认源"
|
||||
cli.mirror.npm: "npm 镜像: %{url}"
|
||||
cli.mirror.pypi: "PyPI 镜像: %{url}"
|
||||
cli.startup.service_starting: "MCP-Proxy 启动中..."
|
||||
cli.startup.version: "版本: %{version}"
|
||||
cli.startup.config_loaded: "配置加载完成"
|
||||
cli.startup.port: "端口: %{port}"
|
||||
cli.startup.log_dir: "日志目录: %{path}"
|
||||
cli.startup.log_level: "日志级别: %{level}"
|
||||
cli.startup.log_retain_days: "日志保留天数: %{days}"
|
||||
cli.startup.success: "✅ 服务启动成功,监听地址: %{addr}"
|
||||
cli.startup.health_endpoint: "✅ 健康检查端点: http://%{addr}/health"
|
||||
cli.startup.mcp_list: "✅ MCP 服务列表: http://%{addr}/mcp"
|
||||
cli.startup.schedule_task_started: "✅ MCP服务状态检查定时任务已启动"
|
||||
cli.startup.log_rotation_configured: "✅ 日志自动轮转已配置(保留最近 %{count} 个日志文件)"
|
||||
cli.startup.system_info: "系统信息:"
|
||||
cli.startup.os: "操作系统: %{os}"
|
||||
cli.startup.arch: "架构: %{arch}"
|
||||
cli.startup.work_dir: "工作目录: %{path}"
|
||||
cli.startup.env_override: "环境变量覆盖:"
|
||||
cli.startup.env_port_override: "MCP_PROXY_PORT: %{port}"
|
||||
cli.startup.env_log_dir_override: "MCP_PROXY_LOG_DIR: %{dir}"
|
||||
cli.startup.env_log_level_override: "MCP_PROXY_LOG_LEVEL: %{level}"
|
||||
cli.startup.trying_bind: "尝试绑定到地址: %{addr}"
|
||||
cli.startup.bind_success: "成功绑定到地址: %{addr}"
|
||||
cli.startup.bind_failed: "绑定地址 %{addr} 失败: %{error}"
|
||||
cli.startup.init_state: "初始化应用状态..."
|
||||
cli.startup.state_done: "应用状态初始化完成"
|
||||
cli.startup.init_router: "初始化路由..."
|
||||
cli.startup.router_done: "路由初始化完成"
|
||||
cli.startup.warming_up: "\U0001F504 开始预热 uv/deno 环境依赖..."
|
||||
cli.startup.warmup_success: "✅ 预热 uv/deno 环境依赖完成"
|
||||
cli.startup.warmup_failed: "❌ 预热 uv/deno 环境依赖失败: %{error}"
|
||||
cli.startup.http_server_starting: "\U0001F680 HTTP 服务器启动,等待连接..."
|
||||
cli.startup.proxy_mode: "命令: proxy (HTTP 服务器模式)"
|
||||
cli.startup.config_info: "配置信息:"
|
||||
cli.startup.listen_port: "监听端口: %{port}"
|
||||
cli.startup.log_retention: "日志保留: %{days} 天"
|
||||
# CLI 消息 - mcp-proxy 关闭
|
||||
cli.shutdown.signal_received: "⚠️ 服务器收到关闭信号,开始清理资源..."
|
||||
cli.shutdown.cleanup_success: "✅ 资源清理成功"
|
||||
cli.shutdown.cleanup_failed: "❌ 清理资源时出错: %{error}"
|
||||
cli.shutdown.complete: "✅ 资源清理完成,服务已完全关闭"
|
||||
cli.shutdown.service_error: "❌ 服务运行错误: %{error}"
|
||||
# CLI 消息 - panic 处理
|
||||
cli.panic.handler_started: "程序发生panic,执行清理..."
|
||||
cli.panic.reason: "Panic 原因: %{reason}"
|
||||
cli.panic.reason_unknown: "Panic 原因: 未知"
|
||||
cli.panic.location: "Panic 位置: %{file}:%{line}"
|
||||
cli.panic.stack_trace: "堆栈跟踪:"
|
||||
# ===========================================
|
||||
# CLI 消息 - convert 模式
|
||||
# ===========================================
|
||||
cli.convert.starting: "开始 URL 模式处理"
|
||||
cli.convert.target_url: "目标 URL: %{url}"
|
||||
cli.convert.protocol_specified: "使用命令行指定协议: %{protocol}"
|
||||
cli.convert.protocol_config: "使用配置文件协议: %{protocol}"
|
||||
cli.convert.detecting_protocol: "开始自动检测协议..."
|
||||
cli.convert.detect_failed: "协议检测失败: %{error}"
|
||||
cli.convert.using_protocol: "使用 %{protocol} 协议模式"
|
||||
cli.convert.stdio_url_not_supported: "Stdio 协议不支持通过 URL 转换"
|
||||
cli.convert.tool_whitelist: "工具白名单: %{tools}"
|
||||
cli.convert.tool_blacklist: "工具黑名单: %{tools}"
|
||||
cli.convert.config_parsed: "配置解析成功"
|
||||
cli.convert.service_name: "MCP 服务名称: %{name}"
|
||||
cli.convert.service_name_not_specified: "MCP 服务名称: 未指定(使用 direct URL)"
|
||||
cli.convert.cli_starting: "MCP-Proxy CLI 启动"
|
||||
cli.convert.command: "命令: convert (stdio 桥接模式)"
|
||||
cli.convert.version: "版本: %{version}"
|
||||
cli.convert.diagnostic_mode: "诊断模式: %{enabled}"
|
||||
cli.convert.mode_direct_url: "模式: 直接 URL 模式"
|
||||
cli.convert.mode_remote_service: "模式: 远程服务配置模式"
|
||||
cli.convert.service_url: "服务 URL: %{url}"
|
||||
cli.convert.config_protocol: "配置协议: %{protocol}"
|
||||
cli.convert.ping_config: "Ping 间隔: %{interval}s, Ping 超时: %{timeout}s"
|
||||
cli.convert.connecting_backend: "开始连接到后端服务 (超时: %{timeout}s)..."
|
||||
cli.convert.detect_complete: "协议检测完成: %{protocol} (耗时: %{duration})"
|
||||
# ===========================================
|
||||
# CLI 消息 - SSE 模式
|
||||
# ===========================================
|
||||
cli.sse.mode_starting: "SSE 模式启动"
|
||||
cli.sse.connect_timeout: "连接后端超时 (%{seconds}s)"
|
||||
cli.sse.connect_failed: "连接后端失败: %{error}"
|
||||
cli.sse.connect_success: "后端连接成功 (耗时: %{duration})"
|
||||
cli.sse.stdio_starting: "启动 stdio server..."
|
||||
cli.sse.stdio_started: "stdio server 已启动"
|
||||
cli.sse.waiting_events: "开始等待 stdio server 事件..."
|
||||
cli.sse.stdio_exit_eof: "stdio server 退出 - 原因: MCP 客户端断开连接 (stdin EOF)"
|
||||
cli.sse.watchdog_exit: "Watchdog 任务退出"
|
||||
cli.sse.normal_exit: "mcp-proxy convert (SSE 模式) 正常退出"
|
||||
cli.sse.watchdog_starting: "SSE Watchdog 启动"
|
||||
cli.sse.max_retries: "最大重试次数: %{count} (0=无限)"
|
||||
cli.sse.monitoring_connection: "开始监控初始连接..."
|
||||
cli.sse.initial_disconnect: "初始连接断开: %{reason}"
|
||||
cli.sse.connection_alive: "初始连接存活时长: %{seconds}s"
|
||||
cli.sse.reconnect_attempt: "重连尝试 #%{attempt}/%{max}"
|
||||
cli.sse.backoff_time: "退避时间: %{seconds}s"
|
||||
cli.sse.reconnect_success: "重连成功 (耗时: %{duration})"
|
||||
cli.sse.monitoring_reconnect: "开始监控重连后的连接..."
|
||||
cli.sse.reconnect_disconnect: "重连后断开: %{reason}"
|
||||
cli.sse.reconnect_alive: "重连后存活时长: %{seconds}s"
|
||||
cli.sse.max_retries_reached: "达到最大重试次数 (%{count}), 停止重连"
|
||||
cli.sse.watchdog_exit_msg: "SSE Watchdog 退出"
|
||||
# ===========================================
|
||||
# CLI 消息 - Stream 模式
|
||||
# ===========================================
|
||||
cli.stream.mode_starting: "Stream 模式启动"
|
||||
cli.stream.connect_timeout: "连接后端超时 (%{seconds}s)"
|
||||
cli.stream.connect_failed: "连接后端失败: %{error}"
|
||||
cli.stream.connect_success: "后端连接成功 (耗时: %{duration})"
|
||||
cli.stream.stdio_starting: "启动 stdio server..."
|
||||
cli.stream.stdio_started: "stdio server 已启动"
|
||||
cli.stream.waiting_events: "开始等待 stdio server 事件..."
|
||||
cli.stream.stdio_exit_eof: "stdio server 退出 - 原因: MCP 客户端断开连接 (stdin EOF)"
|
||||
cli.stream.watchdog_exit: "Watchdog 任务退出"
|
||||
cli.stream.normal_exit: "mcp-proxy convert (Stream 模式) 正常退出"
|
||||
# ===========================================
|
||||
# CLI 消息 - Command 模式
|
||||
# ===========================================
|
||||
cli.command.local_mode: "模式: 本地命令模式"
|
||||
cli.command.command: "命令: %{cmd} %{args}"
|
||||
cli.command.ctrl_c: "收到 Ctrl+C 信号,正在关闭..."
|
||||
# ===========================================
|
||||
# CLI 消息 - 监控
|
||||
# ===========================================
|
||||
cli.monitoring.health: "开始监控 %{protocol} 连接健康状态"
|
||||
cli.monitoring.health_check_status: "\U0001F493 [%{protocol}][健康检查] 连接状态: %{status} (仅检查连接通道, 未调用 list_tools), 检查 #%{count}, 已存活: %{seconds}s"
|
||||
cli.monitoring.health_check_normal: "\U0001F493 [%{protocol}][健康检查] 后端服务正常 (list_tools 验证通过), Ping #%{count}, 已存活: %{seconds}s"
|
||||
# ===========================================
|
||||
# 诊断消息
|
||||
# ===========================================
|
||||
diagnostic.report_header: "========== 诊断报告 =========="
|
||||
diagnostic.connection_protocol: "连接协议: %{protocol}"
|
||||
diagnostic.service_url: "服务 URL: %{url}"
|
||||
diagnostic.connection_duration: "连接存活时长: %{seconds} 秒"
|
||||
diagnostic.disconnect_reason: "断开原因: %{reason}"
|
||||
diagnostic.error_type: "错误类型: %{error_type}"
|
||||
diagnostic.possible_causes: "可能原因分析:"
|
||||
diagnostic.suggestions: "建议:"
|
||||
# 诊断 - 30秒超时分析
|
||||
diagnostic.analysis.timeout_30s: "⚠️ 连接在约 30 秒时断开,极有可能是:"
|
||||
diagnostic.analysis.timeout_30s_cause1: "服务器端设置了 30 秒超时限制"
|
||||
diagnostic.analysis.timeout_30s_cause2: "负载均衡器(如 Nginx/ALB)的默认超时"
|
||||
diagnostic.analysis.timeout_30s_cause3: "云服务商的网关超时限制"
|
||||
# 诊断 - 快速断开分析
|
||||
diagnostic.analysis.quick_disconnect: "⚠️ 连接很快断开(%{seconds}秒),可能是:"
|
||||
diagnostic.analysis.quick_disconnect_cause1: "认证失败或 token 无效"
|
||||
diagnostic.analysis.quick_disconnect_cause2: "服务器拒绝连接"
|
||||
diagnostic.analysis.quick_disconnect_cause3: "网络不稳定"
|
||||
# 诊断 - 长时间连接分析
|
||||
diagnostic.analysis.long_connection: "✅ 连接保持了较长时间(%{seconds}秒),可能是:"
|
||||
diagnostic.analysis.long_connection_cause1: "工具调用执行时间过长"
|
||||
diagnostic.analysis.long_connection_cause2: "网络波动导致断开"
|
||||
# 诊断建议
|
||||
diagnostic.suggestion.timeout_30s: "联系服务提供商增加超时限制"
|
||||
diagnostic.suggestion.timeout_client: "使用 --request-timeout 参数设置客户端超时"
|
||||
diagnostic.suggestion.async_mode: "考虑使用异步处理模式(webhook 回调)"
|
||||
diagnostic.suggestion.ping_interval: "尝试增加 ping 间隔: --ping-interval %{seconds}"
|
||||
diagnostic.suggestion.ping_timeout: "增加 ping 超时时间: --ping-timeout %{seconds}"
|
||||
diagnostic.suggestion.ping_disable: "或禁用 ping: --ping-interval 0"
|
||||
# ===========================================
|
||||
# 错误分类
|
||||
# ===========================================
|
||||
error_classify.timeout_30s: "30秒超时(可能是服务器限制)"
|
||||
error_classify.service_unavailable_503: "服务不可用(503)"
|
||||
error_classify.internal_server_error_500: "服务器内部错误(500)"
|
||||
error_classify.bad_gateway_502: "网关错误(502)"
|
||||
error_classify.gateway_timeout_504: "网关超时(504)"
|
||||
error_classify.unauthorized_401: "未授权(401)"
|
||||
error_classify.forbidden_403: "禁止访问(403)"
|
||||
error_classify.not_found_404: "资源未找到(404)"
|
||||
error_classify.request_timeout_408: "请求超时(408)"
|
||||
error_classify.timeout: "超时"
|
||||
error_classify.connection_refused: "连接被拒绝"
|
||||
error_classify.connection_reset: "连接被重置"
|
||||
error_classify.connection_closed: "连接关闭"
|
||||
error_classify.dns_failed: "DNS解析失败"
|
||||
error_classify.ssl_tls_error: "SSL/TLS错误"
|
||||
error_classify.network_error: "网络错误"
|
||||
error_classify.session_error: "会话错误"
|
||||
error_classify.unknown_error: "未知错误"
|
||||
# ===========================================
|
||||
# CLI 消息 - health 命令
|
||||
# ===========================================
|
||||
cli.health.checking: "\U0001F50D 健康检查服务: %{url}"
|
||||
cli.health.using_protocol: "\U0001F50D 使用指定协议: %{protocol}"
|
||||
cli.health.detecting_protocol: "\U0001F50D 正在检测协议..."
|
||||
cli.health.detected_protocol: "\U0001F50D 检测到 %{protocol} 协议"
|
||||
cli.health.healthy: "✅ 服务健康"
|
||||
cli.health.unhealthy: "❌ 服务不健康"
|
||||
cli.health.stdio_not_supported: "health 命令不支持 stdio 协议"
|
||||
# ===========================================
|
||||
# CLI 消息 - check 命令
|
||||
# ===========================================
|
||||
cli.check.checking: "\U0001F50D 检查服务: %{url}"
|
||||
cli.check.healthy: "✅ 服务正常,检测到 %{protocol} 协议"
|
||||
cli.check.failed: "❌ 服务检查失败: %{error}"
|
||||
# ===========================================
|
||||
# CLI 消息 - document-parser
|
||||
# ===========================================
|
||||
doc_parser.startup.app_info: "=== %{name} v%{version} 启动 ==="
|
||||
doc_parser.startup.config_summary: "配置摘要: %{summary}"
|
||||
doc_parser.startup.listening: "服务监听地址: %{host}:%{port}"
|
||||
doc_parser.startup.checking_python: "开始检查和初始化Python环境..."
|
||||
doc_parser.startup.checking_venv: "检查并激活虚拟环境..."
|
||||
doc_parser.startup.venv_activate_failed: "虚拟环境自动激活失败: %{error}"
|
||||
doc_parser.startup.venv_activate_manual: "请手动激活虚拟环境: source ./venv/bin/activate"
|
||||
doc_parser.startup.venv_activated: "虚拟环境已自动激活"
|
||||
doc_parser.startup.env_check_failed: "环境检查失败,将在后台自动安装: %{error}"
|
||||
doc_parser.startup.mineru_not_installed: "MinerU依赖未安装,开始后台自动安装..."
|
||||
doc_parser.startup.markitdown_not_installed: "MarkItDown依赖未安装,开始后台自动安装..."
|
||||
doc_parser.startup.install_complete: "后台Python环境安装完成"
|
||||
doc_parser.startup.install_failed: "后台Python环境安装失败: %{error}"
|
||||
doc_parser.startup.install_task_started: "Python依赖安装任务已启动(后台进行),服务将正常启动"
|
||||
doc_parser.startup.mineru_ready: "MinerU依赖已安装,版本: %{version}"
|
||||
doc_parser.startup.markitdown_ready: "MarkItDown依赖已安装"
|
||||
doc_parser.startup.python_ready: "Python环境检查完成,所有依赖已就绪"
|
||||
doc_parser.startup.state_failed: "无法创建应用状态: %{error}"
|
||||
doc_parser.startup.health_check_failed: "应用健康检查失败: %{error}"
|
||||
doc_parser.startup.state_ready: "应用状态初始化成功"
|
||||
doc_parser.startup.http_routes_ready: "HTTP路由初始化成功"
|
||||
doc_parser.startup.background_tasks_started: "后台任务已启动"
|
||||
doc_parser.startup.service_ready: "服务启动成功,开始监听连接..."
|
||||
doc_parser.shutdown.service_stopped: "服务已关闭"
|
||||
doc_parser.shutdown.ctrl_c_received: "收到 Ctrl+C 信号,开始优雅关闭..."
|
||||
doc_parser.shutdown.terminate_received: "收到 terminate 信号,开始优雅关闭..."
|
||||
doc_parser.shutdown.closing: "正在关闭服务..."
|
||||
# uv-init 命令
|
||||
doc_parser.uv_init.starting: "\U0001F680 开始在当前目录初始化uv虚拟环境和依赖..."
|
||||
doc_parser.uv_init.current_dir: "\U0001F4C1 当前工作目录: %{path}"
|
||||
doc_parser.uv_init.venv_path: "\U0001F4C1 虚拟环境将创建在: ./venv/"
|
||||
doc_parser.uv_init.validating_dir: "\U0001F50D 验证当前目录设置..."
|
||||
doc_parser.uv_init.dir_valid: " ✅ 目录验证通过"
|
||||
doc_parser.uv_init.warnings_found: " ⚠️ 发现 %{count} 个警告"
|
||||
doc_parser.uv_init.dir_invalid: " ❌ 目录验证失败,发现 %{count} 个问题"
|
||||
doc_parser.uv_init.auto_fixing: " \U0001F527 尝试自动修复 %{count} 个问题..."
|
||||
doc_parser.uv_init.fix_success: " ✅ %{message}"
|
||||
doc_parser.uv_init.fix_failed: " ❌ 清理失败: %{error}"
|
||||
doc_parser.uv_init.manual_fix_hint: " \U0001F4A1 请手动解决以下问题:"
|
||||
doc_parser.uv_init.dir_validation_failed: "目录验证失败,请解决上述问题后重试"
|
||||
doc_parser.uv_init.dir_validation_error: " ⚠️ 目录验证失败: %{error}"
|
||||
doc_parser.uv_init.continue_install: " 继续进行安装,但可能遇到问题..."
|
||||
# 环境检查
|
||||
doc_parser.check.checking_env: "\U0001F50D 检查当前环境状态..."
|
||||
doc_parser.check.env_check_complete: " 环境检查完成:"
|
||||
doc_parser.check.python_available: "✅ 可用"
|
||||
doc_parser.check.python_unavailable: "❌ 不可用"
|
||||
doc_parser.check.uv_available: "✅ 可用"
|
||||
doc_parser.check.uv_unavailable: "❌ 不可用"
|
||||
doc_parser.check.venv_active: "✅ 激活"
|
||||
doc_parser.check.venv_inactive: "❌ 未激活"
|
||||
doc_parser.check.mineru_available: "✅ 可用"
|
||||
doc_parser.check.mineru_unavailable: "❌ 不可用"
|
||||
doc_parser.check.markitdown_available: "✅ 可用"
|
||||
doc_parser.check.markitdown_unavailable: "❌ 不可用"
|
||||
doc_parser.check.env_check_failed: " ⚠️ 环境检查失败: %{error}"
|
||||
doc_parser.check.continue_install: " 继续进行安装..."
|
||||
# 安装过程
|
||||
doc_parser.install.all_ready: "✨ 所有依赖都已就绪,无需安装!"
|
||||
doc_parser.install.plan: "\U0001F4CB 安装计划:"
|
||||
doc_parser.install.install_uv: "安装 uv 工具"
|
||||
doc_parser.install.create_venv: "创建虚拟环境 (./venv/)"
|
||||
doc_parser.install.install_mineru: "安装 MinerU 依赖"
|
||||
doc_parser.install.install_markitdown: "安装 MarkItDown 依赖"
|
||||
doc_parser.install.starting: "⚙️ 开始设置Python环境和依赖..."
|
||||
doc_parser.install.wait_hint: "这可能需要几分钟时间,请耐心等待..."
|
||||
doc_parser.install.path_issues: "⚠️ 检测到潜在的路径问题:"
|
||||
doc_parser.install.auto_fixing: "\U0001F527 尝试自动修复问题..."
|
||||
doc_parser.install.fixed: "✅ 已修复以下问题:"
|
||||
doc_parser.install.cannot_auto_fix: "无法自动修复,请手动解决上述问题"
|
||||
doc_parser.install.fix_failed: "❌ 自动修复失败: %{error}"
|
||||
doc_parser.install.manual_fix_hint: "\U0001F4A1 手动修复建议:"
|
||||
doc_parser.install.python_setup_complete: "✅ Python环境设置完成!"
|
||||
doc_parser.install.python_setup_failed: "❌ Python环境设置失败: %{error}"
|
||||
doc_parser.install.detailed_hints: "\U0001F4A1 详细故障排除建议:"
|
||||
doc_parser.install.diagnostic_commands: "\U0001F50D 诊断命令:"
|
||||
doc_parser.install.check_env: "检查环境状态: document-parser check"
|
||||
doc_parser.install.view_logs: "查看详细日志: 检查 logs/ 目录"
|
||||
# 验证
|
||||
doc_parser.verify.starting: "\U0001F50D 验证安装结果..."
|
||||
doc_parser.verify.complete: "验证完成:"
|
||||
doc_parser.verify.version: "版本: %{version}"
|
||||
doc_parser.verify.partial_issues: "⚠️ 部分依赖安装可能存在问题"
|
||||
doc_parser.verify.need_fix: "\U0001F527 需要解决的问题:"
|
||||
doc_parser.verify.suggestion: "建议: %{suggestion}"
|
||||
doc_parser.verify.init_incomplete: "环境初始化未完全成功"
|
||||
doc_parser.verify.failed: "❌ 验证失败: %{error}"
|
||||
# 成功消息
|
||||
doc_parser.success.init_complete: "\U0001F389 uv环境初始化完成!"
|
||||
doc_parser.success.all_ready: "✨ 所有依赖都已就绪,现在可以启动服务器了"
|
||||
doc_parser.success.venv_activate: "\U0001F4CB 虚拟环境激活指令:"
|
||||
doc_parser.success.start_server: "\U0001F680 启动服务器:"
|
||||
doc_parser.success.use_uv: "\U0001F527 或者使用 uv 直接运行命令:"
|
||||
doc_parser.success.more_help: "\U0001F4DA 更多帮助:"
|
||||
doc_parser.success.tips: "\U0001F4A1 提示:"
|
||||
doc_parser.success.venv_location: "虚拟环境位置: ./venv/"
|
||||
doc_parser.success.python_path: "Python可执行文件: ./venv/bin/python (Linux/macOS) 或 .\\venv\\Scripts\\python.exe (Windows)"
|
||||
doc_parser.success.troubleshoot_hint: "如遇问题,请运行 'document-parser troubleshoot' 查看详细指南"
|
||||
# troubleshoot 命令
|
||||
doc_parser.troubleshoot.title: "\U0001F527 Document Parser 故障排除指南"
|
||||
doc_parser.troubleshoot.env_overview: "\U0001F4CA 当前环境概览:"
|
||||
doc_parser.troubleshoot.work_dir: "工作目录: %{path}"
|
||||
doc_parser.troubleshoot.venv_path: "虚拟环境: ./venv/"
|
||||
doc_parser.troubleshoot.os: "操作系统: %{os}"
|
||||
# 虚拟环境问题
|
||||
doc_parser.troubleshoot.venv_problems: "\U0001F3E0 1. 虚拟环境问题"
|
||||
doc_parser.troubleshoot.venv_create_failed: "❓ 问题: 虚拟环境创建失败"
|
||||
doc_parser.troubleshoot.diagnostic_steps: "\U0001F50D 诊断步骤:"
|
||||
doc_parser.troubleshoot.solutions: "\U0001F4A1 解决方案:"
|
||||
doc_parser.troubleshoot.venv_activate_failed: "❓ 问题: 虚拟环境激活失败"
|
||||
# 依赖安装问题
|
||||
doc_parser.troubleshoot.deps_problems: "\U0001F4E6 2. 依赖安装问题"
|
||||
doc_parser.troubleshoot.uv_not_installed: "❓ 问题: UV工具未安装或不可用"
|
||||
doc_parser.troubleshoot.mineru_markitdown_failed: "❓ 问题: MinerU或MarkItDown安装失败"
|
||||
# 网络问题
|
||||
doc_parser.troubleshoot.network_problems: "\U0001F310 3. 网络和下载问题"
|
||||
doc_parser.troubleshoot.network_timeout: "❓ 问题: 网络连接超时或下载失败"
|
||||
# 系统环境问题
|
||||
doc_parser.troubleshoot.system_problems: "⚙️ 4. 系统环境问题"
|
||||
doc_parser.troubleshoot.python_incompatible: "❓ 问题: Python版本不兼容"
|
||||
doc_parser.troubleshoot.cuda_config: "❓ 问题: CUDA环境配置 (可选,用于GPU加速)"
|
||||
# 诊断命令
|
||||
doc_parser.troubleshoot.diagnostic_commands: "\U0001F50D 5. 常用诊断命令"
|
||||
doc_parser.troubleshoot.env_check: "环境检查:"
|
||||
doc_parser.troubleshoot.manual_verify: "手动验证:"
|
||||
doc_parser.troubleshoot.view_logs: "日志查看:"
|
||||
# 获取帮助
|
||||
doc_parser.troubleshoot.more_help: "\U0001F198 6. 获取更多帮助"
|
||||
doc_parser.troubleshoot.if_unsolved: "如果上述方法都无法解决问题,请:"
|
||||
# 实时诊断
|
||||
doc_parser.troubleshoot.realtime_diagnosis: "\U0001F52C 实时环境诊断"
|
||||
doc_parser.troubleshoot.env_good: "✅ 环境状态良好,所有依赖都已就绪"
|
||||
doc_parser.troubleshoot.issues_found: "⚠️ 发现以下问题:"
|
||||
doc_parser.troubleshoot.env_check_failed: "❌ 环境检查失败: %{error}"
|
||||
doc_parser.troubleshoot.follow_guide: "请按照上述指南进行故障排除"
|
||||
# 提示
|
||||
doc_parser.troubleshoot.tip: "\U0001F4A1 提示: 大多数问题可以通过重新运行 'document-parser uv-init' 解决"
|
||||
# 后台清理
|
||||
doc_parser.background.cleanup_failed: "清理过期数据失败: %{error}"
|
||||
doc_parser.background.cleanup_complete: "后台清理任务执行完成"
|
||||
# 信号处理
|
||||
doc_parser.signal.ctrl_c_failed: "无法监听 Ctrl+C 信号"
|
||||
doc_parser.signal.terminate_failed: "无法监听 terminate 信号"
|
||||
# ===========================================
|
||||
# 通用消息
|
||||
# ===========================================
|
||||
common.yes: "是"
|
||||
common.no: "否"
|
||||
common.success: "成功"
|
||||
common.failed: "失败"
|
||||
common.error: "错误"
|
||||
common.warning: "警告"
|
||||
common.info: "信息"
|
||||
common.loading: "加载中..."
|
||||
common.please_wait: "请稍候..."
|
||||
common.retry: "重试"
|
||||
common.cancel: "取消"
|
||||
common.confirm: "确认"
|
||||
common.back: "返回"
|
||||
common.next: "下一步"
|
||||
common.previous: "上一步"
|
||||
common.done: "完成"
|
||||
common.skip: "跳过"
|
||||
468
qiming-mcp-proxy/mcp-proxy/locales/zh-TW.yml
Normal file
468
qiming-mcp-proxy/mcp-proxy/locales/zh-TW.yml
Normal file
@@ -0,0 +1,468 @@
|
||||
# ===========================================
|
||||
# 錯誤訊息 - mcp-proxy
|
||||
# ===========================================
|
||||
errors.mcp_proxy.service_not_found: "服務 %{service} 未找到"
|
||||
errors.mcp_proxy.service_restart_cooldown: "服務 %{service} 在重啟冷卻期內,請稍後再試"
|
||||
errors.mcp_proxy.service_startup_in_progress: "服務 %{service} 正在啟動中,請稍後再試"
|
||||
errors.mcp_proxy.service_startup_failed: "服務啟動失敗: %{mcp_id}: %{reason}"
|
||||
errors.mcp_proxy.backend_connection: "後端連線錯誤: %{detail}"
|
||||
errors.mcp_proxy.config_parse: "設定解析錯誤: %{detail}"
|
||||
errors.mcp_proxy.mcp_server_error: "MCP 伺服器錯誤: %{detail}"
|
||||
errors.mcp_proxy.json_serialization: "JSON 序列化錯誤: %{detail}"
|
||||
errors.mcp_proxy.io_error: "IO 錯誤: %{detail}"
|
||||
errors.mcp_proxy.route_not_found: "路由未找到: %{path}"
|
||||
errors.mcp_proxy.invalid_parameter: "無效的請求參數: %{detail}"
|
||||
# ===========================================
|
||||
# 錯誤訊息 - document-parser
|
||||
# ===========================================
|
||||
errors.document_parser.config: "設定錯誤: %{detail}"
|
||||
errors.document_parser.file: "檔案操作錯誤: %{detail}"
|
||||
errors.document_parser.unsupported_format: "不支援的檔案格式: %{format}"
|
||||
errors.document_parser.parse: "解析錯誤: %{detail}"
|
||||
errors.document_parser.mineru: "MinerU錯誤: %{detail}"
|
||||
errors.document_parser.markitdown: "MarkItDown錯誤: %{detail}"
|
||||
errors.document_parser.oss: "OSS操作錯誤: %{detail}"
|
||||
errors.document_parser.database: "資料庫錯誤: %{detail}"
|
||||
errors.document_parser.network: "網路錯誤: %{detail}"
|
||||
errors.document_parser.task: "任務錯誤: %{detail}"
|
||||
errors.document_parser.internal: "內部錯誤: %{detail}"
|
||||
errors.document_parser.timeout: "操作逾時: %{detail}"
|
||||
errors.document_parser.validation: "驗證錯誤: %{detail}"
|
||||
errors.document_parser.environment: "環境錯誤: %{detail}"
|
||||
errors.document_parser.virtual_environment_path: "虛擬環境路徑錯誤: %{detail}"
|
||||
errors.document_parser.permission: "權限錯誤: %{detail}"
|
||||
errors.document_parser.path: "路徑錯誤: %{detail}"
|
||||
errors.document_parser.queue: "佇列錯誤: %{detail}"
|
||||
errors.document_parser.processing: "處理錯誤: %{detail}"
|
||||
# 錯誤建議 - document-parser
|
||||
errors.document_parser.suggestions.config: "檢查設定檔案和環境變數"
|
||||
errors.document_parser.suggestions.file: "檢查檔案路徑和權限"
|
||||
errors.document_parser.suggestions.unsupported_format: "檢查檔案格式是否支援"
|
||||
errors.document_parser.suggestions.parse: "檢查檔案內容是否完整"
|
||||
errors.document_parser.suggestions.mineru: "檢查MinerU環境設定"
|
||||
errors.document_parser.suggestions.markitdown: "檢查MarkItDown環境設定"
|
||||
errors.document_parser.suggestions.oss: "檢查OSS設定和網路連線"
|
||||
errors.document_parser.suggestions.database: "檢查資料庫連線和權限"
|
||||
errors.document_parser.suggestions.network: "檢查網路連線和防火牆設定"
|
||||
errors.document_parser.suggestions.task: "檢查任務參數和狀態"
|
||||
errors.document_parser.suggestions.internal: "聯絡技術支援"
|
||||
errors.document_parser.suggestions.timeout: "檢查網路延遲或增加逾時時間"
|
||||
errors.document_parser.suggestions.validation: "檢查輸入參數格式"
|
||||
errors.document_parser.suggestions.environment: "檢查系統環境和相依套件安裝"
|
||||
errors.document_parser.suggestions.queue: "檢查佇列服務狀態和設定"
|
||||
errors.document_parser.suggestions.processing: "檢查處理流程和資料格式"
|
||||
errors.document_parser.suggestions.virtual_environment_path: "檢查虛擬環境路徑和目錄權限"
|
||||
errors.document_parser.suggestions.permission: "檢查檔案和目錄權限設定"
|
||||
errors.document_parser.suggestions.path: "檢查路徑是否存在且可存取"
|
||||
# ===========================================
|
||||
# 錯誤訊息 - oss-client
|
||||
# ===========================================
|
||||
errors.oss.config: "設定錯誤: %{detail}"
|
||||
errors.oss.network: "網路錯誤: %{detail}"
|
||||
errors.oss.file_not_found: "檔案不存在: %{path}"
|
||||
errors.oss.permission: "權限不足: %{detail}"
|
||||
errors.oss.io: "IO錯誤: %{detail}"
|
||||
errors.oss.sdk: "OSS SDK錯誤: %{detail}"
|
||||
errors.oss.file_size_exceeded: "檔案大小超出限制: %{detail}"
|
||||
errors.oss.unsupported_file_type: "不支援的檔案類型: %{detail}"
|
||||
errors.oss.timeout: "操作逾時: %{detail}"
|
||||
errors.oss.invalid_parameter: "無效的參數: %{detail}"
|
||||
# ===========================================
|
||||
# 錯誤訊息 - voice-cli
|
||||
# ===========================================
|
||||
errors.voice.config: "設定錯誤: %{detail}"
|
||||
errors.voice.audio_processing: "音訊處理錯誤: %{detail}"
|
||||
errors.voice.transcription: "轉錄錯誤: %{detail}"
|
||||
errors.voice.model: "模型錯誤: %{detail}"
|
||||
errors.voice.file_io: "檔案I/O錯誤: %{detail}"
|
||||
errors.voice.http: "HTTP請求錯誤: %{detail}"
|
||||
errors.voice.serialization: "序列化錯誤: %{detail}"
|
||||
errors.voice.json: "JSON錯誤: %{detail}"
|
||||
errors.voice.config_rs: "設定讀取錯誤: %{detail}"
|
||||
errors.voice.daemon: "常駐程式錯誤: %{detail}"
|
||||
errors.voice.unsupported_format: "不支援的音訊格式: %{detail}"
|
||||
errors.voice.file_too_large: "檔案過大: %{size} 位元組 (最大: %{max} 位元組)"
|
||||
errors.voice.model_not_found: "模型未找到: %{model}"
|
||||
errors.voice.invalid_model_name: "無效的模型名稱: %{model}"
|
||||
errors.voice.worker_pool: "工作池錯誤: %{detail}"
|
||||
errors.voice.transcription_timeout: "轉錄逾時 (%{seconds} 秒後)"
|
||||
errors.voice.transcription_failed: "轉錄失敗: %{detail}"
|
||||
errors.voice.audio_conversion_failed: "音訊轉換失敗: %{detail}"
|
||||
errors.voice.audio_probe_error: "音訊探測錯誤: %{detail}"
|
||||
errors.voice.temp_file_error: "暫存檔錯誤: %{detail}"
|
||||
errors.voice.multipart_error: "Multipart表單錯誤: %{detail}"
|
||||
errors.voice.missing_field: "缺少必填欄位: %{field}"
|
||||
errors.voice.network: "網路錯誤: %{detail}"
|
||||
errors.voice.storage: "儲存錯誤: %{detail}"
|
||||
errors.voice.task_management_disabled: "任務管理已停用"
|
||||
errors.voice.not_found: "資源未找到: %{resource}"
|
||||
errors.voice.initialization: "初始化錯誤: %{detail}"
|
||||
errors.voice.tts: "TTS錯誤: %{detail}"
|
||||
errors.voice.invalid_input: "無效輸入: %{detail}"
|
||||
errors.voice.io: "IO錯誤: %{detail}"
|
||||
# ===========================================
|
||||
# CLI 訊息 - mcp-proxy 啟動
|
||||
# ===========================================
|
||||
cli.mirror.not_configured: "未配置鏡像源(npm/PyPI),將使用預設來源"
|
||||
cli.mirror.npm: "npm 鏡像: %{url}"
|
||||
cli.mirror.pypi: "PyPI 鏡像: %{url}"
|
||||
cli.startup.service_starting: "MCP-Proxy 啟動中..."
|
||||
cli.startup.version: "版本: %{version}"
|
||||
cli.startup.config_loaded: "設定載入完成"
|
||||
cli.startup.port: "連接埠: %{port}"
|
||||
cli.startup.log_dir: "日誌目錄: %{path}"
|
||||
cli.startup.log_level: "日誌級別: %{level}"
|
||||
cli.startup.log_retain_days: "日誌保留天數: %{days}"
|
||||
cli.startup.success: "✅ 服務啟動成功,監聽位址: %{addr}"
|
||||
cli.startup.health_endpoint: "✅ 健康檢查端點: http://%{addr}/health"
|
||||
cli.startup.mcp_list: "✅ MCP 服務列表: http://%{addr}/mcp"
|
||||
cli.startup.schedule_task_started: "✅ MCP服務狀態檢查定時任務已啟動"
|
||||
cli.startup.log_rotation_configured: "✅ 日誌自動輪轉已設定(保留最近 %{count} 個日誌檔案)"
|
||||
cli.startup.system_info: "系統資訊:"
|
||||
cli.startup.os: "作業系統: %{os}"
|
||||
cli.startup.arch: "架構: %{arch}"
|
||||
cli.startup.work_dir: "工作目錄: %{path}"
|
||||
cli.startup.env_override: "環境變數覆蓋:"
|
||||
cli.startup.env_port_override: "MCP_PROXY_PORT: %{port}"
|
||||
cli.startup.env_log_dir_override: "MCP_PROXY_LOG_DIR: %{dir}"
|
||||
cli.startup.env_log_level_override: "MCP_PROXY_LOG_LEVEL: %{level}"
|
||||
cli.startup.trying_bind: "嘗試綁定到位址: %{addr}"
|
||||
cli.startup.bind_success: "成功綁定到位址: %{addr}"
|
||||
cli.startup.bind_failed: "綁定位址 %{addr} 失敗: %{error}"
|
||||
cli.startup.init_state: "初始化應用狀態..."
|
||||
cli.startup.state_done: "應用狀態初始化完成"
|
||||
cli.startup.init_router: "初始化路由..."
|
||||
cli.startup.router_done: "路由初始化完成"
|
||||
cli.startup.warming_up: "\U0001F504 開始預熱 uv/deno 環境相依套件..."
|
||||
cli.startup.warmup_success: "✅ 預熱 uv/deno 環境相依套件完成"
|
||||
cli.startup.warmup_failed: "❌ 預熱 uv/deno 環境相依套件失敗: %{error}"
|
||||
cli.startup.http_server_starting: "\U0001F680 HTTP 伺服器啟動,等待連線..."
|
||||
cli.startup.proxy_mode: "命令: proxy (HTTP 伺服器模式)"
|
||||
cli.startup.config_info: "設定資訊:"
|
||||
cli.startup.listen_port: "監聽連接埠: %{port}"
|
||||
cli.startup.log_retention: "日誌保留: %{days} 天"
|
||||
# CLI 訊息 - mcp-proxy 關閉
|
||||
cli.shutdown.signal_received: "⚠️ 伺服器收到關閉訊號,開始清理資源..."
|
||||
cli.shutdown.cleanup_success: "✅ 資源清理成功"
|
||||
cli.shutdown.cleanup_failed: "❌ 清理資源時出錯: %{error}"
|
||||
cli.shutdown.complete: "✅ 資源清理完成,服務已完全關閉"
|
||||
cli.shutdown.service_error: "❌ 服務執行錯誤: %{error}"
|
||||
# CLI 訊息 - panic 處理
|
||||
cli.panic.handler_started: "程式發生panic,執行清理..."
|
||||
cli.panic.reason: "Panic 原因: %{reason}"
|
||||
cli.panic.reason_unknown: "Panic 原因: 未知"
|
||||
cli.panic.location: "Panic 位置: %{file}:%{line}"
|
||||
cli.panic.stack_trace: "堆疊追蹤:"
|
||||
# ===========================================
|
||||
# CLI 訊息 - convert 模式
|
||||
# ===========================================
|
||||
cli.convert.starting: "開始 URL 模式處理"
|
||||
cli.convert.target_url: "目標 URL: %{url}"
|
||||
cli.convert.protocol_specified: "使用命令列指定協定: %{protocol}"
|
||||
cli.convert.protocol_config: "使用設定檔協定: %{protocol}"
|
||||
cli.convert.detecting_protocol: "開始自動偵測協定..."
|
||||
cli.convert.detect_failed: "協定偵測失敗: %{error}"
|
||||
cli.convert.using_protocol: "使用 %{protocol} 協定模式"
|
||||
cli.convert.stdio_url_not_supported: "Stdio 協定不支援透過 URL 轉換"
|
||||
cli.convert.tool_whitelist: "工具白名單: %{tools}"
|
||||
cli.convert.tool_blacklist: "工具黑名單: %{tools}"
|
||||
cli.convert.config_parsed: "設定解析成功"
|
||||
cli.convert.service_name: "MCP 服務名稱: %{name}"
|
||||
cli.convert.service_name_not_specified: "MCP 服務名稱: 未指定(使用 direct URL)"
|
||||
cli.convert.cli_starting: "MCP-Proxy CLI 啟動"
|
||||
cli.convert.command: "命令: convert (stdio 橋接模式)"
|
||||
cli.convert.version: "版本: %{version}"
|
||||
cli.convert.diagnostic_mode: "診斷模式: %{enabled}"
|
||||
cli.convert.mode_direct_url: "模式: 直接 URL 模式"
|
||||
cli.convert.mode_remote_service: "模式: 遠端服務設定模式"
|
||||
cli.convert.service_url: "服務 URL: %{url}"
|
||||
cli.convert.config_protocol: "設定協定: %{protocol}"
|
||||
cli.convert.ping_config: "Ping 間隔: %{interval}s, Ping 逾時: %{timeout}s"
|
||||
cli.convert.connecting_backend: "開始連線到後端服務 (逾時: %{timeout}s)..."
|
||||
cli.convert.detect_complete: "協定偵測完成: %{protocol} (耗時: %{duration})"
|
||||
# ===========================================
|
||||
# CLI 訊息 - SSE 模式
|
||||
# ===========================================
|
||||
cli.sse.mode_starting: "SSE 模式啟動"
|
||||
cli.sse.connect_timeout: "連線後端逾時 (%{seconds}s)"
|
||||
cli.sse.connect_failed: "連線後端失敗: %{error}"
|
||||
cli.sse.connect_success: "後端連線成功 (耗時: %{duration})"
|
||||
cli.sse.stdio_starting: "啟動 stdio server..."
|
||||
cli.sse.stdio_started: "stdio server 已啟動"
|
||||
cli.sse.waiting_events: "開始等待 stdio server 事件..."
|
||||
cli.sse.stdio_exit_eof: "stdio server 結束 - 原因: MCP 客戶端斷開連線 (stdin EOF)"
|
||||
cli.sse.watchdog_exit: "Watchdog 任務結束"
|
||||
cli.sse.normal_exit: "mcp-proxy convert (SSE 模式) 正常結束"
|
||||
cli.sse.watchdog_starting: "SSE Watchdog 啟動"
|
||||
cli.sse.max_retries: "最大重試次數: %{count} (0=無限)"
|
||||
cli.sse.monitoring_connection: "開始監控初始連線..."
|
||||
cli.sse.initial_disconnect: "初始連線斷開: %{reason}"
|
||||
cli.sse.connection_alive: "初始連線存活時長: %{seconds}s"
|
||||
cli.sse.reconnect_attempt: "重連嘗試 #%{attempt}/%{max}"
|
||||
cli.sse.backoff_time: "退避時間: %{seconds}s"
|
||||
cli.sse.reconnect_success: "重連成功 (耗時: %{duration})"
|
||||
cli.sse.monitoring_reconnect: "開始監控重連後的連線..."
|
||||
cli.sse.reconnect_disconnect: "重連後斷開: %{reason}"
|
||||
cli.sse.reconnect_alive: "重連後存活時長: %{seconds}s"
|
||||
cli.sse.max_retries_reached: "達到最大重試次數 (%{count}), 停止重連"
|
||||
cli.sse.watchdog_exit_msg: "SSE Watchdog 結束"
|
||||
# ===========================================
|
||||
# CLI 訊息 - Stream 模式
|
||||
# ===========================================
|
||||
cli.stream.mode_starting: "Stream 模式啟動"
|
||||
cli.stream.connect_timeout: "連線後端逾時 (%{seconds}s)"
|
||||
cli.stream.connect_failed: "連線後端失敗: %{error}"
|
||||
cli.stream.connect_success: "後端連線成功 (耗時: %{duration})"
|
||||
cli.stream.stdio_starting: "啟動 stdio server..."
|
||||
cli.stream.stdio_started: "stdio server 已啟動"
|
||||
cli.stream.waiting_events: "開始等待 stdio server 事件..."
|
||||
cli.stream.stdio_exit_eof: "stdio server 結束 - 原因: MCP 客戶端斷開連線 (stdin EOF)"
|
||||
cli.stream.watchdog_exit: "Watchdog 任務結束"
|
||||
cli.stream.normal_exit: "mcp-proxy convert (Stream 模式) 正常結束"
|
||||
# ===========================================
|
||||
# CLI 訊息 - Command 模式
|
||||
# ===========================================
|
||||
cli.command.local_mode: "模式: 本地命令模式"
|
||||
cli.command.command: "命令: %{cmd} %{args}"
|
||||
cli.command.ctrl_c: "收到 Ctrl+C 訊號,正在關閉..."
|
||||
# ===========================================
|
||||
# CLI 訊息 - 監控
|
||||
# ===========================================
|
||||
cli.monitoring.health: "開始監控 %{protocol} 連線健康狀態"
|
||||
cli.monitoring.health_check_status: "\U0001F493 [%{protocol}][健康檢查] 連線狀態: %{status} (僅檢查連線通道, 未呼叫 list_tools), 檢查 #%{count}, 已存活: %{seconds}s"
|
||||
cli.monitoring.health_check_normal: "\U0001F493 [%{protocol}][健康檢查] 後端服務正常 (list_tools 驗證通過), Ping #%{count}, 已存活: %{seconds}s"
|
||||
# ===========================================
|
||||
# 診斷訊息
|
||||
# ===========================================
|
||||
diagnostic.report_header: "========== 診斷報告 =========="
|
||||
diagnostic.connection_protocol: "連線協定: %{protocol}"
|
||||
diagnostic.service_url: "服務 URL: %{url}"
|
||||
diagnostic.connection_duration: "連線存活時長: %{seconds} 秒"
|
||||
diagnostic.disconnect_reason: "斷開原因: %{reason}"
|
||||
diagnostic.error_type: "錯誤類型: %{error_type}"
|
||||
diagnostic.possible_causes: "可能原因分析:"
|
||||
diagnostic.suggestions: "建議:"
|
||||
# 診斷 - 30秒逾時分析
|
||||
diagnostic.analysis.timeout_30s: "⚠️ 連線在約 30 秒時斷開,極有可能是:"
|
||||
diagnostic.analysis.timeout_30s_cause1: "伺服器端設定了 30 秒逾時限制"
|
||||
diagnostic.analysis.timeout_30s_cause2: "負載平衡器(如 Nginx/ALB)的預設逾時"
|
||||
diagnostic.analysis.timeout_30s_cause3: "雲端服務商的閘道逾時限制"
|
||||
# 診斷 - 快速斷開分析
|
||||
diagnostic.analysis.quick_disconnect: "⚠️ 連線很快斷開(%{seconds}秒),可能是:"
|
||||
diagnostic.analysis.quick_disconnect_cause1: "認證失敗或 token 無效"
|
||||
diagnostic.analysis.quick_disconnect_cause2: "伺服器拒絕連線"
|
||||
diagnostic.analysis.quick_disconnect_cause3: "網路不穩定"
|
||||
# 診斷 - 長時間連線分析
|
||||
diagnostic.analysis.long_connection: "✅ 連線保持了較長時間(%{seconds}秒),可能是:"
|
||||
diagnostic.analysis.long_connection_cause1: "工具呼叫執行時間過長"
|
||||
diagnostic.analysis.long_connection_cause2: "網路波動導致斷開"
|
||||
# 診斷建議
|
||||
diagnostic.suggestion.timeout_30s: "聯絡服務提供商增加逾時限制"
|
||||
diagnostic.suggestion.timeout_client: "使用 --request-timeout 參數設定客戶端逾時"
|
||||
diagnostic.suggestion.async_mode: "考慮使用非同步處理模式(webhook 回呼)"
|
||||
diagnostic.suggestion.ping_interval: "嘗試增加 ping 間隔: --ping-interval %{seconds}"
|
||||
diagnostic.suggestion.ping_timeout: "增加 ping 逾時時間: --ping-timeout %{seconds}"
|
||||
diagnostic.suggestion.ping_disable: "或停用 ping: --ping-interval 0"
|
||||
# ===========================================
|
||||
# 錯誤分類
|
||||
# ===========================================
|
||||
error_classify.timeout_30s: "30秒逾時(可能是伺服器限制)"
|
||||
error_classify.service_unavailable_503: "服務不可用(503)"
|
||||
error_classify.internal_server_error_500: "伺服器內部錯誤(500)"
|
||||
error_classify.bad_gateway_502: "閘道錯誤(502)"
|
||||
error_classify.gateway_timeout_504: "閘道逾時(504)"
|
||||
error_classify.unauthorized_401: "未授權(401)"
|
||||
error_classify.forbidden_403: "禁止存取(403)"
|
||||
error_classify.not_found_404: "資源未找到(404)"
|
||||
error_classify.request_timeout_408: "請求逾時(408)"
|
||||
error_classify.timeout: "逾時"
|
||||
error_classify.connection_refused: "連線被拒絕"
|
||||
error_classify.connection_reset: "連線被重設"
|
||||
error_classify.connection_closed: "連線關閉"
|
||||
error_classify.dns_failed: "DNS解析失敗"
|
||||
error_classify.ssl_tls_error: "SSL/TLS錯誤"
|
||||
error_classify.network_error: "網路錯誤"
|
||||
error_classify.session_error: "工作階段錯誤"
|
||||
error_classify.unknown_error: "未知錯誤"
|
||||
# ===========================================
|
||||
# CLI 訊息 - health 命令
|
||||
# ===========================================
|
||||
cli.health.checking: "\U0001F50D 健康檢查服務: %{url}"
|
||||
cli.health.using_protocol: "\U0001F50D 使用指定協定: %{protocol}"
|
||||
cli.health.detecting_protocol: "\U0001F50D 正在偵測協定..."
|
||||
cli.health.detected_protocol: "\U0001F50D 偵測到 %{protocol} 協定"
|
||||
cli.health.healthy: "✅ 服務健康"
|
||||
cli.health.unhealthy: "❌ 服務不健康"
|
||||
cli.health.stdio_not_supported: "health 命令不支援 stdio 協定"
|
||||
# ===========================================
|
||||
# CLI 訊息 - check 命令
|
||||
# ===========================================
|
||||
cli.check.checking: "\U0001F50D 檢查服務: %{url}"
|
||||
cli.check.healthy: "✅ 服務正常,偵測到 %{protocol} 協定"
|
||||
cli.check.failed: "❌ 服務檢查失敗: %{error}"
|
||||
# ===========================================
|
||||
# CLI 訊息 - document-parser
|
||||
# ===========================================
|
||||
doc_parser.startup.app_info: "=== %{name} v%{version} 啟動 ==="
|
||||
doc_parser.startup.config_summary: "設定摘要: %{summary}"
|
||||
doc_parser.startup.listening: "服務監聽位址: %{host}:%{port}"
|
||||
doc_parser.startup.checking_python: "開始檢查和初始化Python環境..."
|
||||
doc_parser.startup.checking_venv: "檢查並啟用虛擬環境..."
|
||||
doc_parser.startup.venv_activate_failed: "虛擬環境自動啟用失敗: %{error}"
|
||||
doc_parser.startup.venv_activate_manual: "請手動啟用虛擬環境: source ./venv/bin/activate"
|
||||
doc_parser.startup.venv_activated: "虛擬環境已自動啟用"
|
||||
doc_parser.startup.env_check_failed: "環境檢查失敗,將在背景自動安裝: %{error}"
|
||||
doc_parser.startup.mineru_not_installed: "MinerU相依套件未安裝,開始背景自動安裝..."
|
||||
doc_parser.startup.markitdown_not_installed: "MarkItDown相依套件未安裝,開始背景自動安裝..."
|
||||
doc_parser.startup.install_complete: "背景Python環境安裝完成"
|
||||
doc_parser.startup.install_failed: "背景Python環境安裝失敗: %{error}"
|
||||
doc_parser.startup.install_task_started: "Python相依套件安裝任務已啟動(背景進行),服務將正常啟動"
|
||||
doc_parser.startup.mineru_ready: "MinerU相依套件已安裝,版本: %{version}"
|
||||
doc_parser.startup.markitdown_ready: "MarkItDown相依套件已安裝"
|
||||
doc_parser.startup.python_ready: "Python環境檢查完成,所有相依套件已就緒"
|
||||
doc_parser.startup.state_failed: "無法建立應用狀態: %{error}"
|
||||
doc_parser.startup.health_check_failed: "應用健康檢查失敗: %{error}"
|
||||
doc_parser.startup.state_ready: "應用狀態初始化成功"
|
||||
doc_parser.startup.http_routes_ready: "HTTP路由初始化成功"
|
||||
doc_parser.startup.background_tasks_started: "背景任務已啟動"
|
||||
doc_parser.startup.service_ready: "服務啟動成功,開始監聽連線..."
|
||||
doc_parser.shutdown.service_stopped: "服務已關閉"
|
||||
doc_parser.shutdown.ctrl_c_received: "收到 Ctrl+C 訊號,開始優雅關閉..."
|
||||
doc_parser.shutdown.terminate_received: "收到 terminate 訊號,開始優雅關閉..."
|
||||
doc_parser.shutdown.closing: "正在關閉服務..."
|
||||
# uv-init 命令
|
||||
doc_parser.uv_init.starting: "\U0001F680 開始在目前目錄初始化uv虛擬環境和相依套件..."
|
||||
doc_parser.uv_init.current_dir: "\U0001F4C1 目前工作目錄: %{path}"
|
||||
doc_parser.uv_init.venv_path: "\U0001F4C1 虛擬環境將建立在: ./venv/"
|
||||
doc_parser.uv_init.validating_dir: "\U0001F50D 驗證目前目錄設定..."
|
||||
doc_parser.uv_init.dir_valid: " ✅ 目錄驗證通過"
|
||||
doc_parser.uv_init.warnings_found: " ⚠️ 發現 %{count} 個警告"
|
||||
doc_parser.uv_init.dir_invalid: " ❌ 目錄驗證失敗,發現 %{count} 個問題"
|
||||
doc_parser.uv_init.auto_fixing: " \U0001F527 嘗試自動修復 %{count} 個問題..."
|
||||
doc_parser.uv_init.fix_success: " ✅ %{message}"
|
||||
doc_parser.uv_init.fix_failed: " ❌ 清理失敗: %{error}"
|
||||
doc_parser.uv_init.manual_fix_hint: " \U0001F4A1 請手動解決以下問題:"
|
||||
doc_parser.uv_init.dir_validation_failed: "目錄驗證失敗,請解決上述問題後重試"
|
||||
doc_parser.uv_init.dir_validation_error: " ⚠️ 目錄驗證失敗: %{error}"
|
||||
doc_parser.uv_init.continue_install: " 繼續進行安裝,但可能遇到問題..."
|
||||
# 環境檢查
|
||||
doc_parser.check.checking_env: "\U0001F50D 檢查目前環境狀態..."
|
||||
doc_parser.check.env_check_complete: " 環境檢查完成:"
|
||||
doc_parser.check.python_available: "✅ 可用"
|
||||
doc_parser.check.python_unavailable: "❌ 不可用"
|
||||
doc_parser.check.uv_available: "✅ 可用"
|
||||
doc_parser.check.uv_unavailable: "❌ 不可用"
|
||||
doc_parser.check.venv_active: "✅ 啟用"
|
||||
doc_parser.check.venv_inactive: "❌ 未啟用"
|
||||
doc_parser.check.mineru_available: "✅ 可用"
|
||||
doc_parser.check.mineru_unavailable: "❌ 不可用"
|
||||
doc_parser.check.markitdown_available: "✅ 可用"
|
||||
doc_parser.check.markitdown_unavailable: "❌ 不可用"
|
||||
doc_parser.check.env_check_failed: " ⚠️ 環境檢查失敗: %{error}"
|
||||
doc_parser.check.continue_install: " 繼續進行安裝..."
|
||||
# 安裝過程
|
||||
doc_parser.install.all_ready: "✨ 所有相依套件都已就緒,無需安裝!"
|
||||
doc_parser.install.plan: "\U0001F4CB 安裝計畫:"
|
||||
doc_parser.install.install_uv: "安裝 uv 工具"
|
||||
doc_parser.install.create_venv: "建立虛擬環境 (./venv/)"
|
||||
doc_parser.install.install_mineru: "安裝 MinerU 相依套件"
|
||||
doc_parser.install.install_markitdown: "安裝 MarkItDown 相依套件"
|
||||
doc_parser.install.starting: "⚙️ 開始設定Python環境和相依套件..."
|
||||
doc_parser.install.wait_hint: "這可能需要幾分鐘時間,請耐心等待..."
|
||||
doc_parser.install.path_issues: "⚠️ 偵測到潛在的路徑問題:"
|
||||
doc_parser.install.auto_fixing: "\U0001F527 嘗試自動修復問題..."
|
||||
doc_parser.install.fixed: "✅ 已修復以下問題:"
|
||||
doc_parser.install.cannot_auto_fix: "無法自動修復,請手動解決上述問題"
|
||||
doc_parser.install.fix_failed: "❌ 自動修復失敗: %{error}"
|
||||
doc_parser.install.manual_fix_hint: "\U0001F4A1 手動修復建議:"
|
||||
doc_parser.install.python_setup_complete: "✅ Python環境設定完成!"
|
||||
doc_parser.install.python_setup_failed: "❌ Python環境設定失敗: %{error}"
|
||||
doc_parser.install.detailed_hints: "\U0001F4A1 詳細故障排除建議:"
|
||||
doc_parser.install.diagnostic_commands: "\U0001F50D 診斷命令:"
|
||||
doc_parser.install.check_env: "檢查環境狀態: document-parser check"
|
||||
doc_parser.install.view_logs: "查看詳細日誌: 檢查 logs/ 目錄"
|
||||
# 驗證
|
||||
doc_parser.verify.starting: "\U0001F50D 驗證安裝結果..."
|
||||
doc_parser.verify.complete: "驗證完成:"
|
||||
doc_parser.verify.version: "版本: %{version}"
|
||||
doc_parser.verify.partial_issues: "⚠️ 部分相依套件安裝可能有問題"
|
||||
doc_parser.verify.need_fix: "\U0001F527 需要解決的問題:"
|
||||
doc_parser.verify.suggestion: "建議: %{suggestion}"
|
||||
doc_parser.verify.init_incomplete: "環境初始化未完全成功"
|
||||
doc_parser.verify.failed: "❌ 驗證失敗: %{error}"
|
||||
# 成功訊息
|
||||
doc_parser.success.init_complete: "\U0001F389 uv環境初始化完成!"
|
||||
doc_parser.success.all_ready: "✨ 所有相依套件都已就緒,現在可以啟動伺服器了"
|
||||
doc_parser.success.venv_activate: "\U0001F4CB 虛擬環境啟用指令:"
|
||||
doc_parser.success.start_server: "\U0001F680 啟動伺服器:"
|
||||
doc_parser.success.use_uv: "\U0001F527 或者使用 uv 直接執行命令:"
|
||||
doc_parser.success.more_help: "\U0001F4DA 更多說明:"
|
||||
doc_parser.success.tips: "\U0001F4A1 提示:"
|
||||
doc_parser.success.venv_location: "虛擬環境位置: ./venv/"
|
||||
doc_parser.success.python_path: "Python可執行檔: ./venv/bin/python (Linux/macOS) 或 .\\venv\\Scripts\\python.exe (Windows)"
|
||||
doc_parser.success.troubleshoot_hint: "如遇問題,請執行 'document-parser troubleshoot' 查看詳細指南"
|
||||
# troubleshoot 命令
|
||||
doc_parser.troubleshoot.title: "\U0001F527 Document Parser 故障排除指南"
|
||||
doc_parser.troubleshoot.env_overview: "\U0001F4CA 目前環境概覽:"
|
||||
doc_parser.troubleshoot.work_dir: "工作目錄: %{path}"
|
||||
doc_parser.troubleshoot.venv_path: "虛擬環境: ./venv/"
|
||||
doc_parser.troubleshoot.os: "作業系統: %{os}"
|
||||
# 虛擬環境問題
|
||||
doc_parser.troubleshoot.venv_problems: "\U0001F3E0 1. 虛擬環境問題"
|
||||
doc_parser.troubleshoot.venv_create_failed: "❓ 問題: 虛擬環境建立失敗"
|
||||
doc_parser.troubleshoot.diagnostic_steps: "\U0001F50D 診斷步驟:"
|
||||
doc_parser.troubleshoot.solutions: "\U0001F4A1 解決方案:"
|
||||
doc_parser.troubleshoot.venv_activate_failed: "❓ 問題: 虛擬環境啟用失敗"
|
||||
# 相依套件安裝問題
|
||||
doc_parser.troubleshoot.deps_problems: "\U0001F4E6 2. 相依套件安裝問題"
|
||||
doc_parser.troubleshoot.uv_not_installed: "❓ 問題: UV工具未安裝或不可用"
|
||||
doc_parser.troubleshoot.mineru_markitdown_failed: "❓ 問題: MinerU或MarkItDown安裝失敗"
|
||||
# 網路問題
|
||||
doc_parser.troubleshoot.network_problems: "\U0001F310 3. 網路和下載問題"
|
||||
doc_parser.troubleshoot.network_timeout: "❓ 問題: 網路連線逾時或下載失敗"
|
||||
# 系統環境問題
|
||||
doc_parser.troubleshoot.system_problems: "⚙️ 4. 系統環境問題"
|
||||
doc_parser.troubleshoot.python_incompatible: "❓ 問題: Python版本不相容"
|
||||
doc_parser.troubleshoot.cuda_config: "❓ 問題: CUDA環境設定 (可選,用於GPU加速)"
|
||||
# 診斷命令
|
||||
doc_parser.troubleshoot.diagnostic_commands: "\U0001F50D 5. 常用診斷命令"
|
||||
doc_parser.troubleshoot.env_check: "環境檢查:"
|
||||
doc_parser.troubleshoot.manual_verify: "手動驗證:"
|
||||
doc_parser.troubleshoot.view_logs: "日誌查看:"
|
||||
# 取得幫助
|
||||
doc_parser.troubleshoot.more_help: "\U0001F198 6. 取得更多幫助"
|
||||
doc_parser.troubleshoot.if_unsolved: "如果上述方法都無法解決問題,請:"
|
||||
# 即時診斷
|
||||
doc_parser.troubleshoot.realtime_diagnosis: "\U0001F52C 即時環境診斷"
|
||||
doc_parser.troubleshoot.env_good: "✅ 環境狀態良好,所有相依套件都已就緒"
|
||||
doc_parser.troubleshoot.issues_found: "⚠️ 發現以下問題:"
|
||||
doc_parser.troubleshoot.env_check_failed: "❌ 環境檢查失敗: %{error}"
|
||||
doc_parser.troubleshoot.follow_guide: "請按照上述指南進行故障排除"
|
||||
# 提示
|
||||
doc_parser.troubleshoot.tip: "\U0001F4A1 提示: 大多數問題可以透過重新執行 'document-parser uv-init' 解決"
|
||||
# 背景清理
|
||||
doc_parser.background.cleanup_failed: "清理過期資料失敗: %{error}"
|
||||
doc_parser.background.cleanup_complete: "背景清理任務執行完成"
|
||||
# 訊號處理
|
||||
doc_parser.signal.ctrl_c_failed: "無法監聽 Ctrl+C 訊號"
|
||||
doc_parser.signal.terminate_failed: "無法監聽 terminate 訊號"
|
||||
# ===========================================
|
||||
# 通用訊息
|
||||
# ===========================================
|
||||
common.yes: "是"
|
||||
common.no: "否"
|
||||
common.success: "成功"
|
||||
common.failed: "失敗"
|
||||
common.error: "錯誤"
|
||||
common.warning: "警告"
|
||||
common.info: "資訊"
|
||||
common.loading: "載入中..."
|
||||
common.please_wait: "請稍候..."
|
||||
common.retry: "重試"
|
||||
common.cancel: "取消"
|
||||
common.confirm: "確認"
|
||||
common.back: "返回"
|
||||
common.next: "下一步"
|
||||
common.previous: "上一步"
|
||||
common.done: "完成"
|
||||
common.skip: "跳過"
|
||||
65
qiming-mcp-proxy/mcp-proxy/src/client/cli.rs
Normal file
65
qiming-mcp-proxy/mcp-proxy/src/client/cli.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// MCP-Proxy CLI 实现
|
||||
//
|
||||
// CLI 主入口,负责命令分发到各个实现模块
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
// 导出 CLI 特定类型
|
||||
// 注意: ConvertArgs 等通用参数类型已由 client/mod.rs 统一导出
|
||||
pub use crate::client::support::args::{Cli, Commands};
|
||||
|
||||
/// 运行 CLI 主逻辑
|
||||
///
|
||||
/// 根据命令类型分发到对应的实现模块:
|
||||
/// - Convert -> cli_impl/convert_cmd.rs
|
||||
/// - Check/Detect -> cli_impl/check.rs
|
||||
/// - Health -> cli_impl/health.rs
|
||||
/// - Proxy -> proxy_server.rs
|
||||
pub async fn run_cli(cli: Cli) -> Result<()> {
|
||||
match cli.command {
|
||||
Some(Commands::Convert(args)) => {
|
||||
crate::client::cli_impl::run_convert_command(args, cli.verbose, cli.quiet).await
|
||||
}
|
||||
Some(Commands::Check(args)) => {
|
||||
crate::client::cli_impl::run_check_command(args, cli.verbose, cli.quiet).await
|
||||
}
|
||||
Some(Commands::Detect(args)) => {
|
||||
crate::client::cli_impl::run_detect_command(args, cli.verbose, cli.quiet).await
|
||||
}
|
||||
Some(Commands::Health(args)) => {
|
||||
crate::client::cli_impl::run_health_command(args, cli.quiet).await
|
||||
}
|
||||
Some(Commands::Proxy(args)) => {
|
||||
super::proxy_server::run_proxy_command(args, cli.verbose, cli.quiet).await
|
||||
}
|
||||
None => {
|
||||
// 直接 URL 模式(向后兼容)
|
||||
if let Some(url) = cli.url {
|
||||
let args = crate::client::support::args::ConvertArgs {
|
||||
url: Some(url),
|
||||
config: None,
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0, // 无限重试
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30, // 默认 30 秒 ping 一次
|
||||
ping_timeout: 10, // 默认 10 秒超时
|
||||
logging: crate::client::support::LoggingArgs {
|
||||
diagnostic: true, // 默认启用诊断模式
|
||||
log_dir: None, // 默认无日志目录(将在 init_logging 中自动设置)
|
||||
log_file: None, // 默认无日志文件
|
||||
otlp_endpoint: None, // 默认不启用 OTLP 追踪
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
crate::client::cli_impl::run_convert_command(args, cli.verbose, cli.quiet).await
|
||||
} else {
|
||||
bail!("请提供 URL 或使用子命令")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/check.rs
Normal file
42
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/check.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! 检查和检测命令
|
||||
//!
|
||||
//! 实现服务状态检查和协议检测功能
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::support::{CheckArgs, DetectArgs};
|
||||
|
||||
/// 运行检查命令
|
||||
pub async fn run_check_command(args: CheckArgs, _verbose: bool, quiet: bool) -> Result<()> {
|
||||
if !quiet {
|
||||
eprintln!("Checking service health: {}", &args.url);
|
||||
}
|
||||
|
||||
match crate::client::protocol::detect_mcp_protocol(&args.url).await {
|
||||
Ok(protocol) => {
|
||||
if !quiet {
|
||||
eprintln!("Service is healthy (protocol: {protocol})");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
if !quiet {
|
||||
eprintln!("Service check failed: {e}");
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行协议检测命令
|
||||
pub async fn run_detect_command(args: DetectArgs, _verbose: bool, quiet: bool) -> Result<()> {
|
||||
let protocol = crate::client::protocol::detect_mcp_protocol(&args.url).await?;
|
||||
|
||||
if quiet {
|
||||
println!("{}", protocol);
|
||||
} else {
|
||||
eprintln!("{}", protocol);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
130
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/convert_cmd.rs
Normal file
130
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/convert_cmd.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! Convert 命令的 CLI 实现
|
||||
//!
|
||||
//! 处理 convert 命令的参数解析和路由到核心逻辑
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::core::{run_command_mode, run_url_mode_with_retry};
|
||||
use crate::client::support::{ConvertArgs, init_logging, parse_convert_config};
|
||||
use crate::proxy::ToolFilter;
|
||||
|
||||
/// 运行转换命令 - 核心功能
|
||||
pub async fn run_convert_command(args: ConvertArgs, verbose: bool, quiet: bool) -> Result<()> {
|
||||
// 检查 --allow-tools 和 --deny-tools 互斥
|
||||
if args.allow_tools.is_some() && args.deny_tools.is_some() {
|
||||
anyhow::bail!(
|
||||
"--allow-tools and --deny-tools cannot be used together, please choose only one"
|
||||
);
|
||||
}
|
||||
|
||||
// 创建工具过滤器
|
||||
let tool_filter = if let Some(allow_tools) = args.allow_tools.clone() {
|
||||
tracing::info!("Tool allowlist enabled: {:?}", allow_tools);
|
||||
ToolFilter::allow(allow_tools)
|
||||
} else if let Some(deny_tools) = args.deny_tools.clone() {
|
||||
tracing::info!("Tool denylist enabled: {:?}", deny_tools);
|
||||
ToolFilter::deny(deny_tools)
|
||||
} else {
|
||||
tracing::debug!("Tool filter disabled");
|
||||
ToolFilter::default()
|
||||
};
|
||||
|
||||
// 解析配置
|
||||
tracing::debug!("Parsing convert configuration...");
|
||||
let config_source = parse_convert_config(&args)?;
|
||||
tracing::info!("Configuration parsed");
|
||||
|
||||
// 提取 MCP 名称用于日志文件命名
|
||||
let mcp_name = match &config_source {
|
||||
crate::client::support::McpConfigSource::RemoteService { name, .. } => {
|
||||
tracing::info!("Service name: {}", name);
|
||||
Some(name.as_str())
|
||||
}
|
||||
crate::client::support::McpConfigSource::LocalCommand { name, .. } => {
|
||||
tracing::info!("Service name: {}", name);
|
||||
Some(name.as_str())
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("Service name not specified");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化日志系统
|
||||
init_logging(&args, mcp_name, quiet, verbose)?;
|
||||
tracing::debug!("Logging initialized");
|
||||
|
||||
// 记录命令启动(必须在日志系统初始化之后)
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting convert command");
|
||||
tracing::info!("Command: convert");
|
||||
tracing::info!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
tracing::info!("Diagnostic mode: {}", args.logging.diagnostic);
|
||||
tracing::info!("========================================");
|
||||
|
||||
// 根据配置源执行不同逻辑
|
||||
match config_source {
|
||||
crate::client::support::McpConfigSource::DirectUrl { url } => {
|
||||
tracing::info!("Mode: direct URL");
|
||||
tracing::info!("Target URL: {}", url);
|
||||
// 直接 URL 模式(带自动重连)
|
||||
run_url_mode_with_retry(
|
||||
&args,
|
||||
&url,
|
||||
std::collections::HashMap::new(),
|
||||
None,
|
||||
tool_filter,
|
||||
verbose,
|
||||
quiet,
|
||||
)
|
||||
.await
|
||||
}
|
||||
crate::client::support::McpConfigSource::RemoteService {
|
||||
name,
|
||||
url,
|
||||
protocol,
|
||||
headers,
|
||||
timeout,
|
||||
} => {
|
||||
// 远程服务配置模式
|
||||
tracing::info!("Mode: remote service config");
|
||||
tracing::info!("Service name: {}", name);
|
||||
tracing::info!("Service URL: {}", url);
|
||||
if let Some(proto) = &protocol {
|
||||
tracing::info!("Configured protocol: {:?}", proto);
|
||||
}
|
||||
if !headers.is_empty() {
|
||||
tracing::debug!("Custom headers: {:?}", headers);
|
||||
}
|
||||
if let Some(timeout) = timeout {
|
||||
tracing::debug!("Configured timeout: {}s", timeout);
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🚀 MCP-Stdio-Proxy: {} ({}) → stdio", name, url);
|
||||
}
|
||||
// 合并 headers:配置 + 命令行
|
||||
let merged_headers =
|
||||
crate::client::support::merge_headers(headers, &args.header, args.auth.as_ref());
|
||||
run_url_mode_with_retry(
|
||||
&args,
|
||||
&url,
|
||||
merged_headers,
|
||||
protocol.or(timeout.map(|_| crate::client::protocol::McpProtocol::Stream)),
|
||||
tool_filter,
|
||||
verbose,
|
||||
quiet,
|
||||
)
|
||||
.await
|
||||
}
|
||||
crate::client::support::McpConfigSource::LocalCommand {
|
||||
name,
|
||||
command,
|
||||
args: cmd_args,
|
||||
env,
|
||||
} => {
|
||||
// 本地命令模式(子进程继承父进程环境变量,MCP JSON 的 env 会覆盖同名变量)
|
||||
run_command_mode(&name, &command, cmd_args, env, tool_filter, quiet).await
|
||||
}
|
||||
}
|
||||
}
|
||||
207
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/health.rs
Normal file
207
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/health.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! 健康检查命令
|
||||
//!
|
||||
//! 通过建立 MCP 连接验证服务是否健康
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use mcp_common::McpClientConfig;
|
||||
use mcp_sse_proxy::SseClientConnection;
|
||||
use mcp_streamable_proxy::StreamClientConnection;
|
||||
|
||||
use crate::client::protocol::detect_mcp_protocol;
|
||||
use crate::client::proxy_server::ProxyProtocol;
|
||||
use crate::client::support::HealthArgs;
|
||||
use crate::model::McpProtocol;
|
||||
|
||||
/// 健康检查结果
|
||||
struct HealthCheckResult {
|
||||
/// 工具数量
|
||||
tool_count: usize,
|
||||
/// 服务器名称
|
||||
server_name: Option<String>,
|
||||
/// 服务器版本
|
||||
server_version: Option<String>,
|
||||
}
|
||||
|
||||
/// 运行健康检查命令
|
||||
///
|
||||
/// 通过真正建立 MCP 连接来验证服务是否健康。
|
||||
/// 成功返回 Ok(()),失败返回 Err。
|
||||
pub async fn run_health_command(args: HealthArgs, quiet: bool) -> Result<()> {
|
||||
if !quiet {
|
||||
eprintln!("Checking health for: {}", &args.url);
|
||||
}
|
||||
|
||||
// 1. 确定协议类型
|
||||
let protocol = match &args.protocol {
|
||||
Some(p) => {
|
||||
let proto = proxy_protocol_to_mcp_protocol(p.clone());
|
||||
if !quiet {
|
||||
eprintln!("Using protocol: {}", protocol_display_name(&proto));
|
||||
}
|
||||
proto
|
||||
}
|
||||
None => {
|
||||
if !quiet {
|
||||
eprintln!("Detecting protocol...");
|
||||
}
|
||||
let proto = detect_mcp_protocol(&args.url).await?;
|
||||
if !quiet {
|
||||
eprintln!("Detected protocol: {}", protocol_display_name(&proto));
|
||||
}
|
||||
proto
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 检查协议类型是否支持
|
||||
if protocol == McpProtocol::Stdio {
|
||||
bail!("Stdio protocol is not supported for health checks");
|
||||
}
|
||||
|
||||
// 3. 构建配置
|
||||
let config = build_config(&args);
|
||||
|
||||
// 4. 尝试连接(带超时)
|
||||
let start = Instant::now();
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(args.timeout),
|
||||
connect_and_verify(&config, protocol.clone()),
|
||||
)
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// 5. 输出结果
|
||||
match result {
|
||||
Ok(Ok(health_result)) => {
|
||||
if !quiet {
|
||||
eprintln!("Health check passed");
|
||||
eprintln!(" Protocol: {}", protocol_display_name(&protocol));
|
||||
eprintln!(" Tools: {}", health_result.tool_count);
|
||||
eprintln!(" Response time: {}ms", elapsed.as_millis());
|
||||
if let (Some(name), Some(version)) =
|
||||
(&health_result.server_name, &health_result.server_version)
|
||||
{
|
||||
eprintln!(" Server: {} v{}", name, version);
|
||||
} else if let Some(name) = &health_result.server_name {
|
||||
eprintln!(" Server: {}", name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
if !quiet {
|
||||
eprintln!("Health check failed");
|
||||
eprintln!(" Error: {}", e);
|
||||
eprintln!(" Response time: {}ms", elapsed.as_millis());
|
||||
}
|
||||
Err(anyhow::anyhow!("health check failed: {}", e))
|
||||
}
|
||||
Err(_) => {
|
||||
if !quiet {
|
||||
eprintln!("Health check failed");
|
||||
eprintln!(" Error: connection timed out ({}s)", args.timeout);
|
||||
}
|
||||
Err(anyhow::anyhow!("health check timeout"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取协议的显示名称
|
||||
fn protocol_display_name(protocol: &McpProtocol) -> &'static str {
|
||||
match protocol {
|
||||
McpProtocol::Sse => "SSE",
|
||||
McpProtocol::Stream => "Streamable HTTP",
|
||||
McpProtocol::Stdio => "Stdio",
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ProxyProtocol 转换为 McpProtocol
|
||||
fn proxy_protocol_to_mcp_protocol(p: ProxyProtocol) -> McpProtocol {
|
||||
match p {
|
||||
ProxyProtocol::Sse => McpProtocol::Sse,
|
||||
ProxyProtocol::Stream => McpProtocol::Stream,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 MCP 客户端配置
|
||||
fn build_config(args: &HealthArgs) -> McpClientConfig {
|
||||
let mut config = McpClientConfig::new(&args.url);
|
||||
|
||||
// 添加认证 header
|
||||
if let Some(auth) = &args.auth {
|
||||
config = config.with_header("Authorization", auth);
|
||||
}
|
||||
|
||||
// 添加自定义 headers
|
||||
for (key, value) in &args.header {
|
||||
config = config.with_header(key, value);
|
||||
}
|
||||
|
||||
// 设置超时
|
||||
config = config.with_connect_timeout(Duration::from_secs(args.timeout));
|
||||
config = config.with_read_timeout(Duration::from_secs(args.timeout));
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// 尝试连接并验证服务
|
||||
async fn connect_and_verify(
|
||||
config: &McpClientConfig,
|
||||
protocol: McpProtocol,
|
||||
) -> Result<HealthCheckResult> {
|
||||
match protocol {
|
||||
McpProtocol::Sse => {
|
||||
let conn = SseClientConnection::connect(config.clone()).await?;
|
||||
|
||||
// 获取工具列表
|
||||
let tools = conn.list_tools().await?;
|
||||
let tool_count = tools.len();
|
||||
|
||||
// 获取服务器信息
|
||||
let (server_name, server_version) = conn
|
||||
.peer_info()
|
||||
.map(|info| {
|
||||
(
|
||||
Some(info.server_info.name.clone()),
|
||||
Some(info.server_info.version.clone()),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
Ok(HealthCheckResult {
|
||||
tool_count,
|
||||
server_name,
|
||||
server_version,
|
||||
})
|
||||
}
|
||||
McpProtocol::Stream => {
|
||||
let conn = StreamClientConnection::connect(config.clone()).await?;
|
||||
|
||||
// 获取工具列表
|
||||
let tools = conn.list_tools().await?;
|
||||
let tool_count = tools.len();
|
||||
|
||||
// 获取服务器信息
|
||||
let (server_name, server_version) = conn
|
||||
.peer_info()
|
||||
.map(|info| {
|
||||
(
|
||||
Some(info.server_info.name.clone()),
|
||||
Some(info.server_info.version.clone()),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
Ok(HealthCheckResult {
|
||||
tool_count,
|
||||
server_name,
|
||||
server_version,
|
||||
})
|
||||
}
|
||||
McpProtocol::Stdio => {
|
||||
// 不应该到达这里,因为前面已经检查过了
|
||||
bail!("stdio protocol is not supported for health check")
|
||||
}
|
||||
}
|
||||
}
|
||||
12
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/mod.rs
Normal file
12
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! CLI 实现模块
|
||||
//!
|
||||
//! 处理 CLI 命令的实现和参数解析
|
||||
|
||||
pub mod check;
|
||||
pub mod convert_cmd;
|
||||
pub mod health;
|
||||
|
||||
// 导出 CLI 命令实现
|
||||
pub use check::{run_check_command, run_detect_command};
|
||||
pub use convert_cmd::run_convert_command;
|
||||
pub use health::run_health_command;
|
||||
165
qiming-mcp-proxy/mcp-proxy/src/client/core/command.rs
Normal file
165
qiming-mcp-proxy/mcp-proxy/src/client/core/command.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! 本地命令模式处理
|
||||
//!
|
||||
//! 处理本地命令形式的 MCP 服务(通过子进程)
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
|
||||
use crate::proxy::{StreamProxyHandler, ToolFilter};
|
||||
|
||||
// 使用 mcp-streamable-proxy 的类型(rmcp 0.12,process-wrap 9.0)
|
||||
use mcp_streamable_proxy::{
|
||||
ClientCapabilities, ClientInfo, Implementation, ServiceExt, TokioChildProcess, stdio,
|
||||
};
|
||||
|
||||
use crate::client::support::utils::truncate_str;
|
||||
|
||||
// 进程组管理(跨平台子进程清理)
|
||||
use process_wrap::tokio::{CommandWrap, KillOnDrop};
|
||||
|
||||
#[cfg(unix)]
|
||||
use process_wrap::tokio::ProcessGroup;
|
||||
|
||||
#[cfg(windows)]
|
||||
use process_wrap::tokio::JobObject;
|
||||
|
||||
/// 命令模式执行(本地子进程)
|
||||
/// 使用 mcp-streamable-proxy(rmcp 0.12)实现 stdio CLI 模式
|
||||
pub async fn run_command_mode(
|
||||
name: &str,
|
||||
command: &str,
|
||||
cmd_args: Vec<String>,
|
||||
env: HashMap<String, String>,
|
||||
tool_filter: ToolFilter,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Running in local command mode");
|
||||
tracing::info!("Command: {} {:?}", command, cmd_args);
|
||||
if !env.is_empty() {
|
||||
tracing::debug!("Env var count: {}", env.len());
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🚀 MCP-Stdio-Proxy: {} (command) → stdio", name);
|
||||
eprintln!(" Command: {} {:?}", command, cmd_args);
|
||||
if !env.is_empty() {
|
||||
eprintln!(" Env vars: {:?}", env);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示过滤器配置
|
||||
if !quiet && tool_filter.is_enabled() {
|
||||
eprintln!("🔧 Tool filtering enabled");
|
||||
}
|
||||
|
||||
// 诊断日志:记录子进程启动信息
|
||||
tracing::debug!("[child-process] {} {:?}", command, cmd_args);
|
||||
|
||||
// 使用 process-wrap 创建子进程命令(跨平台进程清理)
|
||||
// process-wrap 会自动处理进程组(Unix)或 Job Object(Windows)
|
||||
// 并且在 Drop 时自动清理子进程树
|
||||
// 子进程默认继承父进程的所有环境变量
|
||||
let mut wrapped_cmd = CommandWrap::with_new(command, |cmd| {
|
||||
cmd.args(&cmd_args);
|
||||
|
||||
// 设置 MCP JSON 配置中的环境变量(会覆盖继承的同名变量)
|
||||
for (k, v) in &env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
});
|
||||
// Unix: 创建进程组,支持 killpg 清理整个进程树
|
||||
#[cfg(unix)]
|
||||
wrapped_cmd.wrap(ProcessGroup::leader());
|
||||
// Windows: 使用 Job Object 管理进程树,并隐藏控制台窗口
|
||||
// 使用 CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP 确保孙进程也不弹出窗口
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use process_wrap::tokio::CreationFlags;
|
||||
use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
|
||||
wrapped_cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
|
||||
wrapped_cmd.wrap(JobObject);
|
||||
}
|
||||
// 所有平台: Drop 时自动清理进程
|
||||
wrapped_cmd.wrap(KillOnDrop);
|
||||
|
||||
// 启动子进程
|
||||
// 使用 builder 模式捕获 stderr,便于诊断子 MCP 服务初始化失败
|
||||
tracing::debug!("Starting child process...");
|
||||
let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// 启动 stderr 日志读取任务
|
||||
if let Some(stderr_pipe) = child_stderr {
|
||||
mcp_common::spawn_stderr_reader(stderr_pipe, name.to_string());
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Starting child process...");
|
||||
}
|
||||
|
||||
// 创建 ClientInfo(使用 rmcp 0.12 类型)
|
||||
let client_info = create_client_info();
|
||||
|
||||
// 连接到子进程
|
||||
let running = client_info.serve(tokio_process).await?;
|
||||
|
||||
if !quiet {
|
||||
eprintln!("✅ Child process started, proxying to stdio...");
|
||||
|
||||
// 打印工具列表
|
||||
match running.list_tools(None).await {
|
||||
Ok(tools_result) => {
|
||||
let tools = &tools_result.tools;
|
||||
if tools.is_empty() {
|
||||
eprintln!("⚠️ Tool list is empty (tools/list returned 0 tools)");
|
||||
} else {
|
||||
eprintln!("🔧 Available tools ({}):", tools.len());
|
||||
for tool in tools {
|
||||
let desc = tool.description.as_deref().unwrap_or("no description");
|
||||
let desc_short = truncate_str(desc, 50);
|
||||
eprintln!(" - {} : {}", tool.name, desc_short);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to list tools: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("💡 You can now send JSON-RPC requests through stdin");
|
||||
}
|
||||
|
||||
// 使用 StreamProxyHandler + stdio 将本地 MCP 服务透明暴露为 stdio
|
||||
let proxy_handler =
|
||||
StreamProxyHandler::with_tool_filter(running, name.to_string(), tool_filter);
|
||||
let server = proxy_handler.serve(stdio()).await?;
|
||||
|
||||
// 设置 Ctrl+C 信号处理
|
||||
tokio::select! {
|
||||
result = server.waiting() => {
|
||||
result?;
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("Ctrl+C received, shutting down");
|
||||
// tokio runtime 会清理资源,包括子进程
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建 ClientInfo(使用 rmcp 1.1.0 类型)
|
||||
fn create_client_info() -> ClientInfo {
|
||||
let capabilities = ClientCapabilities::builder()
|
||||
.enable_experimental()
|
||||
.enable_roots()
|
||||
.enable_roots_list_changed()
|
||||
.enable_sampling()
|
||||
.build();
|
||||
ClientInfo::new(
|
||||
capabilities,
|
||||
Implementation::new("mcp-proxy-cli", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
}
|
||||
168
qiming-mcp-proxy/mcp-proxy/src/client/core/common.rs
Normal file
168
qiming-mcp-proxy/mcp-proxy/src/client/core/common.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! 公共模块 - 提取 SSE 和 Stream 模式的共享逻辑
|
||||
//!
|
||||
//! 减少代码重复,统一健康检查行为
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 健康检查能力 trait
|
||||
///
|
||||
/// 抽象 SSE 和 Stream handler 的共同行为
|
||||
pub trait HealthChecker: Send + Sync {
|
||||
/// 检查后端是否可用(同步检查)
|
||||
fn is_backend_available(&self) -> bool;
|
||||
|
||||
/// 检查是否已终止(异步,会调用 list_tools 验证后端服务)
|
||||
fn is_terminated_async(&self) -> impl Future<Output = bool> + Send;
|
||||
}
|
||||
|
||||
/// 监控连接健康状态
|
||||
///
|
||||
/// 提取自 monitor_sse_connection 和 monitor_stream_connection 的公共逻辑
|
||||
///
|
||||
/// # 参数
|
||||
/// - `handler`: 实现 HealthChecker trait 的 handler
|
||||
/// - `ping_interval`: ping 间隔(秒),0 表示禁用 ping
|
||||
/// - `ping_timeout`: ping 超时(秒)
|
||||
/// - `quiet`: 是否静默模式
|
||||
/// - `protocol_name`: 协议名称,用于日志输出(如 "SSE" 或 "Stream")
|
||||
///
|
||||
/// # 返回值
|
||||
/// 返回断开连接的原因描述
|
||||
pub async fn monitor_connection_health<H: HealthChecker>(
|
||||
handler: &H,
|
||||
ping_interval: u64,
|
||||
ping_timeout: u64,
|
||||
quiet: bool,
|
||||
protocol_name: &str,
|
||||
) -> String {
|
||||
let connection_start = std::time::Instant::now();
|
||||
if !quiet {
|
||||
tracing::info!("[{}] Connection health monitoring started", protocol_name);
|
||||
}
|
||||
|
||||
// 健康检查日志间隔:与 ping 间隔一致,但至少 30 秒
|
||||
let health_log_interval_secs = if ping_interval > 0 {
|
||||
ping_interval.max(30)
|
||||
} else {
|
||||
30
|
||||
};
|
||||
|
||||
if ping_interval == 0 {
|
||||
// 没有 ping 检测,仅检查连接状态
|
||||
let mut health_log_interval =
|
||||
tokio::time::interval(Duration::from_secs(health_log_interval_secs));
|
||||
health_log_interval.tick().await; // 跳过第一次
|
||||
let mut check_count = 0u64;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {
|
||||
if !handler.is_backend_available() {
|
||||
let alive_duration = connection_start.elapsed();
|
||||
let disconnect_reason =
|
||||
format!(
|
||||
"[{}] Backend connection closed (alive: {}s)",
|
||||
protocol_name,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
if !quiet {
|
||||
tracing::error!("{}", disconnect_reason);
|
||||
}
|
||||
return disconnect_reason;
|
||||
}
|
||||
}
|
||||
_ = health_log_interval.tick() => {
|
||||
check_count += 1;
|
||||
let alive_duration = connection_start.elapsed();
|
||||
let backend_available = handler.is_backend_available();
|
||||
tracing::info!(
|
||||
"[{}] Health check #{} status={}, alive={}s",
|
||||
protocol_name,
|
||||
check_count,
|
||||
if backend_available { "ok" } else { "error" },
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(ping_interval));
|
||||
interval.tick().await;
|
||||
let mut ping_count = 0u64;
|
||||
let mut last_health_log = std::time::Instant::now();
|
||||
let mut first_ping = true; // 标记是否是第一次 ping
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
ping_count += 1;
|
||||
|
||||
let alive_duration = connection_start.elapsed();
|
||||
|
||||
if !handler.is_backend_available() {
|
||||
let disconnect_reason = format!(
|
||||
"[{}] Backend connection closed (alive: {}s)",
|
||||
protocol_name,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
if !quiet {
|
||||
tracing::error!("{}", disconnect_reason);
|
||||
}
|
||||
return disconnect_reason;
|
||||
}
|
||||
|
||||
let check_result = tokio::time::timeout(
|
||||
Duration::from_secs(ping_timeout),
|
||||
handler.is_terminated_async(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match check_result {
|
||||
Ok(true) => {
|
||||
let disconnect_reason = format!(
|
||||
"[{}] Ping check failed (service error), alive: {}s",
|
||||
protocol_name,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
if !quiet {
|
||||
tracing::error!("{}", disconnect_reason);
|
||||
}
|
||||
return disconnect_reason;
|
||||
}
|
||||
Ok(false) => {
|
||||
// 第一次 ping 成功后打印,之后每隔 health_log_interval_secs 秒打印一次
|
||||
let since_last_log = last_health_log.elapsed().as_secs();
|
||||
if first_ping || since_last_log >= health_log_interval_secs {
|
||||
tracing::info!(
|
||||
"[{}] Ping check #{} passed, alive={}s",
|
||||
protocol_name,
|
||||
ping_count,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
last_health_log = std::time::Instant::now();
|
||||
first_ping = false;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[{}] list_tools ping #{} passed, alive={}s",
|
||||
protocol_name,
|
||||
ping_count,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let disconnect_reason = format!(
|
||||
"[{}] Ping check timeout ({}s), alive: {}s",
|
||||
protocol_name,
|
||||
ping_timeout,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
if !quiet {
|
||||
tracing::error!("{}", disconnect_reason);
|
||||
}
|
||||
return disconnect_reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
157
qiming-mcp-proxy/mcp-proxy/src/client/core/convert.rs
Normal file
157
qiming-mcp-proxy/mcp-proxy/src/client/core/convert.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! 协议转换核心逻辑
|
||||
//!
|
||||
//! 处理协议转换的主要流程,包括 URL 模式、协议检测等
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::client::support::{ConvertArgs, protocol_name};
|
||||
use crate::proxy::{McpClientConfig, ToolFilter};
|
||||
|
||||
use super::sse::run_sse_mode;
|
||||
use super::stream::run_stream_mode;
|
||||
|
||||
/// URL 模式执行(带自动重连)
|
||||
/// 使用分支逻辑:根据协议类型调用不同的处理函数
|
||||
pub async fn run_url_mode_with_retry(
|
||||
args: &ConvertArgs,
|
||||
url: &str,
|
||||
merged_headers: HashMap<String, String>,
|
||||
config_protocol: Option<crate::client::protocol::McpProtocol>,
|
||||
tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Starting protocol conversion");
|
||||
tracing::info!("Target URL: {url}");
|
||||
tracing::debug!("Header count: {}", merged_headers.len());
|
||||
tracing::debug!(
|
||||
"Ping interval: {}s, ping timeout: {}s",
|
||||
args.ping_interval,
|
||||
args.ping_timeout
|
||||
);
|
||||
tracing::debug!("Retry count: {} (0 = unlimited)", args.retries);
|
||||
|
||||
if !quiet && merged_headers.is_empty() {
|
||||
eprintln!("🚀 MCP-Stdio-Proxy: {} → stdio", url);
|
||||
}
|
||||
|
||||
// 显示过滤器配置
|
||||
if !quiet {
|
||||
if let Some(ref allow_tools) = args.allow_tools {
|
||||
tracing::info!("Tool allowlist: {:?}", allow_tools);
|
||||
}
|
||||
if let Some(ref deny_tools) = args.deny_tools {
|
||||
tracing::info!("Tool denylist: {:?}", deny_tools);
|
||||
}
|
||||
}
|
||||
|
||||
// 确定协议类型:命令行参数 > 配置文件 > 自动检测
|
||||
let protocol = if let Some(ref proto) = args.protocol {
|
||||
let detected = match proto {
|
||||
crate::client::proxy_server::ProxyProtocol::Sse => {
|
||||
crate::client::protocol::McpProtocol::Sse
|
||||
}
|
||||
crate::client::proxy_server::ProxyProtocol::Stream => {
|
||||
crate::client::protocol::McpProtocol::Stream
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
"Using protocol from CLI argument: {}",
|
||||
protocol_name(&detected)
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("🔧 Using protocol from CLI: {}", protocol_name(&detected));
|
||||
}
|
||||
detected
|
||||
} else if let Some(proto) = config_protocol {
|
||||
tracing::info!("Using protocol from config: {}", protocol_name(&proto));
|
||||
if !quiet {
|
||||
eprintln!("🔧 Using protocol from config: {}", protocol_name(&proto));
|
||||
}
|
||||
proto
|
||||
} else {
|
||||
tracing::info!("Detecting protocol...");
|
||||
if !quiet {
|
||||
eprintln!("🔍 Detecting protocol...");
|
||||
}
|
||||
let detection_start = std::time::Instant::now();
|
||||
let detected = crate::client::protocol::detect_mcp_protocol(url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Protocol detection failed: {}", e);
|
||||
e
|
||||
})?;
|
||||
let detection_duration = detection_start.elapsed();
|
||||
tracing::info!(
|
||||
"Protocol detection completed: protocol={}, duration={:?}",
|
||||
protocol_name(&detected),
|
||||
detection_duration
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("🔍 Detected protocol: {}", protocol_name(&detected));
|
||||
}
|
||||
detected
|
||||
};
|
||||
|
||||
// 构建 McpClientConfig
|
||||
tracing::debug!("Building MCP client config...");
|
||||
let config = build_mcp_config(url, &merged_headers, args.auth.as_ref());
|
||||
tracing::debug!("MCP client config ready");
|
||||
|
||||
// 根据协议类型分支处理
|
||||
tracing::info!("Using protocol: {}", protocol_name(&protocol));
|
||||
match protocol {
|
||||
crate::client::protocol::McpProtocol::Sse => {
|
||||
run_sse_mode(config, args.clone(), tool_filter, verbose, quiet)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("SSE mode failed: {:?}", e);
|
||||
eprintln!("❌ SSE mode failed: {}", e);
|
||||
e
|
||||
})
|
||||
}
|
||||
crate::client::protocol::McpProtocol::Stream => {
|
||||
run_stream_mode(config, args.clone(), tool_filter, verbose, quiet)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Stream mode failed: {:?}", e);
|
||||
eprintln!("❌ Stream mode failed: {}", e);
|
||||
e
|
||||
})
|
||||
}
|
||||
crate::client::protocol::McpProtocol::Stdio => {
|
||||
tracing::error!("Stdio protocol does not support URL conversion");
|
||||
anyhow::bail!(
|
||||
"Stdio protocol does not support URL conversion, please use --config for local commands"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 McpClientConfig
|
||||
pub fn build_mcp_config(
|
||||
url: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
auth: Option<&String>,
|
||||
) -> McpClientConfig {
|
||||
let mut config = McpClientConfig::new(url);
|
||||
for (k, v) in headers {
|
||||
// Authorization header: 确保有 "Bearer " 前缀,与 Server 模式行为一致
|
||||
if k.eq_ignore_ascii_case("Authorization") {
|
||||
let value = if v.starts_with("Bearer ") {
|
||||
v.clone()
|
||||
} else {
|
||||
format!("Bearer {}", v)
|
||||
};
|
||||
config = config.with_header(k, value);
|
||||
} else {
|
||||
config = config.with_header(k, v);
|
||||
}
|
||||
}
|
||||
if let Some(auth_value) = auth {
|
||||
// 命令行 --auth 参数不带 "Bearer " 前缀,直接添加
|
||||
config = config.with_header("Authorization", auth_value);
|
||||
}
|
||||
config
|
||||
}
|
||||
15
qiming-mcp-proxy/mcp-proxy/src/client/core/mod.rs
Normal file
15
qiming-mcp-proxy/mcp-proxy/src/client/core/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! 核心业务逻辑模块
|
||||
//!
|
||||
//! 包含协议转换的核心实现,与 CLI 接口解耦
|
||||
|
||||
pub mod command;
|
||||
pub mod common;
|
||||
pub mod convert;
|
||||
pub mod sse;
|
||||
pub mod stream;
|
||||
|
||||
// 导出公共 API
|
||||
// 注意: run_sse_mode 和 run_stream_mode 是内部实现细节,
|
||||
// 只被 convert 模块使用,不需要对外暴露
|
||||
pub use command::run_command_mode;
|
||||
pub use convert::run_url_mode_with_retry;
|
||||
436
qiming-mcp-proxy/mcp-proxy/src/client/core/sse.rs
Normal file
436
qiming-mcp-proxy/mcp-proxy/src/client/core/sse.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
//! SSE 模式处理
|
||||
//!
|
||||
//! Server-Sent Events 协议的实现和连接管理
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::error;
|
||||
|
||||
use super::common::HealthChecker;
|
||||
use crate::client::support::{
|
||||
ConvertArgs, classify_error, print_diagnostic_report, summarize_error, truncate_str,
|
||||
};
|
||||
use crate::proxy::{McpClientConfig, ProxyHandler, SseClientConnection, ToolFilter};
|
||||
|
||||
use mcp_sse_proxy::{ServiceExt, stdio as sse_stdio};
|
||||
|
||||
/// 为 ProxyHandler 实现 HealthChecker trait
|
||||
impl HealthChecker for ProxyHandler {
|
||||
fn is_backend_available(&self) -> bool {
|
||||
self.is_backend_available()
|
||||
}
|
||||
|
||||
async fn is_terminated_async(&self) -> bool {
|
||||
self.is_terminated_async().await
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE 模式处理(使用 mcp-sse-proxy,rmcp 0.10)
|
||||
pub async fn run_sse_mode(
|
||||
config: McpClientConfig,
|
||||
args: ConvertArgs,
|
||||
tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting SSE mode");
|
||||
tracing::info!("Target URL: {}", config.url);
|
||||
tracing::info!(
|
||||
"Ping config: interval={}s, timeout={}s",
|
||||
args.ping_interval,
|
||||
args.ping_timeout
|
||||
);
|
||||
tracing::info!("========================================");
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Connecting to backend service (SSE)...");
|
||||
}
|
||||
|
||||
// 1. 使用高层 API 连接(带重试,防止初始连接因时序问题失败)
|
||||
let connect_timeout = Duration::from_secs(15);
|
||||
const MAX_INITIAL_RETRIES: u32 = 3;
|
||||
const INITIAL_BACKOFF_SECS: u64 = 2;
|
||||
const MAX_BACKOFF_SECS: u64 = 4;
|
||||
|
||||
tracing::info!(
|
||||
"Connecting to backend (per-attempt timeout: {}s, max retries: {})",
|
||||
connect_timeout.as_secs(),
|
||||
MAX_INITIAL_RETRIES
|
||||
);
|
||||
let connect_start = std::time::Instant::now();
|
||||
|
||||
let mut last_error = None;
|
||||
let mut conn = None;
|
||||
let mut backoff_secs = INITIAL_BACKOFF_SECS;
|
||||
for attempt in 1..=MAX_INITIAL_RETRIES {
|
||||
match tokio::time::timeout(
|
||||
connect_timeout,
|
||||
SseClientConnection::connect(config.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(c)) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(
|
||||
"Backend connection succeeded on attempt {}/{}",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES
|
||||
);
|
||||
}
|
||||
conn = Some(c);
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
"Backend connection attempt {}/{} failed: {}",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES,
|
||||
e
|
||||
);
|
||||
last_error = Some(format!("Backend connection failed: {}", e));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"Backend connection attempt {}/{} timed out ({}s)",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES,
|
||||
connect_timeout.as_secs()
|
||||
);
|
||||
last_error = Some(format!(
|
||||
"Backend connection timeout ({}s)",
|
||||
connect_timeout.as_secs()
|
||||
));
|
||||
}
|
||||
}
|
||||
if attempt < MAX_INITIAL_RETRIES {
|
||||
tracing::info!("Retrying in {}s... (elapsed: {:?})", backoff_secs, connect_start.elapsed());
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ Connection attempt {}/{} failed, retrying in {}s...",
|
||||
attempt, MAX_INITIAL_RETRIES, backoff_secs
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
let conn = conn.ok_or_else(|| {
|
||||
let elapsed = connect_start.elapsed();
|
||||
let msg = last_error.unwrap_or_else(|| "Unknown connection error".to_string());
|
||||
tracing::error!(
|
||||
"All {} connection attempts failed after {:?}: {}",
|
||||
MAX_INITIAL_RETRIES, elapsed, msg
|
||||
);
|
||||
eprintln!(
|
||||
"❌ All {} connection attempts failed after {:.1}s: {}",
|
||||
MAX_INITIAL_RETRIES, elapsed.as_secs_f64(), msg
|
||||
);
|
||||
anyhow::anyhow!(msg)
|
||||
})?;
|
||||
|
||||
let connect_duration = connect_start.elapsed();
|
||||
tracing::info!(
|
||||
"Backend connected successfully (duration: {:?})",
|
||||
connect_duration
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("✅ Backend connected successfully");
|
||||
// 打印工具列表
|
||||
print_sse_tools(&conn, quiet).await;
|
||||
if args.ping_interval > 0 {
|
||||
eprintln!(
|
||||
"💓 Health ping: every {}s (timeout {}s)",
|
||||
args.ping_interval, args.ping_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建 handler(消耗 conn)
|
||||
tracing::debug!("Creating ProxyHandler...");
|
||||
let handler = Arc::new(conn.into_handler("cli".to_string(), tool_filter.clone()));
|
||||
tracing::debug!("ProxyHandler created");
|
||||
|
||||
// 3. 启动 stdio server
|
||||
tracing::info!("Starting stdio server...");
|
||||
let server = (*handler).clone().serve(sse_stdio()).await.map_err(|e| {
|
||||
tracing::error!("Failed to start stdio server: {:?}", e);
|
||||
eprintln!("❌ Failed to start stdio server: {}", e);
|
||||
e
|
||||
})?;
|
||||
tracing::info!("Stdio server started");
|
||||
|
||||
if !quiet {
|
||||
eprintln!("💡 Stdio server started, proxying traffic...");
|
||||
}
|
||||
|
||||
// 4. 启动 watchdog 任务
|
||||
let handler_for_watchdog = handler.clone();
|
||||
let mut watchdog_handle = tokio::spawn(run_sse_watchdog(
|
||||
handler_for_watchdog,
|
||||
args,
|
||||
config,
|
||||
tool_filter,
|
||||
verbose,
|
||||
quiet,
|
||||
));
|
||||
tracing::debug!("Watchdog task started");
|
||||
|
||||
// 5. 等待 stdio server 退出
|
||||
tracing::info!("Waiting for stdio/watchdog events...");
|
||||
tokio::select! {
|
||||
result = server.waiting() => {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Stdio server exited (EOF)");
|
||||
tracing::info!("========================================");
|
||||
watchdog_handle.abort();
|
||||
result?;
|
||||
}
|
||||
watchdog_result = &mut watchdog_handle => {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Watchdog task exited");
|
||||
tracing::info!("========================================");
|
||||
if let Err(e) = watchdog_result
|
||||
&& !e.is_cancelled()
|
||||
{
|
||||
error!("SSE Watchdog task failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("SSE mode exited normally");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打印 SSE 连接的工具列表
|
||||
async fn print_sse_tools(conn: &SseClientConnection, quiet: bool) {
|
||||
if quiet {
|
||||
return;
|
||||
}
|
||||
match conn.list_tools().await {
|
||||
Ok(tools) => {
|
||||
if tools.is_empty() {
|
||||
eprintln!("⚠️ Tool list is empty (tools/list returned 0 tools)");
|
||||
} else {
|
||||
eprintln!("🔧 Available tools ({}):", tools.len());
|
||||
for tool in &tools {
|
||||
let desc = tool.description.as_deref().unwrap_or("no description");
|
||||
let desc_short = truncate_str(desc, 50);
|
||||
eprintln!(" - {} : {}", tool.name, desc_short);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to list tools: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE 模式的 watchdog:负责监控连接健康、断开时重连
|
||||
async fn run_sse_watchdog(
|
||||
handler: Arc<ProxyHandler>,
|
||||
args: ConvertArgs,
|
||||
config: McpClientConfig,
|
||||
_tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting SSE watchdog");
|
||||
tracing::info!("Max retries: {}", args.retries);
|
||||
tracing::info!("========================================");
|
||||
|
||||
let max_retries = args.retries;
|
||||
let mut attempt = 0u32;
|
||||
let mut backoff_secs = 1u64;
|
||||
const MAX_BACKOFF_SECS: u64 = 30;
|
||||
const EVENT_DISCONNECTED: &str = "EVENT_DISCONNECTED";
|
||||
const EVENT_RECONNECTED: &str = "EVENT_RECONNECTED";
|
||||
const EVENT_RETRY_BACKOFF: &str = "EVENT_RETRY_BACKOFF";
|
||||
let initial_connection_start = std::time::Instant::now();
|
||||
|
||||
// 首先监控现有连接的健康状态
|
||||
tracing::info!("Monitoring initial connection health");
|
||||
let disconnect_reason =
|
||||
monitor_sse_connection(&handler, args.ping_interval, args.ping_timeout, quiet).await;
|
||||
|
||||
// 连接断开,标记后端不可用
|
||||
tracing::warn!("Initial connection disconnected: {}", disconnect_reason);
|
||||
handler.swap_backend(None);
|
||||
|
||||
let alive_duration = initial_connection_start.elapsed();
|
||||
tracing::info!(
|
||||
"Initial connection alive duration: {}s",
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection disconnected: {}",
|
||||
EVENT_DISCONNECTED, disconnect_reason
|
||||
);
|
||||
}
|
||||
|
||||
// 生成诊断报告(首次断开)
|
||||
print_diagnostic_report(
|
||||
"SSE",
|
||||
&config.url,
|
||||
alive_duration.as_secs(),
|
||||
&disconnect_reason,
|
||||
None,
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
|
||||
// 进入重连循环
|
||||
loop {
|
||||
attempt += 1;
|
||||
tracing::info!("========================================");
|
||||
if max_retries == 0 {
|
||||
tracing::info!("Reconnect attempt #{} (unlimited mode)", attempt);
|
||||
} else {
|
||||
tracing::info!("Reconnect attempt {}/{}", attempt, max_retries);
|
||||
}
|
||||
tracing::info!("Backoff: {}s", backoff_secs);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Reconnecting (attempt #{})...", attempt);
|
||||
}
|
||||
|
||||
// 尝试建立连接
|
||||
tracing::debug!("Attempting to establish connection...");
|
||||
let connect_start = std::time::Instant::now();
|
||||
let connect_result = SseClientConnection::connect(config.clone()).await;
|
||||
let connect_duration = connect_start.elapsed();
|
||||
|
||||
match connect_result {
|
||||
Ok(conn) => {
|
||||
tracing::info!("Reconnect succeeded (duration: {:?})", connect_duration);
|
||||
|
||||
// 连接成功,获取 RunningService 并热替换后端
|
||||
let running = conn.into_running_service();
|
||||
handler.swap_backend(Some(running));
|
||||
backoff_secs = 1;
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"✅ [{}] Reconnected, proxy service resumed",
|
||||
EVENT_RECONNECTED
|
||||
);
|
||||
}
|
||||
|
||||
// 监控连接健康
|
||||
tracing::info!("Monitoring connection after reconnect");
|
||||
let reconnect_start = std::time::Instant::now();
|
||||
let disconnect_reason =
|
||||
monitor_sse_connection(&handler, args.ping_interval, args.ping_timeout, quiet)
|
||||
.await;
|
||||
|
||||
// 连接断开,标记后端不可用
|
||||
tracing::warn!("Reconnected session disconnected: {}", disconnect_reason);
|
||||
handler.swap_backend(None);
|
||||
let reconnect_alive_duration = reconnect_start.elapsed();
|
||||
tracing::info!(
|
||||
"Reconnected session alive duration: {}s",
|
||||
reconnect_alive_duration.as_secs()
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection disconnected: {}",
|
||||
EVENT_DISCONNECTED, disconnect_reason
|
||||
);
|
||||
}
|
||||
|
||||
// 生成诊断报告(重连后断开)
|
||||
print_diagnostic_report(
|
||||
"SSE",
|
||||
&config.url,
|
||||
reconnect_alive_duration.as_secs(),
|
||||
&disconnect_reason,
|
||||
None,
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let error_type = classify_error(&e);
|
||||
tracing::error!(
|
||||
"Connection failed [{}]: {} (duration: {:?})",
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
connect_duration
|
||||
);
|
||||
|
||||
if max_retries > 0 && attempt >= max_retries {
|
||||
tracing::error!("Max retries reached: {}", max_retries);
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"❌ Connection failed, max retries reached ({})",
|
||||
max_retries
|
||||
);
|
||||
eprintln!(" Error type: {}", error_type);
|
||||
eprintln!(" Error detail: {}", e);
|
||||
}
|
||||
// 生成最终诊断报告
|
||||
print_diagnostic_report(
|
||||
"SSE",
|
||||
&config.url,
|
||||
0,
|
||||
"Connection failed: max retries reached",
|
||||
Some(&error_type),
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
if max_retries == 0 {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection failed [{}]: {}; retrying in {}s (attempt #{})...",
|
||||
EVENT_RETRY_BACKOFF,
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
backoff_secs,
|
||||
attempt
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection failed [{}]: {}; retrying in {}s ({}/{})...",
|
||||
EVENT_RETRY_BACKOFF,
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
backoff_secs,
|
||||
attempt,
|
||||
max_retries
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if verbose && !quiet {
|
||||
eprintln!(" Full error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("Waiting {}s before next reconnect attempt", backoff_secs);
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS);
|
||||
}
|
||||
|
||||
tracing::info!("SSE watchdog exited");
|
||||
}
|
||||
|
||||
/// 监控 SSE 连接健康状态
|
||||
///
|
||||
/// 委托给 common::monitor_connection_health 公共函数
|
||||
async fn monitor_sse_connection(
|
||||
handler: &ProxyHandler,
|
||||
ping_interval: u64,
|
||||
ping_timeout: u64,
|
||||
quiet: bool,
|
||||
) -> String {
|
||||
super::common::monitor_connection_health(handler, ping_interval, ping_timeout, quiet, "SSE")
|
||||
.await
|
||||
}
|
||||
405
qiming-mcp-proxy/mcp-proxy/src/client/core/stream.rs
Normal file
405
qiming-mcp-proxy/mcp-proxy/src/client/core/stream.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
//! Stream 模式处理
|
||||
//!
|
||||
//! Streamable HTTP 协议的实现和连接管理
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::HealthChecker;
|
||||
use crate::client::support::{
|
||||
ConvertArgs, classify_error, print_diagnostic_report, summarize_error, truncate_str,
|
||||
};
|
||||
use crate::proxy::{McpClientConfig, StreamClientConnection, StreamProxyHandler, ToolFilter};
|
||||
|
||||
use mcp_streamable_proxy::{ServiceExt, stdio as stream_stdio};
|
||||
|
||||
/// 为 StreamProxyHandler 实现 HealthChecker trait
|
||||
impl HealthChecker for StreamProxyHandler {
|
||||
fn is_backend_available(&self) -> bool {
|
||||
self.is_backend_available()
|
||||
}
|
||||
|
||||
async fn is_terminated_async(&self) -> bool {
|
||||
self.is_terminated_async().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream 模式处理(使用 mcp-streamable-proxy,rmcp 0.12)
|
||||
pub async fn run_stream_mode(
|
||||
config: McpClientConfig,
|
||||
args: ConvertArgs,
|
||||
tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting Stream mode");
|
||||
tracing::info!("Target URL: {}", config.url);
|
||||
tracing::info!(
|
||||
"Ping config: interval={}s, timeout={}s",
|
||||
args.ping_interval,
|
||||
args.ping_timeout
|
||||
);
|
||||
tracing::info!("========================================");
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Connecting to backend service (Stream)...");
|
||||
}
|
||||
|
||||
// 1. 使用高层 API 连接(带重试,防止初始连接因时序问题失败)
|
||||
//
|
||||
// 重试策略:最多 3 次,每次给 15s 连接超时,间隔 2s/4s
|
||||
// 第1次(最多 15s)→ 失败 → 等 2s → 第2次(最多 15s)→ 失败 → 等 4s → 第3次(最多 15s)→ 退出
|
||||
// 最坏总耗时:15 + 2 + 15 + 4 + 15 = 51s(但大多数失败会比 15s 更快返回)
|
||||
// 通常总耗时:连接快速失败(~1s) + 2 + 快速失败(~1s) + 4 + 快速失败(~1s) = ~9s
|
||||
let connect_timeout = Duration::from_secs(15);
|
||||
const MAX_INITIAL_RETRIES: u32 = 3;
|
||||
const INITIAL_BACKOFF_SECS: u64 = 2;
|
||||
const MAX_BACKOFF_SECS: u64 = 4;
|
||||
|
||||
tracing::info!(
|
||||
"Connecting to backend (per-attempt timeout: {}s, max retries: {})",
|
||||
connect_timeout.as_secs(),
|
||||
MAX_INITIAL_RETRIES
|
||||
);
|
||||
let connect_start = std::time::Instant::now();
|
||||
|
||||
let mut last_error = None;
|
||||
let mut conn = None;
|
||||
let mut backoff_secs = INITIAL_BACKOFF_SECS;
|
||||
for attempt in 1..=MAX_INITIAL_RETRIES {
|
||||
match tokio::time::timeout(
|
||||
connect_timeout,
|
||||
StreamClientConnection::connect(config.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(c)) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(
|
||||
"Backend connection succeeded on attempt {}/{}",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES
|
||||
);
|
||||
}
|
||||
conn = Some(c);
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
"Backend connection attempt {}/{} failed: {}",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES,
|
||||
e
|
||||
);
|
||||
last_error = Some(format!("Backend connection failed: {}", e));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"Backend connection attempt {}/{} timed out ({}s)",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES,
|
||||
connect_timeout.as_secs()
|
||||
);
|
||||
last_error = Some(format!(
|
||||
"Backend connection timeout ({}s)",
|
||||
connect_timeout.as_secs()
|
||||
));
|
||||
}
|
||||
}
|
||||
if attempt < MAX_INITIAL_RETRIES {
|
||||
tracing::info!("Retrying in {}s... (elapsed: {:?})", backoff_secs, connect_start.elapsed());
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ Connection attempt {}/{} failed, retrying in {}s...",
|
||||
attempt, MAX_INITIAL_RETRIES, backoff_secs
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
let conn = conn.ok_or_else(|| {
|
||||
let elapsed = connect_start.elapsed();
|
||||
let msg = last_error.unwrap_or_else(|| "Unknown connection error".to_string());
|
||||
tracing::error!(
|
||||
"All {} connection attempts failed after {:?}: {}",
|
||||
MAX_INITIAL_RETRIES, elapsed, msg
|
||||
);
|
||||
eprintln!(
|
||||
"❌ All {} connection attempts failed after {:.1}s: {}",
|
||||
MAX_INITIAL_RETRIES, elapsed.as_secs_f64(), msg
|
||||
);
|
||||
anyhow::anyhow!(msg)
|
||||
})?;
|
||||
|
||||
let connect_duration = connect_start.elapsed();
|
||||
tracing::info!(
|
||||
"Backend connected successfully (duration: {:?})",
|
||||
connect_duration
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("✅ Backend connected successfully");
|
||||
// 打印工具列表
|
||||
print_stream_tools(&conn, quiet).await;
|
||||
if args.ping_interval > 0 {
|
||||
eprintln!(
|
||||
"💓 Health ping: every {}s (timeout {}s)",
|
||||
args.ping_interval, args.ping_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建 handler(消耗 conn)
|
||||
tracing::debug!("Creating ProxyHandler...");
|
||||
let handler = Arc::new(conn.into_handler("cli".to_string(), tool_filter.clone()));
|
||||
tracing::debug!("ProxyHandler created");
|
||||
|
||||
// 3. 启动 stdio server(使用 stream_stdio,即 rmcp 0.12 的 stdio)
|
||||
tracing::info!("Starting stdio server...");
|
||||
let server = (*handler).clone().serve(stream_stdio()).await.map_err(|e| {
|
||||
tracing::error!("Failed to start stdio server: {:?}", e);
|
||||
eprintln!("❌ Failed to start stdio server: {}", e);
|
||||
e
|
||||
})?;
|
||||
tracing::info!("Stdio server started");
|
||||
|
||||
if !quiet {
|
||||
eprintln!("💡 Stdio server started, proxying traffic...");
|
||||
}
|
||||
|
||||
// 4. 启动 watchdog 任务
|
||||
let handler_for_watchdog = handler.clone();
|
||||
let mut watchdog_handle = tokio::spawn(run_stream_watchdog(
|
||||
handler_for_watchdog,
|
||||
args,
|
||||
config,
|
||||
tool_filter,
|
||||
verbose,
|
||||
quiet,
|
||||
));
|
||||
tracing::debug!("Watchdog task started");
|
||||
|
||||
// 5. 等待 stdio server 退出
|
||||
tracing::info!("Waiting for stdio/watchdog events...");
|
||||
tokio::select! {
|
||||
result = server.waiting() => {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Stdio server exited (EOF)");
|
||||
tracing::info!("========================================");
|
||||
watchdog_handle.abort();
|
||||
result?;
|
||||
}
|
||||
watchdog_result = &mut watchdog_handle => {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Watchdog task exited");
|
||||
tracing::info!("========================================");
|
||||
if let Err(e) = watchdog_result
|
||||
&& !e.is_cancelled()
|
||||
{
|
||||
tracing::error!("Stream Watchdog task failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Stream mode exited normally");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打印 Stream 连接的工具列表
|
||||
async fn print_stream_tools(conn: &StreamClientConnection, quiet: bool) {
|
||||
if quiet {
|
||||
return;
|
||||
}
|
||||
match conn.list_tools().await {
|
||||
Ok(tools) => {
|
||||
if tools.is_empty() {
|
||||
eprintln!("⚠️ Tool list is empty (tools/list returned 0 tools)");
|
||||
} else {
|
||||
eprintln!("🔧 Available tools ({}):", tools.len());
|
||||
for tool in &tools {
|
||||
let desc = tool.description.as_deref().unwrap_or("no description");
|
||||
let desc_short = truncate_str(desc, 50);
|
||||
eprintln!(" - {} : {}", tool.name, desc_short);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to list tools: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream 模式的 watchdog:负责监控连接健康、断开时重连
|
||||
async fn run_stream_watchdog(
|
||||
handler: Arc<StreamProxyHandler>,
|
||||
args: ConvertArgs,
|
||||
config: McpClientConfig,
|
||||
_tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) {
|
||||
let max_retries = args.retries;
|
||||
let mut attempt = 0u32;
|
||||
let mut backoff_secs = 1u64;
|
||||
const MAX_BACKOFF_SECS: u64 = 30;
|
||||
const EVENT_DISCONNECTED: &str = "EVENT_DISCONNECTED";
|
||||
const EVENT_RECONNECTED: &str = "EVENT_RECONNECTED";
|
||||
const EVENT_RETRY_BACKOFF: &str = "EVENT_RETRY_BACKOFF";
|
||||
let initial_connection_start = std::time::Instant::now();
|
||||
|
||||
// 首先监控现有连接的健康状态
|
||||
let disconnect_reason =
|
||||
monitor_stream_connection(&handler, args.ping_interval, args.ping_timeout, quiet).await;
|
||||
|
||||
// 连接断开,标记后端不可用
|
||||
handler.swap_backend(None);
|
||||
|
||||
let alive_duration = initial_connection_start.elapsed();
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection disconnected: {}",
|
||||
EVENT_DISCONNECTED, disconnect_reason
|
||||
);
|
||||
}
|
||||
|
||||
// 生成诊断报告(首次断开)
|
||||
print_diagnostic_report(
|
||||
"Streamable HTTP",
|
||||
&config.url,
|
||||
alive_duration.as_secs(),
|
||||
&disconnect_reason,
|
||||
None,
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
|
||||
// 进入重连循环
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Reconnecting (attempt #{})...", attempt);
|
||||
}
|
||||
|
||||
// 尝试建立连接
|
||||
let connect_result = StreamClientConnection::connect(config.clone()).await;
|
||||
|
||||
match connect_result {
|
||||
Ok(conn) => {
|
||||
// 连接成功,获取 RunningService 并热替换后端
|
||||
let running = conn.into_running_service();
|
||||
handler.swap_backend(Some(running));
|
||||
backoff_secs = 1;
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"✅ [{}] Reconnected, proxy service resumed",
|
||||
EVENT_RECONNECTED
|
||||
);
|
||||
}
|
||||
|
||||
// 监控连接健康
|
||||
let reconnect_start = std::time::Instant::now();
|
||||
let disconnect_reason = monitor_stream_connection(
|
||||
&handler,
|
||||
args.ping_interval,
|
||||
args.ping_timeout,
|
||||
quiet,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 连接断开,标记后端不可用
|
||||
handler.swap_backend(None);
|
||||
let reconnect_alive_duration = reconnect_start.elapsed();
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection disconnected: {}",
|
||||
EVENT_DISCONNECTED, disconnect_reason
|
||||
);
|
||||
}
|
||||
|
||||
// 生成诊断报告(重连后断开)
|
||||
print_diagnostic_report(
|
||||
"Streamable HTTP",
|
||||
&config.url,
|
||||
reconnect_alive_duration.as_secs(),
|
||||
&disconnect_reason,
|
||||
None,
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let error_type = classify_error(&e);
|
||||
|
||||
if max_retries > 0 && attempt >= max_retries {
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"❌ Connection failed, max retries reached ({})",
|
||||
max_retries
|
||||
);
|
||||
eprintln!(" Error type: {}", error_type);
|
||||
eprintln!(" Error detail: {}", e);
|
||||
}
|
||||
// 生成最终诊断报告
|
||||
print_diagnostic_report(
|
||||
"Streamable HTTP",
|
||||
&config.url,
|
||||
0,
|
||||
"Connection failed: max retries reached",
|
||||
Some(&error_type),
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
if max_retries == 0 {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection failed [{}]: {}; retrying in {}s (attempt #{})...",
|
||||
EVENT_RETRY_BACKOFF,
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
backoff_secs,
|
||||
attempt
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection failed [{}]: {}; retrying in {}s ({}/{})...",
|
||||
EVENT_RETRY_BACKOFF,
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
backoff_secs,
|
||||
attempt,
|
||||
max_retries
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if verbose && !quiet {
|
||||
eprintln!(" Full error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
/// 监控 Stream 连接健康状态
|
||||
///
|
||||
/// 委托给 common::monitor_connection_health 公共函数
|
||||
async fn monitor_stream_connection(
|
||||
handler: &StreamProxyHandler,
|
||||
ping_interval: u64,
|
||||
ping_timeout: u64,
|
||||
quiet: bool,
|
||||
) -> String {
|
||||
super::common::monitor_connection_health(handler, ping_interval, ping_timeout, quiet, "Stream")
|
||||
.await
|
||||
}
|
||||
20
qiming-mcp-proxy/mcp-proxy/src/client/mod.rs
Normal file
20
qiming-mcp-proxy/mcp-proxy/src/client/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// MCP 客户端模块
|
||||
// 提供各种 MCP 协议的客户端实现和 CLI 工具
|
||||
|
||||
mod cli;
|
||||
mod protocol;
|
||||
pub(crate) mod proxy_server;
|
||||
|
||||
// 新的模块化架构 (按功能层次分组)
|
||||
pub mod cli_impl; // CLI 命令实现
|
||||
pub mod core; // 核心业务逻辑
|
||||
pub mod support; // 支持功能
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// 导出 CLI 功能(公共 API)
|
||||
pub use cli::{Cli, Commands, run_cli};
|
||||
|
||||
// 注意:ConvertArgs, CheckArgs, DetectArgs 等类型只在内部使用,
|
||||
// 不需要在这里重新导出。如需使用,请通过 support 模块导入。
|
||||
15
qiming-mcp-proxy/mcp-proxy/src/client/protocol.rs
Normal file
15
qiming-mcp-proxy/mcp-proxy/src/client/protocol.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// MCP 协议检测模块
|
||||
// 用于自动检测远程 MCP 服务的协议类型
|
||||
// 复用 server 模块的协议检测逻辑
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
// 复用 model 中的协议类型定义
|
||||
pub use crate::model::McpProtocol;
|
||||
|
||||
/// 自动检测 MCP 协议类型
|
||||
///
|
||||
/// 直接复用 server 模块的协议检测逻辑
|
||||
pub async fn detect_mcp_protocol(url: &str) -> Result<McpProtocol> {
|
||||
crate::server::detect_mcp_protocol(url).await
|
||||
}
|
||||
378
qiming-mcp-proxy/mcp-proxy/src/client/proxy_server.rs
Normal file
378
qiming-mcp-proxy/mcp-proxy/src/client/proxy_server.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
//! MCP Proxy Server - 将 stdio MCP 服务代理为 HTTP/SSE 或 Streamable HTTP 服务
|
||||
//!
|
||||
//! 支持多个 agent 复用同一个 MCP 服务
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Once;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::Parser;
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::client::support::{LoggingArgs, init_logging_with_config};
|
||||
use crate::proxy::ToolFilter;
|
||||
|
||||
/// Panic hook 初始化标志(确保只设置一次)
|
||||
static INIT_PANIC_HOOK: Once = Once::new();
|
||||
|
||||
/// 最大重试次数 (0 = 无限重试)
|
||||
const MAX_RETRIES: u32 = 0;
|
||||
/// 初始重试间隔(秒)
|
||||
const INITIAL_RETRY_DELAY_SECS: u64 = 3;
|
||||
/// 最大重试间隔(秒)
|
||||
const MAX_RETRY_DELAY_SECS: u64 = 30;
|
||||
|
||||
/// 输出协议类型
|
||||
#[derive(clap::ValueEnum, Clone, Debug, Default)]
|
||||
pub enum ProxyProtocol {
|
||||
/// SSE 协议
|
||||
Sse,
|
||||
/// Streamable HTTP 协议
|
||||
#[default]
|
||||
Stream,
|
||||
}
|
||||
|
||||
/// 代理模式参数 - 将 stdio MCP 服务代理为 HTTP 服务
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ProxyArgs {
|
||||
/// 监听端口
|
||||
#[arg(short, long, default_value = "8080", help = "监听端口")]
|
||||
pub port: u16,
|
||||
|
||||
/// 监听地址
|
||||
#[arg(long, default_value = "127.0.0.1", help = "监听地址")]
|
||||
pub host: String,
|
||||
|
||||
/// MCP 服务名称(当配置包含多个服务时必需)
|
||||
#[arg(short, long, help = "MCP 服务名称(多服务配置时必需)")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// MCP 服务配置 JSON
|
||||
#[arg(long, conflicts_with = "config_file", help = "MCP 服务配置 JSON")]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// MCP 服务配置文件路径
|
||||
#[arg(long, conflicts_with = "config", help = "MCP 服务配置文件路径")]
|
||||
pub config_file: Option<PathBuf>,
|
||||
|
||||
/// 输出协议类型
|
||||
#[arg(long, value_enum, default_value = "stream", help = "输出协议类型")]
|
||||
pub protocol: ProxyProtocol,
|
||||
|
||||
/// SSE 端点路径(仅 SSE 协议)
|
||||
#[arg(long, default_value = "/sse", help = "SSE 端点路径")]
|
||||
pub sse_path: String,
|
||||
|
||||
/// 消息端点路径(仅 SSE 协议)
|
||||
#[arg(long, default_value = "/message", help = "消息端点路径")]
|
||||
pub message_path: String,
|
||||
|
||||
/// 工具白名单(逗号分隔),只允许指定的工具
|
||||
#[arg(long, value_delimiter = ',', help = "工具白名单(逗号分隔)")]
|
||||
pub allow_tools: Option<Vec<String>>,
|
||||
|
||||
/// 工具黑名单(逗号分隔),排除指定的工具
|
||||
#[arg(long, value_delimiter = ',', help = "工具黑名单(逗号分隔)")]
|
||||
pub deny_tools: Option<Vec<String>>,
|
||||
|
||||
/// 日志配置(使用通用结构)
|
||||
#[command(flatten)]
|
||||
pub logging: LoggingArgs,
|
||||
}
|
||||
|
||||
/// MCP 配置格式
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct McpConfig {
|
||||
#[serde(rename = "mcpServers")]
|
||||
mcp_servers: HashMap<String, StdioConfig>,
|
||||
}
|
||||
|
||||
/// stdio 配置
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct StdioConfig {
|
||||
command: String,
|
||||
args: Option<Vec<String>>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 解析后的服务配置(包含服务名)
|
||||
struct ParsedConfig {
|
||||
name: String,
|
||||
config: StdioConfig,
|
||||
}
|
||||
|
||||
/// 运行代理命令
|
||||
pub async fn run_proxy_command(args: ProxyArgs, verbose: bool, quiet: bool) -> Result<()> {
|
||||
// 设置 panic hook 以记录详细的 panic 信息(只设置一次)
|
||||
INIT_PANIC_HOOK.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|panic_info| {
|
||||
let backtrace = std::backtrace::Backtrace::capture();
|
||||
error!(
|
||||
"[PANIC] Program panic - Location: {}:{}, Message: {:?}",
|
||||
panic_info.location().map(|l| l.file()).unwrap_or("unknown"),
|
||||
panic_info.location().map(|l| l.line()).unwrap_or(0),
|
||||
panic_info.payload().downcast_ref::<String>()
|
||||
);
|
||||
error!("[PANIC] Stack trace:\\n{:?}", backtrace);
|
||||
}));
|
||||
});
|
||||
|
||||
// 1. 验证互斥参数
|
||||
if args.allow_tools.is_some() && args.deny_tools.is_some() {
|
||||
bail!("--allow-tools 和 --deny-tools 不能同时使用,请只选择其中一个");
|
||||
}
|
||||
|
||||
// 2. 解析配置
|
||||
let parsed = parse_config(&args)?;
|
||||
|
||||
// 2.5 初始化镜像源环境变量(MCP_PROXY_NPM_REGISTRY → npm_config_registry 等)
|
||||
// 必须在日志初始化之前、单线程阶段调用
|
||||
{
|
||||
let mirror = mcp_common::mirror::MirrorConfig::from_env();
|
||||
if !mirror.is_empty() {
|
||||
mirror.apply_to_process_env();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 初始化日志系统(在启动服务之前)
|
||||
init_logging_with_config(&args.logging, Some(&parsed.name), quiet, verbose)?;
|
||||
|
||||
// 4. 创建工具过滤器
|
||||
let tool_filter = if let Some(allow_tools) = args.allow_tools.clone() {
|
||||
ToolFilter::allow(allow_tools)
|
||||
} else if let Some(deny_tools) = args.deny_tools.clone() {
|
||||
ToolFilter::deny(deny_tools)
|
||||
} else {
|
||||
ToolFilter::default()
|
||||
};
|
||||
|
||||
let protocol_name = match args.protocol {
|
||||
ProxyProtocol::Sse => "SSE",
|
||||
ProxyProtocol::Stream => "Streamable HTTP",
|
||||
};
|
||||
|
||||
// 记录服务启动信息到日志文件
|
||||
info!(
|
||||
"[Service startup] MCP Proxy service startup - protocol: {}, service name: {}, command: {} {:?}",
|
||||
protocol_name,
|
||||
parsed.name,
|
||||
parsed.config.command,
|
||||
parsed.config.args.as_ref().unwrap_or(&vec![])
|
||||
);
|
||||
|
||||
if let Some(ref allow_tools) = args.allow_tools {
|
||||
info!("[Service startup] Tool whitelist: {:?}", allow_tools);
|
||||
}
|
||||
if let Some(ref deny_tools) = args.deny_tools {
|
||||
info!("[Service startup] Tool blacklist: {:?}", deny_tools);
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🚀 MCP Proxy Service");
|
||||
eprintln!("Protocol type: {}", protocol_name);
|
||||
eprintln!("Service name: {}", parsed.name);
|
||||
eprintln!(
|
||||
"Command: {} {:?}",
|
||||
parsed.config.command,
|
||||
parsed.config.args.as_ref().unwrap_or(&vec![])
|
||||
);
|
||||
if verbose && let Some(ref env) = parsed.config.env {
|
||||
eprintln!("Environment variable: {:?}", env);
|
||||
}
|
||||
// 显示过滤器配置
|
||||
if let Some(ref allow_tools) = args.allow_tools {
|
||||
eprintln!("Tool whitelist: {:?}", allow_tools);
|
||||
}
|
||||
if let Some(ref deny_tools) = args.deny_tools {
|
||||
eprintln!("Tool blacklist: {:?}", deny_tools);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 端口提前绑定(在重试循环之前),确保 ServiceManager 的 TCP 健康检查能检测到进程存活
|
||||
let bind_addr = format!("{}:{}", args.host, args.port);
|
||||
let std_listener = std::net::TcpListener::bind(&bind_addr)
|
||||
.map_err(|e| anyhow::anyhow!("端口绑定失败 {}: {}", bind_addr, e))?;
|
||||
std_listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| anyhow::anyhow!("设置非阻塞失败: {}", e))?;
|
||||
info!("[Port Binding] Binded {}", bind_addr);
|
||||
|
||||
// 6. 主循环 - 支持子进程崩溃后自动重启(指数退避,有上限)
|
||||
let mut retry_count: u32 = 0;
|
||||
let mut retry_delay = Duration::from_secs(INITIAL_RETRY_DELAY_SECS);
|
||||
|
||||
loop {
|
||||
let result = run_proxy_server(
|
||||
&args,
|
||||
&parsed,
|
||||
&std_listener,
|
||||
tool_filter.clone(),
|
||||
verbose,
|
||||
quiet,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// 正常退出(如 Ctrl+C)
|
||||
info!(
|
||||
"[Service stopped] MCP Proxy service stopped normally - service name: {}",
|
||||
parsed.name
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("🛑 Service has been stopped");
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
retry_count += 1;
|
||||
// MAX_RETRIES = 0 表示无限重试,非零值表示最大重试次数
|
||||
#[allow(clippy::absurd_extreme_comparisons)]
|
||||
if MAX_RETRIES > 0 && retry_count >= MAX_RETRIES {
|
||||
error!(
|
||||
"[Service Termination] Maximum number of retries reached {}, service name: {}, last error: {}",
|
||||
MAX_RETRIES, parsed.name, e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
let retry_info = if MAX_RETRIES == 0 {
|
||||
format!("第{}次", retry_count)
|
||||
} else {
|
||||
format!("第{}/{}次", retry_count, MAX_RETRIES)
|
||||
};
|
||||
error!(
|
||||
"[Service exception] MCP Proxy service exited abnormally - service name: {}, error: {}, {} and restarts after seconds ({})",
|
||||
parsed.name,
|
||||
e,
|
||||
retry_delay.as_secs(),
|
||||
retry_info
|
||||
);
|
||||
eprintln!(
|
||||
"⚠️ Service exception: {}, {}, restart after seconds ({})...",
|
||||
e,
|
||||
retry_delay.as_secs(),
|
||||
retry_info
|
||||
);
|
||||
tokio::time::sleep(retry_delay).await;
|
||||
retry_delay =
|
||||
std::cmp::min(retry_delay * 2, Duration::from_secs(MAX_RETRY_DELAY_SECS));
|
||||
warn!(
|
||||
"[Service Restart] Restarting MCP Proxy service - Service name: {}",
|
||||
parsed.name
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("🔄 Restarting service...");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 运行代理服务器(单次运行)
|
||||
async fn run_proxy_server(
|
||||
args: &ProxyArgs,
|
||||
parsed: &ParsedConfig,
|
||||
std_listener: &std::net::TcpListener,
|
||||
tool_filter: ToolFilter,
|
||||
_verbose: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
// Windows 上解析命令扩展名(如 npx -> npx.cmd)
|
||||
let resolved_command = mcp_common::resolve_windows_command(&parsed.config.command);
|
||||
if resolved_command != parsed.config.command {
|
||||
info!(
|
||||
"[Command parsing] Windows command parsed: {} -> {}",
|
||||
parsed.config.command, resolved_command
|
||||
);
|
||||
}
|
||||
|
||||
// 根据协议类型选择对应的库并启动服务器
|
||||
// 每个库使用自己的 rmcp 版本创建完整的生命周期
|
||||
match args.protocol {
|
||||
ProxyProtocol::Sse => {
|
||||
// 使用 mcp-sse-proxy 库(rmcp 0.10)
|
||||
let config = mcp_sse_proxy::McpServiceConfig {
|
||||
name: parsed.name.clone(),
|
||||
command: resolved_command,
|
||||
args: parsed.config.args.clone(),
|
||||
env: parsed.config.env.clone(),
|
||||
tool_filter: Some(tool_filter),
|
||||
};
|
||||
mcp_sse_proxy::run_sse_server_from_config(config, std_listener, quiet).await
|
||||
}
|
||||
ProxyProtocol::Stream => {
|
||||
// 使用 mcp-streamable-proxy 库(rmcp 0.12)
|
||||
let config = mcp_streamable_proxy::McpServiceConfig {
|
||||
name: parsed.name.clone(),
|
||||
command: resolved_command,
|
||||
args: parsed.config.args.clone(),
|
||||
env: parsed.config.env.clone(),
|
||||
tool_filter: Some(tool_filter),
|
||||
};
|
||||
mcp_streamable_proxy::run_stream_server_from_config(config, std_listener, quiet).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: 两个库现在都使用 mcp-common::ToolFilter,所以直接传递即可
|
||||
|
||||
/// 解析配置
|
||||
fn parse_config(args: &ProxyArgs) -> Result<ParsedConfig> {
|
||||
// 1. 读取配置内容
|
||||
let json_str = if let Some(ref config) = args.config {
|
||||
config.clone()
|
||||
} else if let Some(ref path) = args.config_file {
|
||||
std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("读取配置文件失败: {}", e))?
|
||||
} else {
|
||||
bail!("必须提供 --config 或 --config-file 参数");
|
||||
};
|
||||
|
||||
// 2. 解析配置
|
||||
let mcp_config: McpConfig = serde_json::from_str(&json_str).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"配置解析失败: {}。配置必须是标准 MCP 格式,包含 mcpServers 字段",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let servers = mcp_config.mcp_servers;
|
||||
|
||||
if servers.is_empty() {
|
||||
bail!("配置中没有找到任何 MCP 服务");
|
||||
}
|
||||
|
||||
// 3. 根据服务数量和 --name 参数选择服务
|
||||
if servers.len() == 1 {
|
||||
// 单服务:自动使用,无需 --name
|
||||
let (name, config) = servers.into_iter().next().unwrap();
|
||||
Ok(ParsedConfig { name, config })
|
||||
} else if let Some(ref name) = args.name {
|
||||
// 多服务:根据 --name 选择
|
||||
servers
|
||||
.get(name)
|
||||
.cloned()
|
||||
.map(|config| ParsedConfig {
|
||||
name: name.clone(),
|
||||
config,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"服务 '{}' 不存在。可用服务: {:?}",
|
||||
name,
|
||||
servers.keys().collect::<Vec<_>>()
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// 多服务但未指定 --name
|
||||
bail!(
|
||||
"配置包含多个服务 {:?},请使用 --name 指定要启动的服务",
|
||||
servers.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
244
qiming-mcp-proxy/mcp-proxy/src/client/support/args.rs
Normal file
244
qiming-mcp-proxy/mcp-proxy/src/client/support/args.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
//! CLI 参数定义
|
||||
//!
|
||||
//! 定义所有命令行参数结构体
|
||||
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 通用日志配置参数
|
||||
///
|
||||
/// 用于多个命令之间共享日志配置
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct LoggingArgs {
|
||||
/// 启用详细诊断模式,输出连接和工具调用的详细时间信息(默认关闭
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "false",
|
||||
help = "启用详细诊断模式,追踪连接生命周期和超时问题(默认关闭)"
|
||||
)]
|
||||
pub diagnostic: bool,
|
||||
|
||||
/// 日志输出目录(自动生成文件名)
|
||||
#[arg(long, help = "日志输出目录,将自动生成日志文件名")]
|
||||
pub log_dir: Option<PathBuf>,
|
||||
|
||||
/// 日志文件完整路径(手动指定)
|
||||
#[arg(long, conflicts_with = "log_dir", help = "日志文件完整路径")]
|
||||
pub log_file: Option<PathBuf>,
|
||||
|
||||
/// OTLP 追踪端点(如 http://localhost:4317)
|
||||
///
|
||||
/// 启用 diagnostic 模式时,可配置此参数将追踪数据发送到 Jaeger 等 OTLP 兼容的后端。
|
||||
/// 支持 gRPC (端口 4317) 和 HTTP (端口 4318) 协议。
|
||||
#[arg(
|
||||
long,
|
||||
env = "OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
help = "OTLP 追踪端点 (如 http://localhost:4317)"
|
||||
)]
|
||||
pub otlp_endpoint: Option<String>,
|
||||
|
||||
/// 追踪服务名称(用于 Jaeger 等追踪后端标识)
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "mcp-proxy",
|
||||
help = "追踪服务名称(用于 Jaeger 等追踪后端标识)"
|
||||
)]
|
||||
pub service_name: String,
|
||||
}
|
||||
|
||||
/// MCP-Proxy CLI 主命令结构
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "mcp-proxy")]
|
||||
#[command(version = env!("CARGO_PKG_VERSION"))]
|
||||
#[command(about = "MCP 协议转换代理工具", long_about = None)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
|
||||
/// 直接URL模式(向后兼容)
|
||||
#[arg(value_name = "URL", help = "MCP 服务的 URL 地址(直接模式)")]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// 全局详细输出
|
||||
#[arg(short, long, global = true)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// 全局静默模式
|
||||
#[arg(short, long, global = true)]
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// 协议转换模式 - 将 URL 转换为 stdio
|
||||
Convert(ConvertArgs),
|
||||
|
||||
/// 检查服务状态
|
||||
Check(CheckArgs),
|
||||
|
||||
/// 协议检测
|
||||
Detect(DetectArgs),
|
||||
|
||||
/// 代理模式 - 将 stdio MCP 服务代理为 HTTP/SSE 服务
|
||||
Proxy(crate::client::proxy_server::ProxyArgs),
|
||||
|
||||
/// 健康检查 - 验证 MCP 服务是否可用
|
||||
Health(HealthArgs),
|
||||
}
|
||||
|
||||
/// 协议转换参数
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ConvertArgs {
|
||||
/// MCP 服务的 URL 地址(可选,与 --config/--config-file 二选一)
|
||||
#[arg(value_name = "URL", help = "MCP 服务的 URL 地址")]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// MCP 服务配置 JSON
|
||||
#[arg(long, conflicts_with = "config_file", help = "MCP 服务配置 JSON")]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// MCP 服务配置文件路径
|
||||
#[arg(long, conflicts_with = "config", help = "MCP 服务配置文件路径")]
|
||||
pub config_file: Option<PathBuf>,
|
||||
|
||||
/// MCP 服务名称(多服务配置时必需)
|
||||
#[arg(short, long, help = "MCP 服务名称(多服务配置时必需)")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// 指定远程服务协议类型(不指定则自动检测)
|
||||
#[arg(long, value_enum, help = "指定远程服务协议类型(不指定则自动检测)")]
|
||||
pub protocol: Option<crate::client::proxy_server::ProxyProtocol>,
|
||||
|
||||
/// 认证 header (如: "Bearer token")
|
||||
#[arg(short, long, help = "认证 header")]
|
||||
pub auth: Option<String>,
|
||||
|
||||
/// 自定义 HTTP headers
|
||||
#[arg(short = 'H', long, value_parser = parse_key_val, help = "自定义 HTTP headers (KEY=VALUE 格式)")]
|
||||
pub header: Vec<(String, String)>,
|
||||
|
||||
/// 重试次数
|
||||
#[arg(long, default_value = "0", help = "重试次数,0 表示无限重试")]
|
||||
pub retries: u32,
|
||||
|
||||
/// 工具白名单(逗号分隔),只允许指定的工具
|
||||
#[arg(
|
||||
long,
|
||||
value_delimiter = ',',
|
||||
help = "工具白名单(逗号分隔),只允许指定的工具"
|
||||
)]
|
||||
pub allow_tools: Option<Vec<String>>,
|
||||
|
||||
/// 工具黑名单(逗号分隔),排除指定的工具
|
||||
#[arg(
|
||||
long,
|
||||
value_delimiter = ',',
|
||||
help = "工具黑名单(逗号分隔),排除指定的工具"
|
||||
)]
|
||||
pub deny_tools: Option<Vec<String>>,
|
||||
|
||||
/// 客户端 ping 间隔(秒),0 表示禁用
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "30",
|
||||
help = "客户端 ping 间隔(秒),0 表示禁用"
|
||||
)]
|
||||
pub ping_interval: u64,
|
||||
|
||||
/// 客户端 ping 超时(秒)
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "10",
|
||||
help = "客户端 ping 超时(秒),超时则认为连接断开"
|
||||
)]
|
||||
pub ping_timeout: u64,
|
||||
|
||||
/// 日志配置(使用通用结构)
|
||||
#[command(flatten)]
|
||||
pub logging: LoggingArgs,
|
||||
}
|
||||
|
||||
/// 检查参数
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct CheckArgs {
|
||||
/// 要检查的 MCP 服务 URL
|
||||
#[arg(value_name = "URL")]
|
||||
pub url: String,
|
||||
|
||||
/// 认证 header
|
||||
#[arg(short, long)]
|
||||
pub auth: Option<String>,
|
||||
|
||||
/// 超时时间
|
||||
#[arg(long, default_value = "10")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
/// 协议检测参数
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct DetectArgs {
|
||||
/// 要检测的 MCP 服务 URL
|
||||
#[arg(value_name = "URL")]
|
||||
pub url: String,
|
||||
|
||||
/// 认证 header
|
||||
#[arg(short, long)]
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
/// 健康检查参数
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(after_help = "\
|
||||
退出码:
|
||||
0 服务健康 - MCP 连接握手成功
|
||||
1 服务不健康 - 连接失败、超时或握手失败
|
||||
|
||||
示例:
|
||||
# 基本用法
|
||||
mcp-proxy health http://localhost:8080/mcp
|
||||
|
||||
# 带认证
|
||||
mcp-proxy health http://localhost:8080/mcp -a \"Bearer token123\"
|
||||
|
||||
# 指定协议和超时
|
||||
mcp-proxy health http://localhost:8080/mcp --protocol sse --timeout 5
|
||||
|
||||
# 静默模式(仅返回退出码,适合脚本使用)
|
||||
mcp-proxy health http://localhost:8080/mcp -q
|
||||
|
||||
# 在 shell 脚本中使用
|
||||
if mcp-proxy health http://localhost:8080/mcp -q; then
|
||||
echo \"MCP 服务正常\"
|
||||
else
|
||||
echo \"MCP 服务不可用\"
|
||||
fi
|
||||
")]
|
||||
pub struct HealthArgs {
|
||||
/// 要检查的 MCP 服务 URL
|
||||
#[arg(value_name = "URL")]
|
||||
pub url: String,
|
||||
|
||||
/// 认证 header (如: "Bearer token")
|
||||
#[arg(short, long, help = "认证 header")]
|
||||
pub auth: Option<String>,
|
||||
|
||||
/// 自定义 HTTP headers
|
||||
#[arg(short = 'H', long, value_parser = parse_key_val, help = "自定义 HTTP headers (KEY=VALUE 格式)")]
|
||||
pub header: Vec<(String, String)>,
|
||||
|
||||
/// 超时时间(秒)
|
||||
#[arg(long, default_value = "10")]
|
||||
pub timeout: u64,
|
||||
|
||||
/// 指定远程服务协议类型(不指定则自动检测)
|
||||
#[arg(long, value_enum, help = "指定远程服务协议类型(不指定则自动检测)")]
|
||||
pub protocol: Option<crate::client::proxy_server::ProxyProtocol>,
|
||||
}
|
||||
|
||||
/// 解析 KEY=VALUE 格式的辅助函数
|
||||
pub fn parse_key_val(s: &str) -> Result<(String, String), String> {
|
||||
let pos = s
|
||||
.find('=')
|
||||
.ok_or_else(|| format!("无效的 KEY=VALUE 格式: {}", s))?;
|
||||
Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
|
||||
}
|
||||
197
qiming-mcp-proxy/mcp-proxy/src/client/support/config.rs
Normal file
197
qiming-mcp-proxy/mcp-proxy/src/client/support/config.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! MCP 配置解析
|
||||
//!
|
||||
//! 解析 JSON 配置文件,支持多种服务配置格式
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::args::ConvertArgs;
|
||||
|
||||
/// 解析后的配置源
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum McpConfigSource {
|
||||
/// 直接 URL 模式(命令行参数)
|
||||
DirectUrl { url: String },
|
||||
/// 远程服务配置(JSON 配置)
|
||||
RemoteService {
|
||||
name: String,
|
||||
url: String,
|
||||
protocol: Option<crate::client::protocol::McpProtocol>,
|
||||
headers: HashMap<String, String>,
|
||||
timeout: Option<u64>,
|
||||
},
|
||||
/// 本地命令配置(JSON 配置)
|
||||
LocalCommand {
|
||||
name: String,
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
env: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// MCP 配置格式
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct McpConfig {
|
||||
#[serde(rename = "mcpServers")]
|
||||
mcp_servers: HashMap<String, McpServerInnerConfig>,
|
||||
}
|
||||
|
||||
/// MCP 服务配置(支持 Command 和 Url 两种类型)
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum McpServerInnerConfig {
|
||||
Command(StdioConfig),
|
||||
Url(UrlConfig),
|
||||
}
|
||||
|
||||
/// stdio 配置(本地命令)
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct StdioConfig {
|
||||
command: String,
|
||||
args: Option<Vec<String>>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// URL 配置(远程服务)
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct UrlConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
#[serde(
|
||||
skip_serializing_if = "Option::is_none",
|
||||
default,
|
||||
rename = "baseUrl",
|
||||
alias = "baseurl",
|
||||
alias = "base_url"
|
||||
)]
|
||||
base_url: Option<String>,
|
||||
#[serde(default, rename = "type", alias = "Type")]
|
||||
r#type: Option<String>,
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
#[serde(default, alias = "authToken", alias = "auth_token")]
|
||||
pub auth_token: Option<String>,
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
impl UrlConfig {
|
||||
fn get_url(&self) -> Option<&str> {
|
||||
self.url.as_deref().or(self.base_url.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 convert 命令的配置
|
||||
pub fn parse_convert_config(args: &ConvertArgs) -> Result<McpConfigSource> {
|
||||
// 优先级:url > config > config_file
|
||||
if let Some(ref url) = args.url {
|
||||
return Ok(McpConfigSource::DirectUrl { url: url.clone() });
|
||||
}
|
||||
|
||||
// 读取 JSON 配置
|
||||
let json_str = if let Some(ref config) = args.config {
|
||||
config.clone()
|
||||
} else if let Some(ref path) = args.config_file {
|
||||
std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("读取配置文件失败: {}", e))?
|
||||
} else {
|
||||
bail!("必须提供 URL、--config 或 --config-file 参数之一");
|
||||
};
|
||||
|
||||
// 解析 JSON 配置
|
||||
let mcp_config: McpConfig = serde_json::from_str(&json_str).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"配置解析失败: {}。配置必须是标准 MCP 格式,包含 mcpServers 字段",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let servers = mcp_config.mcp_servers;
|
||||
|
||||
if servers.is_empty() {
|
||||
bail!("配置中没有找到任何 MCP 服务");
|
||||
}
|
||||
|
||||
// 选择服务
|
||||
let (name, inner_config) = if let Some(ref name) = args.name {
|
||||
// 用户指定了服务名称,必须严格匹配
|
||||
let config = servers.get(name).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"服务 '{}' 不存在。可用服务: {:?}",
|
||||
name,
|
||||
servers.keys().collect::<Vec<_>>()
|
||||
)
|
||||
})?;
|
||||
(name.clone(), config)
|
||||
} else if servers.len() == 1 {
|
||||
// 单服务且未指定名称,自动使用
|
||||
servers.into_iter().next().unwrap()
|
||||
} else {
|
||||
// 多服务且未指定名称
|
||||
bail!(
|
||||
"配置包含多个服务 {:?},请使用 --name 指定要使用的服务",
|
||||
servers.keys().collect::<Vec<_>>()
|
||||
);
|
||||
};
|
||||
|
||||
// 根据配置类型返回
|
||||
match inner_config {
|
||||
McpServerInnerConfig::Command(stdio) => Ok(McpConfigSource::LocalCommand {
|
||||
name,
|
||||
command: stdio.command,
|
||||
args: stdio.args.unwrap_or_default(),
|
||||
env: stdio.env.unwrap_or_default(),
|
||||
}),
|
||||
McpServerInnerConfig::Url(url_config) => {
|
||||
let url = url_config
|
||||
.get_url()
|
||||
.ok_or_else(|| anyhow::anyhow!("URL 配置缺少 url 或 baseUrl 字段"))?
|
||||
.to_string();
|
||||
|
||||
// 解析协议类型
|
||||
let protocol =
|
||||
url_config
|
||||
.r#type
|
||||
.as_ref()
|
||||
.and_then(|t| match t.to_ascii_lowercase().as_str() {
|
||||
"sse" => Some(crate::client::protocol::McpProtocol::Sse),
|
||||
"http" | "stream" | "streamablehttp" | "streamable-http"
|
||||
| "streamable_http" => Some(crate::client::protocol::McpProtocol::Stream),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// 合并 headers:JSON 配置中的 auth_token -> Authorization
|
||||
let mut headers = url_config.headers.clone().unwrap_or_default();
|
||||
if let Some(auth_token) = &url_config.auth_token {
|
||||
headers.insert("Authorization".to_string(), auth_token.clone());
|
||||
}
|
||||
|
||||
Ok(McpConfigSource::RemoteService {
|
||||
name,
|
||||
url,
|
||||
protocol,
|
||||
headers,
|
||||
timeout: url_config.timeout,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并 headers:JSON 配置 + 命令行参数(命令行优先)
|
||||
pub fn merge_headers(
|
||||
config_headers: HashMap<String, String>,
|
||||
cli_headers: &[(String, String)],
|
||||
cli_auth: Option<&String>,
|
||||
) -> HashMap<String, String> {
|
||||
let mut merged = config_headers;
|
||||
|
||||
// 命令行 -H 参数覆盖配置
|
||||
for (key, value) in cli_headers {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// 命令行 --auth 参数优先级最高
|
||||
if let Some(auth_value) = cli_auth {
|
||||
merged.insert("Authorization".to_string(), auth_value.clone());
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
628
qiming-mcp-proxy/mcp-proxy/src/client/support/config_tests.rs
Normal file
628
qiming-mcp-proxy/mcp-proxy/src/client/support/config_tests.rs
Normal file
@@ -0,0 +1,628 @@
|
||||
//! 配置解析单元测试
|
||||
//!
|
||||
//! 测试各种 MCP JSON 配置格式的解析
|
||||
|
||||
use super::args::{ConvertArgs, LoggingArgs};
|
||||
use super::config::{McpConfigSource, parse_convert_config};
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_parsing_tests {
|
||||
use super::*;
|
||||
|
||||
/// 测试 1: 解析简单的本地命令配置(command 类型)
|
||||
#[test]
|
||||
fn test_parse_simple_command_config() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"test-service": {
|
||||
"command": "node",
|
||||
"args": ["server.js"]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok(), "解析应该成功");
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::LocalCommand {
|
||||
name,
|
||||
command,
|
||||
args,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(name, "test-service");
|
||||
assert_eq!(command, "node");
|
||||
assert_eq!(args, vec!["server.js"]);
|
||||
}
|
||||
_ => panic!("应该解析为 LocalCommand 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 2: 解析带环境变量的本地命令配置
|
||||
#[test]
|
||||
fn test_parse_command_config_with_env() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"my-service": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"env": {
|
||||
"API_KEY": "test-key",
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::LocalCommand {
|
||||
name,
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
assert_eq!(name, "my-service");
|
||||
assert_eq!(command, "python");
|
||||
assert_eq!(args, vec!["-m", "mcp_server"]);
|
||||
assert_eq!(env.get("API_KEY"), Some(&"test-key".to_string()));
|
||||
assert_eq!(env.get("DEBUG"), Some(&"true".to_string()));
|
||||
}
|
||||
_ => panic!("应该解析为 LocalCommand 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 3: 解析远程 URL 配置(使用 baseUrl)
|
||||
#[test]
|
||||
fn test_parse_remote_service_config_baseurl() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"remote-service": {
|
||||
"baseUrl": "https://api.example.com/mcp",
|
||||
"headers": {
|
||||
"X-Custom-Header": "custom-value"
|
||||
},
|
||||
"authToken": "Bearer token123",
|
||||
"timeout": 30
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::RemoteService {
|
||||
name,
|
||||
url,
|
||||
headers,
|
||||
timeout,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(name, "remote-service");
|
||||
assert_eq!(url, "https://api.example.com/mcp");
|
||||
assert_eq!(
|
||||
headers.get("X-Custom-Header"),
|
||||
Some(&"custom-value".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("Authorization"),
|
||||
Some(&"Bearer token123".to_string())
|
||||
);
|
||||
assert_eq!(timeout, Some(30));
|
||||
}
|
||||
_ => panic!("应该解析为 RemoteService 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 4: 解析远程 URL 配置(使用 url 字段)
|
||||
#[test]
|
||||
fn test_parse_remote_service_config_url() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"sse-service": {
|
||||
"url": "https://api.example.com/sse",
|
||||
"type": "sse"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::RemoteService {
|
||||
name,
|
||||
url,
|
||||
protocol,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(name, "sse-service");
|
||||
assert_eq!(url, "https://api.example.com/sse");
|
||||
assert_eq!(format!("{:?}", protocol), "Some(Sse)"); // SSE 协议
|
||||
}
|
||||
_ => panic!("应该解析为 RemoteService 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 5: 多服务配置,使用 --name 参数选择
|
||||
#[test]
|
||||
fn test_parse_multi_server_config_with_name() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"service-a": {
|
||||
"command": "node",
|
||||
"args": ["a.js"]
|
||||
},
|
||||
"service-b": {
|
||||
"command": "python",
|
||||
"args": ["b.py"]
|
||||
},
|
||||
"service-c": {
|
||||
"baseUrl": "https://api.example.com/mcp"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: Some("service-b".to_string()),
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::LocalCommand { name, command, .. } => {
|
||||
assert_eq!(name, "service-b");
|
||||
assert_eq!(command, "python");
|
||||
}
|
||||
_ => panic!("应该解析为 LocalCommand 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 6: 多服务配置,未指定 --name 参数应该失败
|
||||
#[test]
|
||||
fn test_parse_multi_server_config_without_name_should_fail() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"service-a": {
|
||||
"command": "node"
|
||||
},
|
||||
"service-b": {
|
||||
"command": "python"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None, // 未指定 name
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "多服务配置未指定 --name 应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("请使用 --name 指定"),
|
||||
"错误消息应该提示使用 --name"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 7: 指定的服务不存在应该失败
|
||||
#[test]
|
||||
fn test_parse_nonexistent_service_should_fail() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"service-a": {
|
||||
"command": "node"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: Some("nonexistent".to_string()), // 不存在的服务
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "不存在的服务应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("'nonexistent' 不存在"),
|
||||
"错误消息应该提示服务不存在"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 8: 空配置应该失败
|
||||
#[test]
|
||||
fn test_parse_empty_config_should_fail() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "空配置应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("没有找到任何 MCP 服务"),
|
||||
"错误消息应该提示没有服务"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 9: URL 配置缺少 url 和 baseUrl 应该失败
|
||||
#[test]
|
||||
fn test_parse_url_config_without_url_should_fail() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"invalid-service": {
|
||||
"type": "sse"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "缺少 URL 的配置应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("缺少 url 或 baseUrl"),
|
||||
"错误消息应该提示缺少 URL"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 10: 直接 URL 模式(不使用 JSON 配置)
|
||||
#[test]
|
||||
fn test_parse_direct_url_mode() {
|
||||
let args = ConvertArgs {
|
||||
url: Some("https://api.example.com/mcp".to_string()),
|
||||
config: None,
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::DirectUrl { url } => {
|
||||
assert_eq!(url, "https://api.example.com/mcp");
|
||||
}
|
||||
_ => panic!("应该解析为 DirectUrl 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 11: 既没有 URL 也没有配置应该失败
|
||||
#[test]
|
||||
fn test_parse_no_url_no_config_should_fail() {
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: None,
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "既没有 URL 也没有配置应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("必须提供 URL"),
|
||||
"错误消息应该提示需要提供配置"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 12: 无效的 JSON 应该失败
|
||||
#[test]
|
||||
fn test_parse_invalid_json_should_fail() {
|
||||
let invalid_json = r#"{
|
||||
"mcpServers": {
|
||||
"test": {
|
||||
"command": "node"
|
||||
}
|
||||
// 缺少闭合括号
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(invalid_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "无效的 JSON 应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("配置解析失败"),
|
||||
"错误消息应该提示解析失败"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 13: Stream 协议类型解析
|
||||
#[test]
|
||||
fn test_parse_stream_protocol_type() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"stream-service": {
|
||||
"url": "https://api.example.com/mcp",
|
||||
"type": "stream"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::RemoteService { protocol, .. } => {
|
||||
assert_eq!(format!("{:?}", protocol), "Some(Stream)");
|
||||
}
|
||||
_ => panic!("应该解析为 RemoteService 类型"),
|
||||
}
|
||||
}
|
||||
}
|
||||
211
qiming-mcp-proxy/mcp-proxy/src/client/support/diagnostic.rs
Normal file
211
qiming-mcp-proxy/mcp-proxy/src/client/support/diagnostic.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
//! 诊断和错误处理
|
||||
//!
|
||||
//! 提供错误分类、诊断报告生成等功能
|
||||
|
||||
/// 错误分类
|
||||
pub fn classify_error(e: &anyhow::Error) -> String {
|
||||
let err_str = e.to_string().to_lowercase();
|
||||
|
||||
// 特殊识别 30 秒超时(可能是服务器限制)
|
||||
if (err_str.contains("30") || err_str.contains("thirty"))
|
||||
&& (err_str.contains("timeout") || err_str.contains("second") || err_str.contains("秒"))
|
||||
{
|
||||
"30-second timeout".to_string()
|
||||
}
|
||||
// 识别 503 服务不可用
|
||||
else if err_str.contains("503") || err_str.contains("service unavailable") {
|
||||
"503 Service Unavailable".to_string()
|
||||
}
|
||||
// 识别其他 HTTP 5xx 错误
|
||||
else if err_str.contains("500") || err_str.contains("internal server error") {
|
||||
"500 Internal Server Error".to_string()
|
||||
} else if err_str.contains("502") || err_str.contains("bad gateway") {
|
||||
"502 Bad Gateway".to_string()
|
||||
} else if err_str.contains("504") || err_str.contains("gateway timeout") {
|
||||
"504 Gateway Timeout".to_string()
|
||||
}
|
||||
// 识别 HTTP 4xx 错误
|
||||
else if err_str.contains("401") || err_str.contains("unauthorized") {
|
||||
"401 Unauthorized".to_string()
|
||||
} else if err_str.contains("403") || err_str.contains("forbidden") {
|
||||
"403 Forbidden".to_string()
|
||||
} else if err_str.contains("404") || err_str.contains("not found") {
|
||||
"404 Not Found".to_string()
|
||||
} else if err_str.contains("408") || err_str.contains("request timeout") {
|
||||
"408 Request Timeout".to_string()
|
||||
}
|
||||
// 通用超时
|
||||
else if err_str.contains("timeout") || err_str.contains("timed out") {
|
||||
"Timeout".to_string()
|
||||
}
|
||||
// 连接相关错误
|
||||
else if err_str.contains("connection refused") {
|
||||
"Connection Refused".to_string()
|
||||
} else if err_str.contains("connection reset") {
|
||||
"Connection Reset".to_string()
|
||||
} else if err_str.contains("eof") || err_str.contains("closed") || err_str.contains("shutdown")
|
||||
{
|
||||
"Connection Closed".to_string()
|
||||
}
|
||||
// 网络相关错误
|
||||
else if err_str.contains("dns") || err_str.contains("resolve") {
|
||||
"DNS Resolution Failed".to_string()
|
||||
} else if err_str.contains("certificate") || err_str.contains("ssl") || err_str.contains("tls")
|
||||
{
|
||||
"SSL/TLS Error".to_string()
|
||||
} else if err_str.contains("sending request") || err_str.contains("network") {
|
||||
"Network Error".to_string()
|
||||
}
|
||||
// 会话相关
|
||||
else if err_str.contains("session") {
|
||||
"Session Error".to_string()
|
||||
} else {
|
||||
"Unknown Error".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化错误信息(用于单行日志)
|
||||
pub fn summarize_error(e: &anyhow::Error) -> String {
|
||||
let full = e.to_string();
|
||||
// 截取第一行或前80个字符
|
||||
let first_line = full.lines().next().unwrap_or(&full);
|
||||
// 使用 chars() 安全处理 UTF-8 字符,避免在多字节字符中间截断
|
||||
if first_line.chars().count() > 80 {
|
||||
format!("{}...", first_line.chars().take(77).collect::<String>())
|
||||
} else {
|
||||
first_line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成诊断报告
|
||||
pub fn print_diagnostic_report(
|
||||
protocol: &str,
|
||||
url: &str,
|
||||
alive_duration_secs: u64,
|
||||
disconnect_reason: &str,
|
||||
error_type: Option<&str>,
|
||||
diagnostic: bool,
|
||||
) {
|
||||
if !diagnostic {
|
||||
return;
|
||||
}
|
||||
|
||||
eprintln!("\n=== Connection Diagnostic Report ===");
|
||||
eprintln!("Protocol: {protocol}");
|
||||
|
||||
// 隐藏 URL 中的敏感信息(如 token/ak/key/secret 参数)
|
||||
let masked_url = if url.contains("?") {
|
||||
let parts: Vec<&str> = url.split('?').collect();
|
||||
if parts.len() == 2 {
|
||||
let base = parts[0];
|
||||
let params: Vec<&str> = parts[1].split('&').collect();
|
||||
let masked_params: Vec<String> = params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let lower = p.to_lowercase();
|
||||
let key_part = lower.split('=').next().unwrap_or("");
|
||||
if key_part.contains("key")
|
||||
|| key_part.contains("token")
|
||||
|| key_part.contains("secret")
|
||||
|| key_part.contains("auth")
|
||||
|| key_part.contains("password")
|
||||
|| key_part.contains("passwd")
|
||||
|| key_part.contains("credential")
|
||||
|| key_part == "ak"
|
||||
|| key_part == "sk"
|
||||
{
|
||||
let original_key = p.split('=').next().unwrap_or("");
|
||||
format!("{}=***", original_key)
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{}?{}", base, masked_params.join("&"))
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
|
||||
eprintln!("Service URL: {masked_url}");
|
||||
eprintln!("Connection duration: {}s", alive_duration_secs);
|
||||
eprintln!("Disconnect reason: {disconnect_reason}");
|
||||
|
||||
if let Some(err_type) = error_type {
|
||||
eprintln!("Error type: {err_type}");
|
||||
}
|
||||
|
||||
// 分析可能的原因
|
||||
eprintln!("\nPossible causes:");
|
||||
if (28..=32).contains(&alive_duration_secs) {
|
||||
eprintln!(" Connection dropped around 30 seconds:");
|
||||
eprintln!(" 1. The backend may enforce a fixed session timeout");
|
||||
eprintln!(" 2. A load balancer or gateway may be closing idle connections");
|
||||
eprintln!(" 3. Keepalive/ping settings may be too weak for this environment");
|
||||
} else if alive_duration_secs < 10 {
|
||||
eprintln!(" Quick disconnect ({}s):", alive_duration_secs);
|
||||
eprintln!(" 1. Service may be unavailable or misconfigured");
|
||||
eprintln!(" 2. Authentication/headers may be invalid");
|
||||
eprintln!(" 3. URL/path may point to a non-MCP endpoint");
|
||||
} else if alive_duration_secs >= 60 {
|
||||
eprintln!(" Long-lived connection ({}s):", alive_duration_secs);
|
||||
eprintln!(" 1. Disconnect may be caused by transient network instability");
|
||||
eprintln!(" 2. Backend restarts or rolling deployments may interrupt sessions");
|
||||
}
|
||||
|
||||
let timeout_30s = "30-second timeout";
|
||||
let service_unavailable = "503 Service Unavailable";
|
||||
|
||||
if error_type == Some(timeout_30s) || error_type == Some(service_unavailable) {
|
||||
eprintln!("\nSuggestions:");
|
||||
eprintln!(" 1. Increase backend/read timeout settings");
|
||||
eprintln!(" 2. Increase client ping timeout if the backend is slow");
|
||||
eprintln!(" 3. Consider async/task-based invocation patterns");
|
||||
eprintln!(" 4. Increase ping interval to {}s to reduce pressure", 120);
|
||||
} else if disconnect_reason.contains("Ping") || disconnect_reason.contains("ping") {
|
||||
eprintln!("\nSuggestions:");
|
||||
eprintln!(" 1. Increase ping timeout to {}s", 30);
|
||||
eprintln!(" 2. Increase ping interval to {}s", 60);
|
||||
eprintln!(" 3. Disable ping if the backend does not support stable probes");
|
||||
}
|
||||
|
||||
eprintln!("==============================\n");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::anyhow;
|
||||
|
||||
#[test]
|
||||
fn test_classify_error() {
|
||||
assert_eq!(classify_error(&anyhow!("connection timeout")), "Timeout");
|
||||
assert_eq!(
|
||||
classify_error(&anyhow!("connection refused")),
|
||||
"Connection Refused"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error(&anyhow!("503 Service Unavailable")),
|
||||
"503 Service Unavailable"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error(&anyhow!("401 Unauthorized")),
|
||||
"401 Unauthorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_error() {
|
||||
let short_err = anyhow!("short error");
|
||||
assert_eq!(summarize_error(&short_err), "short error");
|
||||
|
||||
let long_err = anyhow!(
|
||||
"this is a very long error message that exceeds eighty characters and should be truncated"
|
||||
);
|
||||
let summary = summarize_error(&long_err);
|
||||
assert!(summary.len() <= 80);
|
||||
assert!(summary.ends_with("..."));
|
||||
}
|
||||
}
|
||||
268
qiming-mcp-proxy/mcp-proxy/src/client/support/logging.rs
Normal file
268
qiming-mcp-proxy/mcp-proxy/src/client/support/logging.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
//! 日志系统初始化
|
||||
//!
|
||||
//! 处理日志文件的创建、日志级别配置和 OpenTelemetry 追踪初始化
|
||||
|
||||
use anyhow::Result;
|
||||
use mcp_common::{TracingConfig, TracingGuard};
|
||||
use once_cell::sync::OnceCell;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
use super::args::{ConvertArgs, LoggingArgs};
|
||||
|
||||
/// 全局追踪守卫,保持 OTLP exporter 存活
|
||||
static TRACING_GUARD: OnceCell<TracingGuard> = OnceCell::new();
|
||||
|
||||
/// 初始化日志系统
|
||||
/// 只有在 diagnostic=true 时才会创建日志文件并输出详细日志
|
||||
pub fn init_logging(
|
||||
args: &ConvertArgs,
|
||||
mcp_name: Option<&str>,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
init_logging_with_config(&args.logging, mcp_name, quiet, verbose)
|
||||
}
|
||||
|
||||
/// 使用日志配置初始化日志系统(更通用的接口)
|
||||
///
|
||||
/// 这个函数可以被任何需要日志初始化的地方调用,不依赖完整的 ConvertArgs
|
||||
///
|
||||
/// # 功能
|
||||
///
|
||||
/// - 根据 `diagnostic` 参数控制日志级别
|
||||
/// - 支持日志文件输出
|
||||
/// - 支持 OTLP 追踪(当配置了 `otlp_endpoint` 时)
|
||||
pub fn init_logging_with_config(
|
||||
logging: &LoggingArgs,
|
||||
mcp_name: Option<&str>,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
// 检查是否需要启用 OTLP 追踪
|
||||
let enable_otlp = logging.diagnostic && logging.otlp_endpoint.is_some();
|
||||
|
||||
// 如果启用 OTLP,先初始化 tracer provider
|
||||
if enable_otlp {
|
||||
init_otlp_tracing(logging, mcp_name, quiet)?;
|
||||
}
|
||||
|
||||
// 只有在 diagnostic=true 或用户明确指定日志文件时才创建日志文件
|
||||
let log_file_path = determine_log_file_path(logging, mcp_name)?;
|
||||
|
||||
// 根据不同的配置组合初始化 subscriber
|
||||
match (log_file_path, enable_otlp) {
|
||||
(Some(file_path), true) => {
|
||||
init_with_file_and_otlp(logging, &file_path, quiet, verbose)?;
|
||||
}
|
||||
(Some(file_path), false) => {
|
||||
init_with_file_only(logging, &file_path, quiet, verbose)?;
|
||||
}
|
||||
(None, true) => {
|
||||
init_stderr_with_otlp(logging, quiet, verbose)?;
|
||||
}
|
||||
(None, false) => {
|
||||
init_stderr_only(logging, quiet, verbose)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确定日志文件路径
|
||||
fn determine_log_file_path(
|
||||
logging: &LoggingArgs,
|
||||
mcp_name: Option<&str>,
|
||||
) -> Result<Option<std::path::PathBuf>> {
|
||||
if let Some(log_file) = &logging.log_file {
|
||||
// 手动指定文件
|
||||
Ok(Some(log_file.clone()))
|
||||
} else if let Some(log_dir) = &logging.log_dir {
|
||||
// 指定日志目录
|
||||
let session_id = generate_session_id();
|
||||
let date = chrono::Local::now().format("%Y%m%d");
|
||||
let name_part = mcp_name.unwrap_or("unknown");
|
||||
let filename = format!("mcp-proxy-{}-{}-{}.log", name_part, date, session_id);
|
||||
|
||||
std::fs::create_dir_all(log_dir)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create log directory: {}", e))?;
|
||||
Ok(Some(log_dir.join(filename)))
|
||||
} else if logging.diagnostic {
|
||||
// diagnostic=true 时,使用系统临时目录
|
||||
let session_id = generate_session_id();
|
||||
let date = chrono::Local::now().format("%Y%m%d");
|
||||
let name_part = mcp_name.unwrap_or("unknown");
|
||||
let filename = format!("mcp-proxy-{}-{}-{}.log", name_part, date, session_id);
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let file_path = temp_dir.join(filename);
|
||||
|
||||
// 尝试创建文件,验证目录可写
|
||||
std::fs::File::create(&file_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to create log file: {} (path: {})",
|
||||
e,
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Some(file_path))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建日志过滤器
|
||||
fn create_filter(logging: &LoggingArgs, verbose: bool) -> EnvFilter {
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
if logging.diagnostic {
|
||||
EnvFilter::new("debug")
|
||||
} else if verbose {
|
||||
EnvFilter::new("info")
|
||||
} else {
|
||||
EnvFilter::new("warn")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 初始化:文件 + OTLP
|
||||
fn init_with_file_and_otlp(
|
||||
logging: &LoggingArgs,
|
||||
file_path: &std::path::Path,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
let file = std::fs::File::create(file_path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create log file: {}", e))?;
|
||||
|
||||
if !quiet {
|
||||
eprintln!("📝 Log file: {}", file_path.display());
|
||||
eprintln!(
|
||||
"📋 Diagnostic mode: {} (log level: {})",
|
||||
if logging.diagnostic {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
if logging.diagnostic { "DEBUG" } else { "WARN" }
|
||||
);
|
||||
}
|
||||
|
||||
let file_shared = std::sync::Arc::new(file);
|
||||
let filter = create_filter(logging, verbose);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(std::sync::Mutex::new(file_shared.clone()))
|
||||
.with_ansi(false),
|
||||
)
|
||||
.with(fmt::layer().with_writer(std::io::stderr).with_ansi(true))
|
||||
.with(tracing_opentelemetry::layer())
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化:仅文件
|
||||
fn init_with_file_only(
|
||||
logging: &LoggingArgs,
|
||||
file_path: &std::path::Path,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
let file = std::fs::File::create(file_path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create log file: {}", e))?;
|
||||
|
||||
if !quiet {
|
||||
eprintln!("📝 Log file: {}", file_path.display());
|
||||
eprintln!(
|
||||
"📋 Diagnostic mode: {} (log level: {})",
|
||||
if logging.diagnostic {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
if logging.diagnostic { "DEBUG" } else { "WARN" }
|
||||
);
|
||||
}
|
||||
|
||||
let file_shared = std::sync::Arc::new(file);
|
||||
let filter = create_filter(logging, verbose);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(std::sync::Mutex::new(file_shared.clone()))
|
||||
.with_ansi(false),
|
||||
)
|
||||
.with(fmt::layer().with_writer(std::io::stderr).with_ansi(true))
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化:stderr + OTLP
|
||||
fn init_stderr_with_otlp(logging: &LoggingArgs, quiet: bool, verbose: bool) -> Result<()> {
|
||||
if !quiet && !logging.diagnostic {
|
||||
eprintln!("📋 Diagnostic mode: disabled (no log file will be created)");
|
||||
}
|
||||
|
||||
let filter = create_filter(logging, verbose);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer().with_writer(std::io::stderr))
|
||||
.with(tracing_opentelemetry::layer())
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化:仅 stderr
|
||||
fn init_stderr_only(logging: &LoggingArgs, quiet: bool, verbose: bool) -> Result<()> {
|
||||
if !quiet && !logging.diagnostic {
|
||||
eprintln!("📋 Diagnostic mode: disabled (no log file will be created)");
|
||||
}
|
||||
|
||||
let filter = create_filter(logging, verbose);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化 OTLP 追踪(Jaeger 等)
|
||||
fn init_otlp_tracing(logging: &LoggingArgs, mcp_name: Option<&str>, quiet: bool) -> Result<()> {
|
||||
if let Some(endpoint) = &logging.otlp_endpoint {
|
||||
let service_name = mcp_name.unwrap_or(&logging.service_name);
|
||||
|
||||
let config = TracingConfig::new(service_name)
|
||||
.with_otlp(endpoint)
|
||||
.with_version(env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let guard = mcp_common::init_tracing(&config)?;
|
||||
|
||||
// 保存到全局静态变量,确保 guard 在程序运行期间保持存活
|
||||
let _ = TRACING_GUARD.set(guard);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔭 OTLP tracing: enabled");
|
||||
eprintln!(" Endpoint: {}", endpoint);
|
||||
eprintln!(" Service: {}", service_name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 生成随机会话 ID(8 位十六进制)
|
||||
pub fn generate_session_id() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
format!("{:08x}", rng.random::<u32>())
|
||||
}
|
||||
19
qiming-mcp-proxy/mcp-proxy/src/client/support/mod.rs
Normal file
19
qiming-mcp-proxy/mcp-proxy/src/client/support/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! 支持功能模块
|
||||
//!
|
||||
//! 提供配置、日志、工具函数等基础设施支持
|
||||
|
||||
pub mod args;
|
||||
pub mod config;
|
||||
pub mod diagnostic;
|
||||
pub mod logging;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests;
|
||||
|
||||
// 导出常用类型
|
||||
pub use args::{CheckArgs, ConvertArgs, DetectArgs, HealthArgs, LoggingArgs};
|
||||
pub use config::{McpConfigSource, merge_headers, parse_convert_config};
|
||||
pub use diagnostic::{classify_error, print_diagnostic_report, summarize_error};
|
||||
pub use logging::{init_logging, init_logging_with_config};
|
||||
pub use utils::{protocol_name, truncate_str};
|
||||
40
qiming-mcp-proxy/mcp-proxy/src/client/support/utils.rs
Normal file
40
qiming-mcp-proxy/mcp-proxy/src/client/support/utils.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! 通用工具函数
|
||||
|
||||
/// 获取协议名称
|
||||
pub fn protocol_name(protocol: &crate::client::protocol::McpProtocol) -> &'static str {
|
||||
match protocol {
|
||||
crate::client::protocol::McpProtocol::Sse => "SSE",
|
||||
crate::client::protocol::McpProtocol::Stream => "Streamable HTTP",
|
||||
crate::client::protocol::McpProtocol::Stdio => "Stdio",
|
||||
}
|
||||
}
|
||||
|
||||
/// 截断字符串(UTF-8 安全)
|
||||
pub fn truncate_str(s: &str, max_len: usize) -> String {
|
||||
if s.chars().count() > max_len {
|
||||
format!("{}...", s.chars().take(max_len - 3).collect::<String>())
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::protocol::McpProtocol;
|
||||
|
||||
#[test]
|
||||
fn test_protocol_name() {
|
||||
assert_eq!(protocol_name(&McpProtocol::Sse), "SSE");
|
||||
assert_eq!(protocol_name(&McpProtocol::Stream), "Streamable HTTP");
|
||||
assert_eq!(protocol_name(&McpProtocol::Stdio), "Stdio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_str() {
|
||||
assert_eq!(truncate_str("hello", 10), "hello");
|
||||
assert_eq!(truncate_str("hello world", 8), "hello...");
|
||||
assert_eq!(truncate_str("你好世界", 3), "...");
|
||||
assert_eq!(truncate_str("你好世界", 6), "你好世界");
|
||||
}
|
||||
}
|
||||
874
qiming-mcp-proxy/mcp-proxy/src/client/tests.rs
Normal file
874
qiming-mcp-proxy/mcp-proxy/src/client/tests.rs
Normal file
@@ -0,0 +1,874 @@
|
||||
// MCP 客户端模块测试 - 集成测试
|
||||
|
||||
// ============== 共享测试工具 ==============
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_helpers {
|
||||
use serde_json::json;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 测试端口分配
|
||||
pub const TEST_PORT_INTEGRATION: u16 = 19880; // integration_tests 使用
|
||||
pub const TEST_PORT_PROTOCOL: u16 = 19881; // protocol detection 使用
|
||||
pub const TEST_PORT_RECONNECT: u16 = 19876; // reconnection_tests 使用
|
||||
|
||||
/// 获取预编译的 test_mcp_server 二进制路径
|
||||
///
|
||||
/// 注意:test_mcp_server 现在是 mcp-sse-proxy 的 example,
|
||||
/// 编译输出在 target/debug/examples/test_mcp_server
|
||||
pub fn get_test_mcp_server_path() -> String {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace_root = std::path::Path::new(manifest_dir).parent().unwrap();
|
||||
workspace_root
|
||||
.join("target/debug/examples/test_mcp_server")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 获取预编译的 mcp-proxy 二进制路径
|
||||
pub fn get_mcp_proxy_path() -> String {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace_root = std::path::Path::new(manifest_dir).parent().unwrap();
|
||||
workspace_root
|
||||
.join("target/debug/mcp-proxy")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 创建 MCP 服务配置 JSON(使用预编译二进制)
|
||||
pub fn create_test_config() -> String {
|
||||
let binary_path = get_test_mcp_server_path();
|
||||
json!({
|
||||
"mcpServers": {
|
||||
"test-server": {
|
||||
"command": binary_path,
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 启动 proxy 服务器
|
||||
pub async fn spawn_proxy_server(port: u16) -> anyhow::Result<Child> {
|
||||
let config = create_test_config();
|
||||
let mcp_proxy_path = get_mcp_proxy_path();
|
||||
|
||||
let child = Command::new(&mcp_proxy_path)
|
||||
.args([
|
||||
"proxy",
|
||||
"--port",
|
||||
&port.to_string(),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--config",
|
||||
&config,
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// 等待服务器就绪(TCP 轮询)
|
||||
pub async fn wait_for_server_ready(addr: &str, max_retries: u32) -> anyhow::Result<()> {
|
||||
for i in 0..max_retries {
|
||||
match tokio::net::TcpStream::connect(addr).await {
|
||||
Ok(_) => {
|
||||
println!("✅ Server ready (try #{})", i + 1);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
if i < max_retries - 1 {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("服务器在 {} 次尝试后未就绪", max_retries)
|
||||
}
|
||||
|
||||
/// 启动 convert 客户端进程
|
||||
pub async fn spawn_convert_client(
|
||||
url: &str,
|
||||
ping_interval: u64,
|
||||
ping_timeout: u64,
|
||||
) -> anyhow::Result<Child> {
|
||||
let mcp_proxy_path = get_mcp_proxy_path();
|
||||
|
||||
let child = Command::new(&mcp_proxy_path)
|
||||
.args([
|
||||
"convert",
|
||||
url,
|
||||
"--ping-interval",
|
||||
&ping_interval.to_string(),
|
||||
"--ping-timeout",
|
||||
&ping_timeout.to_string(),
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// 监控 stderr 输出,查找任一日志模式
|
||||
pub async fn wait_for_stderr_patterns(
|
||||
stderr: &mut BufReader<tokio::process::ChildStderr>,
|
||||
patterns: &[&str],
|
||||
timeout_duration: Duration,
|
||||
) -> anyhow::Result<bool> {
|
||||
let result = timeout(timeout_duration, async {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stderr.read_line(&mut line).await {
|
||||
Ok(0) => return false, // EOF
|
||||
Ok(_) => {
|
||||
print!("[stderr] {}", line);
|
||||
if patterns.iter().any(|pattern| line.contains(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(found) => Ok(found),
|
||||
Err(_) => Ok(false), // timeout
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送 JSON-RPC 请求并获取响应
|
||||
pub async fn send_jsonrpc_and_receive(
|
||||
stdin: &mut tokio::process::ChildStdin,
|
||||
stdout: &mut BufReader<tokio::process::ChildStdout>,
|
||||
request: serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let msg = format!("{}\n", serde_json::to_string(&request)?);
|
||||
stdin.write_all(msg.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
let mut response = String::new();
|
||||
stdout.read_line(&mut response).await?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// 初始化 MCP 客户端(发送 initialize + initialized)
|
||||
pub async fn initialize_mcp_client(
|
||||
stdin: &mut tokio::process::ChildStdin,
|
||||
stdout: &mut BufReader<tokio::process::ChildStdout>,
|
||||
) -> anyhow::Result<()> {
|
||||
// 发送 initialize 请求
|
||||
let init_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"roots": {"listChanged": true},
|
||||
"sampling": {}
|
||||
},
|
||||
"clientInfo": {
|
||||
"name": "test-client",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let init_response = send_jsonrpc_and_receive(stdin, stdout, init_request).await?;
|
||||
if init_response["error"].is_object() {
|
||||
anyhow::bail!("Initialize failed: {:?}", init_response["error"]);
|
||||
}
|
||||
|
||||
// 发送 initialized 通知
|
||||
let initialized_notification = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
});
|
||||
let msg = format!("{}\n", serde_json::to_string(&initialized_notification)?);
|
||||
stdin.write_all(msg.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
// 等待一下让服务器处理
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 集成测试 ==============
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::test_helpers::*;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio::io::BufReader;
|
||||
|
||||
/// 测试本地 MCP 服务连接和通信
|
||||
///
|
||||
/// 使用本地 test-mcp-server + mcp-proxy proxy 进行测试
|
||||
/// 验证完整的 MCP 通信流程:initialize -> tools/list -> tools/call
|
||||
#[tokio::test]
|
||||
async fn test_real_mcp_service_communication() {
|
||||
println!("\\n========== Test: MCP service connection and communication ==========");
|
||||
|
||||
// 1. 启动本地 proxy 服务器
|
||||
println!("🚀 Start local proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_INTEGRATION)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_INTEGRATION);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 等待 proxy 完全初始化后端连接
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// 2. 启动 convert 客户端连接到本地 proxy
|
||||
println!("🔗 Start convert client...");
|
||||
let url = format!("http://{}", addr);
|
||||
let mut client = spawn_convert_client(&url, 30, 10)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let mut stdin = client.stdin.take().expect("获取 stdin 失败");
|
||||
let stdout = client.stdout.take().expect("获取 stdout 失败");
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 等待客户端连接成功
|
||||
println!("⏳ Waiting for client to connect...");
|
||||
let connected = wait_for_stderr_patterns(
|
||||
&mut stderr_reader,
|
||||
&["开始代理转换", "proxying traffic", "Stdio server started"],
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await
|
||||
.expect("监控 stderr 失败");
|
||||
|
||||
if !connected {
|
||||
println!("⚠️ No connection success log detected, trying to communicate directly...");
|
||||
// 额外等待以确保连接建立
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
// 3. 初始化 MCP 客户端
|
||||
println!("🤝 Initialize MCP client...");
|
||||
initialize_mcp_client(&mut stdin, &mut stdout_reader)
|
||||
.await
|
||||
.expect("初始化失败");
|
||||
|
||||
// 4. 发送 tools/list 请求
|
||||
println!("📋 Get tool list...");
|
||||
let tools_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list"
|
||||
});
|
||||
let tools_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, tools_request)
|
||||
.await
|
||||
.expect("tools/list 请求失败");
|
||||
|
||||
assert_eq!(tools_response["jsonrpc"], "2.0");
|
||||
assert_eq!(tools_response["id"], 2);
|
||||
assert!(tools_response["result"]["tools"].is_array());
|
||||
|
||||
let tools = tools_response["result"]["tools"].as_array().unwrap();
|
||||
println!("✅ Obtained {} tools", tools.len());
|
||||
|
||||
// 验证本地测试工具存在
|
||||
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
println!("Tool list: {:?}", tool_names);
|
||||
assert!(tool_names.contains(&"echo"), "应该包含 echo 工具");
|
||||
assert!(tool_names.contains(&"increment"), "应该包含 increment 工具");
|
||||
|
||||
// 5. 测试调用 echo 工具
|
||||
println!("🔧 Call the echo tool...");
|
||||
let call_tool_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "echo",
|
||||
"arguments": {
|
||||
"message": "Hello from integration test!"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let call_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, call_tool_request)
|
||||
.await
|
||||
.expect("tools/call 请求失败");
|
||||
|
||||
assert_eq!(call_response["jsonrpc"], "2.0");
|
||||
assert_eq!(call_response["id"], 3);
|
||||
|
||||
// 验证返回结果
|
||||
if call_response["error"].is_object() {
|
||||
panic!("Tool call failed with error: {:?}", call_response["error"]);
|
||||
}
|
||||
|
||||
let result = &call_response["result"];
|
||||
assert!(
|
||||
!result["isError"].as_bool().unwrap_or(true),
|
||||
"echo 调用不应该出错"
|
||||
);
|
||||
assert!(result["content"].is_array(), "Content should be an array");
|
||||
|
||||
let content = result["content"].as_array().unwrap();
|
||||
assert!(!content.is_empty(), "Content should not be empty");
|
||||
|
||||
let first_content = &content[0];
|
||||
assert_eq!(first_content["type"], "text");
|
||||
let text = first_content["text"]
|
||||
.as_str()
|
||||
.expect("Should have text field");
|
||||
assert!(
|
||||
text.contains("Hello from integration test!"),
|
||||
"Should echo our message"
|
||||
);
|
||||
|
||||
println!("✅ Tool call successful! Response: {}", text);
|
||||
|
||||
// 6. 测试调用 increment 工具
|
||||
println!("🔧 Call the increment tool...");
|
||||
let increment_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "increment",
|
||||
"arguments": {}
|
||||
}
|
||||
});
|
||||
|
||||
let increment_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, increment_request)
|
||||
.await
|
||||
.expect("increment 请求失败");
|
||||
|
||||
assert!(
|
||||
!increment_response["result"]["isError"]
|
||||
.as_bool()
|
||||
.unwrap_or(true),
|
||||
"increment 调用不应该出错"
|
||||
);
|
||||
println!("✅ increment call successful");
|
||||
|
||||
// 清理:关闭进程
|
||||
println!("🧹 Cleaning process...");
|
||||
drop(stdin);
|
||||
let _ = client.kill().await;
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
println!("========== Test completed ==========\\n");
|
||||
}
|
||||
|
||||
/// 测试协议检测功能
|
||||
///
|
||||
/// 使用本地 mcp-proxy proxy 服务测试协议检测
|
||||
/// 本地 proxy 默认使用 Streamable HTTP 协议
|
||||
#[tokio::test]
|
||||
async fn test_protocol_detection() {
|
||||
println!("\\n========== Test: Protocol Detection ==========");
|
||||
|
||||
// 1. 启动本地 proxy 服务器(Streamable HTTP 模式)
|
||||
println!("🚀 Start local proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_PROTOCOL)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_PROTOCOL);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 2. 测试协议检测
|
||||
println!("🔍Detect protocol type...");
|
||||
let url = format!("http://{}", addr);
|
||||
let protocol = crate::client::protocol::detect_mcp_protocol(&url).await;
|
||||
|
||||
assert!(protocol.is_ok(), "协议检测应该成功");
|
||||
|
||||
let protocol = protocol.unwrap();
|
||||
use crate::client::protocol::McpProtocol;
|
||||
// 本地 proxy 默认使用 Streamable HTTP
|
||||
assert_eq!(
|
||||
protocol,
|
||||
McpProtocol::Stream,
|
||||
"应该检测到 Streamable HTTP 协议"
|
||||
);
|
||||
|
||||
println!("✅ Protocol detected: {:?}", protocol);
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
println!("========== Test completed ==========\\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地重连测试模块
|
||||
///
|
||||
/// 使用 `mcp-proxy proxy` 启动本地服务,`mcp-proxy convert` 连接测试
|
||||
/// 验证通道检查和自动重连逻辑
|
||||
#[cfg(test)]
|
||||
mod reconnection_tests {
|
||||
use super::test_helpers::*;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 测试配置
|
||||
const CLIENT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const RECONNECT_DETECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 测试 1: 正常连接和通信
|
||||
///
|
||||
/// 验证:
|
||||
/// - proxy 服务启动正常
|
||||
/// - convert 客户端连接成功
|
||||
/// - tools/list 请求正常响应
|
||||
#[tokio::test]
|
||||
async fn test_reconnection_normal_connection() {
|
||||
println!("\\n========== Test 1: Normal connection and communication ==========");
|
||||
|
||||
// 1. 启动 proxy 服务器
|
||||
println!("🚀 Start proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_RECONNECT)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
// 等待服务器就绪
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_RECONNECT);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 2. 启动 convert 客户端
|
||||
println!("🔗 Start convert client...");
|
||||
let url = format!("http://{}", addr);
|
||||
let mut client = spawn_convert_client(&url, 5, 3)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let mut stdin = client.stdin.take().expect("获取 stdin 失败");
|
||||
let stdout = client.stdout.take().expect("获取 stdout 失败");
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 等待客户端连接成功(监控 stderr)
|
||||
println!("⏳ Waiting for client to connect...");
|
||||
let connected = wait_for_stderr_patterns(
|
||||
&mut stderr_reader,
|
||||
&["连接成功", "Backend connected successfully"],
|
||||
CLIENT_CONNECT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.expect("监控 stderr 失败");
|
||||
|
||||
if !connected {
|
||||
// 可能连接很快,直接尝试初始化
|
||||
println!("⚠️ No connection success log detected, trying to communicate directly...");
|
||||
}
|
||||
|
||||
// 3. 初始化 MCP 客户端
|
||||
println!("🤝 Initialize MCP client...");
|
||||
initialize_mcp_client(&mut stdin, &mut stdout_reader)
|
||||
.await
|
||||
.expect("初始化失败");
|
||||
|
||||
// 4. 发送 tools/list 请求
|
||||
println!("📋 Get tool list...");
|
||||
let tools_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list"
|
||||
});
|
||||
let tools_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, tools_request)
|
||||
.await
|
||||
.expect("tools/list 请求失败");
|
||||
|
||||
assert!(
|
||||
tools_response["result"]["tools"].is_array(),
|
||||
"应该返回工具列表"
|
||||
);
|
||||
let tools = tools_response["result"]["tools"].as_array().unwrap();
|
||||
println!("✅ Obtained {} tools", tools.len());
|
||||
|
||||
// 验证我们的测试工具存在
|
||||
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
assert!(tool_names.contains(&"echo"), "应该包含 echo 工具");
|
||||
assert!(tool_names.contains(&"increment"), "应该包含 increment 工具");
|
||||
|
||||
// 5. 测试调用 increment 工具
|
||||
println!("🔧 Call the increment tool...");
|
||||
let call_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "increment",
|
||||
"arguments": {}
|
||||
}
|
||||
});
|
||||
let call_response = send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, call_request)
|
||||
.await
|
||||
.expect("tools/call 请求失败");
|
||||
|
||||
assert!(
|
||||
!call_response["result"]["isError"].as_bool().unwrap_or(true),
|
||||
"increment 调用不应该出错"
|
||||
);
|
||||
println!("✅ increment call successful");
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
drop(stdin);
|
||||
let _ = client.kill().await;
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
println!("========== Test 1 Complete ==========\\n");
|
||||
}
|
||||
|
||||
/// 测试 2: 服务器重启后自动重连
|
||||
///
|
||||
/// 验证:
|
||||
/// - 杀死 proxy 服务后,convert 检测到断开
|
||||
/// - 重启 proxy 后,convert 自动重连
|
||||
/// - 重连后功能正常
|
||||
#[tokio::test]
|
||||
async fn test_reconnection_on_server_restart() {
|
||||
println!("\\n========== Test 2: Automatically reconnect after server restart ==========");
|
||||
|
||||
// 1. 启动 proxy 服务器
|
||||
println!("🚀 Start proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_RECONNECT + 1)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_RECONNECT + 1);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 2. 启动 convert 客户端(短 ping 间隔以快速检测断开)
|
||||
println!("🔗 Start convert client...");
|
||||
let url = format!("http://{}", addr);
|
||||
let mut client = spawn_convert_client(&url, 2, 2)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 等待客户端连接成功
|
||||
println!("⏳ Waiting for client to connect...");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// 3. 杀死 proxy 服务器
|
||||
println!("💀 Kill proxy server...");
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
// 4. 等待客户端检测到断开
|
||||
println!("⏳ Waiting for the client to detect the disconnect...");
|
||||
let disconnected = wait_for_stderr_patterns(
|
||||
&mut stderr_reader,
|
||||
&["连接断开", "EVENT_DISCONNECTED", "Connection disconnected"],
|
||||
RECONNECT_DETECT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.expect("监控 stderr 失败");
|
||||
|
||||
// 也可能是 "Ping 检测" 或 "后端连接已关闭"
|
||||
if !disconnected {
|
||||
println!(
|
||||
"⚠️ No clear disconnection log detected, check if there are any reconnection attempts..."
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 重启 proxy 服务器
|
||||
println!("🔄 Restart proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_RECONNECT + 1)
|
||||
.await
|
||||
.expect("重启 proxy 失败");
|
||||
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器重启超时");
|
||||
|
||||
// 6. 等待客户端重连成功
|
||||
println!("⏳ Waiting for the client to reconnect...");
|
||||
let reconnected = wait_for_stderr_patterns(
|
||||
&mut stderr_reader,
|
||||
&[
|
||||
"重连成功",
|
||||
"EVENT_RECONNECTED",
|
||||
"Reconnected, proxy service resumed",
|
||||
],
|
||||
RECONNECT_DETECT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.expect("监控 stderr 失败");
|
||||
|
||||
if reconnected {
|
||||
println!("✅ The client has been reconnected successfully");
|
||||
} else {
|
||||
println!(
|
||||
"⚠️ No reconnection success log detected (may have reconnected before timeout)"
|
||||
);
|
||||
}
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
let _ = client.kill().await;
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
println!("========== Test 2 Complete ==========\\n");
|
||||
}
|
||||
|
||||
/// 测试 3: 指数退避验证
|
||||
///
|
||||
/// 验证退避时间递增(1s, 2s, 4s...)
|
||||
#[tokio::test]
|
||||
async fn test_reconnection_exponential_backoff() {
|
||||
println!("\\n========== Test 3: Exponential Backoff Verification ==========");
|
||||
|
||||
// 不启动服务器,直接启动客户端
|
||||
// 客户端应该不断尝试连接并显示退避时间
|
||||
println!("🔗 Start convert client (serverless)...");
|
||||
let url = format!("http://127.0.0.1:{}", TEST_PORT_RECONNECT + 2);
|
||||
let mut client = spawn_convert_client(&url, 30, 10)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 监控退避日志
|
||||
println!("⏳ Monitor the backoff log (wait about 15 seconds)...");
|
||||
let mut backoff_times: Vec<String> = Vec::new();
|
||||
|
||||
let result = timeout(Duration::from_secs(15), async {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stderr_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
print!("[stderr] {}", line);
|
||||
// 查找退避时间日志(兼容中英文/事件标记)
|
||||
if line.contains("秒后重连")
|
||||
|| line.contains("retrying in")
|
||||
|| line.contains("EVENT_RETRY_BACKOFF")
|
||||
{
|
||||
backoff_times.push(line.clone());
|
||||
if backoff_times.len() >= 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
println!("⚠️ Monitoring timeout");
|
||||
}
|
||||
|
||||
println!("📊 Detected backoff log: {:?}", backoff_times);
|
||||
|
||||
// 验证退避时间递增
|
||||
if backoff_times.len() >= 2 {
|
||||
println!("✅ It has been detected that the backoff mechanism is working");
|
||||
} else {
|
||||
println!("⚠️ Not enough backoff logs detected");
|
||||
}
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
let _ = client.kill().await;
|
||||
|
||||
println!("========== Test 3 Complete ==========\\n");
|
||||
}
|
||||
|
||||
/// 测试 4: 通道断开后请求是否立即返回错误
|
||||
///
|
||||
/// 验证:
|
||||
/// - 服务器停止后,客户端发送请求能否立即返回错误
|
||||
/// - 而不是空等超时
|
||||
#[tokio::test]
|
||||
async fn test_request_returns_error_when_connection_closed() {
|
||||
println!(
|
||||
"\\n========== Test 4: The request returns an error immediately after the channel is disconnected =========="
|
||||
);
|
||||
|
||||
// 1. 启动 proxy 服务器
|
||||
println!("🚀 Start proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_RECONNECT + 3)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_RECONNECT + 3);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 2. 启动 convert 客户端(短 ping 间隔以快速检测断开)
|
||||
println!("🔗 Start convert client...");
|
||||
let url = format!("http://{}", addr);
|
||||
let mut client = spawn_convert_client(&url, 2, 2)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let mut stdin = client.stdin.take().expect("获取 stdin 失败");
|
||||
let stdout = client.stdout.take().expect("获取 stdout 失败");
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 后台监控 stderr
|
||||
let stderr_monitor = tokio::spawn(async move {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stderr_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
print!("[stderr] {}", line);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 等待连接建立
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// 3. 初始化 MCP 客户端
|
||||
println!("🤝 Initialize MCP client...");
|
||||
initialize_mcp_client(&mut stdin, &mut stdout_reader)
|
||||
.await
|
||||
.expect("初始化失败");
|
||||
|
||||
// 4. 发送第一个 tools/list 请求确认通信正常
|
||||
println!("📋 Send first tools/list request (should succeed)...");
|
||||
let tools_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list"
|
||||
});
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let tools_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, tools_request)
|
||||
.await
|
||||
.expect("tools/list 请求失败");
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
tools_response["result"]["tools"].is_array(),
|
||||
"第一个请求应该成功返回工具列表"
|
||||
);
|
||||
println!(
|
||||
"✅ The first request was successful and took time: {:?}",
|
||||
elapsed
|
||||
);
|
||||
|
||||
// 5. 杀死 proxy 服务器
|
||||
println!("💀 Kill proxy server...");
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
// 等待 ping 检测发现连接断开(ping 间隔是 2s,超时也是 2s)
|
||||
println!("⏳ Wait for the ping to detect the disconnection (about 5 seconds)...");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// 6. 发送第二个 tools/list 请求
|
||||
println!("📋 Send a second tools/list request (should return an error quickly)...");
|
||||
let tools_request2 = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/list"
|
||||
});
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = timeout(
|
||||
Duration::from_secs(5),
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, tools_request2),
|
||||
)
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
match result {
|
||||
Ok(Ok(response)) => {
|
||||
// 检查是否是错误响应
|
||||
if response["error"].is_object() {
|
||||
println!("✅ Error response received, time taken: {:?}", elapsed);
|
||||
println!("Error message: {:?}", response["error"]);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"错误响应应该在 3 秒内返回,实际耗时: {:?}",
|
||||
elapsed
|
||||
);
|
||||
} else {
|
||||
// 如果返回了成功响应,说明可能重连了
|
||||
println!(
|
||||
"⚠️ Successful response received (may have been reconnected), time taken: {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
println!(
|
||||
"✅ The request failed (as expected), time taken: {:?}",
|
||||
elapsed
|
||||
);
|
||||
println!("Error: {}", e);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"错误应该在 3 秒内返回,实际耗时: {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
println!(
|
||||
"❌ The request times out (5 seconds), indicating that the client is waiting!"
|
||||
);
|
||||
panic!("请求应该快速返回错误,而不是超时空等");
|
||||
}
|
||||
}
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
drop(stdin);
|
||||
stderr_monitor.abort();
|
||||
let _ = client.kill().await;
|
||||
|
||||
println!("========== Test 4 Complete ==========\\n");
|
||||
}
|
||||
}
|
||||
106
qiming-mcp-proxy/mcp-proxy/src/config.rs
Normal file
106
qiming-mcp-proxy/mcp-proxy/src/config.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
/// The default config file
|
||||
const DEFAULT_CONFIG_YAML: &str = include_str!("../config.yml");
|
||||
|
||||
/// config.yml 中 mirror 段的结构
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
pub struct MirrorYamlConfig {
|
||||
#[serde(default)]
|
||||
pub npm_registry: String,
|
||||
#[serde(default)]
|
||||
pub pypi_index_url: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub log: LogConfig,
|
||||
#[serde(default)]
|
||||
pub mirror: MirrorYamlConfig,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServerConfig {
|
||||
/// The port to listen on for incoming connections
|
||||
pub port: u16,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct LogConfig {
|
||||
/// The log level to use
|
||||
pub level: String,
|
||||
/// The path to the log file
|
||||
pub path: String,
|
||||
/// The number of log files to retain (default: 20)
|
||||
#[serde(default = "default_retain_days")]
|
||||
pub retain_days: u32,
|
||||
}
|
||||
|
||||
/// Default log files to retain
|
||||
fn default_retain_days() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AppConfig {
|
||||
/// Load the config file from the following sources:
|
||||
/// 1. /app/config.yml
|
||||
/// 2. config.yml
|
||||
/// 3. BOT_SERVER_CONFIG environment variable
|
||||
///
|
||||
/// Environment variables can override config values:
|
||||
/// - MCP_PROXY_PORT: Override server port
|
||||
/// - MCP_PROXY_LOG_DIR: Override log directory path
|
||||
/// - MCP_PROXY_LOG_LEVEL: Override log level
|
||||
pub fn load_config() -> Result<Self> {
|
||||
let mut config = match (
|
||||
File::open("/app/config.yml"),
|
||||
File::open("config.yml"),
|
||||
env::var("BOT_SERVER_CONFIG"),
|
||||
) {
|
||||
(Ok(file), _, _) => serde_yaml::from_reader(file),
|
||||
(_, Ok(file), _) => serde_yaml::from_reader(file),
|
||||
(_, _, Ok(file_path)) => serde_yaml::from_reader(File::open(file_path)?),
|
||||
_ => {
|
||||
// 如果都没有,则使用默认配置
|
||||
serde_yaml::from_str::<AppConfig>(DEFAULT_CONFIG_YAML)
|
||||
}
|
||||
}?;
|
||||
|
||||
// 环境变量覆盖配置(优先级最高)
|
||||
if let Ok(port) = env::var("MCP_PROXY_PORT")
|
||||
&& let Ok(port_num) = port.parse::<u16>()
|
||||
{
|
||||
config.server.port = port_num;
|
||||
}
|
||||
|
||||
if let Ok(log_dir) = env::var("MCP_PROXY_LOG_DIR") {
|
||||
config.log.path = log_dir;
|
||||
}
|
||||
|
||||
if let Ok(log_level) = env::var("MCP_PROXY_LOG_LEVEL") {
|
||||
config.log.level = log_level;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn log_path_init(&self) -> Result<()> {
|
||||
let log_path = &self.log.path;
|
||||
|
||||
// 获取日志文件的父目录
|
||||
if let Some(parent) = Path::new(log_path).parent()
|
||||
&& !parent.exists()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
44
qiming-mcp-proxy/mcp-proxy/src/env_init.rs
Normal file
44
qiming-mcp-proxy/mcp-proxy/src/env_init.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! 进程环境初始化
|
||||
//!
|
||||
//! 在 mcp-proxy 启动早期调用,设置进程级环境变量:
|
||||
//! - 镜像源(npm_config_registry / UV_INDEX_URL)
|
||||
|
||||
use crate::config::{AppConfig, MirrorYamlConfig};
|
||||
|
||||
/// 初始化进程环境(镜像源)
|
||||
///
|
||||
/// 在 main() 启动早期、日志初始化前调用。
|
||||
/// 设置的环境变量会被所有子进程(npx/uvx 等)自动继承。
|
||||
pub fn init(app_config: &AppConfig) {
|
||||
// 1. 镜像源配置
|
||||
init_mirror(&app_config.mirror);
|
||||
|
||||
// 2. 汇总诊断日志
|
||||
mcp_common::diagnostic::eprint_env_summary();
|
||||
}
|
||||
|
||||
/// 从 config.yml + 环境变量合并镜像配置,设为进程级环境变量
|
||||
fn init_mirror(yml: &MirrorYamlConfig) {
|
||||
let mut config = mcp_common::mirror::MirrorConfig::from_env();
|
||||
|
||||
// config.yml 非空值作为默认(环境变量优先级更高)
|
||||
if config.npm_registry.is_none() && !yml.npm_registry.is_empty() {
|
||||
config.npm_registry = Some(yml.npm_registry.clone());
|
||||
}
|
||||
if config.pypi_index_url.is_none() && !yml.pypi_index_url.is_empty() {
|
||||
config.pypi_index_url = Some(yml.pypi_index_url.clone());
|
||||
}
|
||||
|
||||
if config.is_empty() {
|
||||
eprintln!(" - Mirror: not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref npm) = config.npm_registry {
|
||||
eprintln!(" - npm registry: {npm}");
|
||||
}
|
||||
if let Some(ref pypi) = config.pypi_index_url {
|
||||
eprintln!(" - PyPI index: {pypi}");
|
||||
}
|
||||
config.apply_to_process_env();
|
||||
}
|
||||
40
qiming-mcp-proxy/mcp-proxy/src/lib.rs
Normal file
40
qiming-mcp-proxy/mcp-proxy/src/lib.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
// 初始化 i18n,使用 crate 内置翻译文件
|
||||
#[macro_use]
|
||||
extern crate rust_i18n;
|
||||
|
||||
// 初始化翻译文件,使用 crate 内置 locales(支持独立发布)
|
||||
i18n!("locales", fallback = "en");
|
||||
|
||||
mod client;
|
||||
mod config;
|
||||
pub mod env_init;
|
||||
mod mcp_error;
|
||||
mod model;
|
||||
mod proxy;
|
||||
mod server;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// 导出基础功能
|
||||
pub use config::AppConfig;
|
||||
pub use mcp_error::AppError;
|
||||
pub use model::{
|
||||
AppState, DynamicRouterService, McpConfig, McpProtocol, McpType, ProxyHandlerManager,
|
||||
get_proxy_manager,
|
||||
};
|
||||
pub use proxy::{McpHandler, ProxyHandler, StreamProxyHandler};
|
||||
pub use proxy::{SseBackendConfig, SseServerBuilder, StreamBackendConfig, StreamServerBuilder};
|
||||
pub use server::{
|
||||
create_telemetry_layer, get_health, get_ready, get_router, init_tracer_provider,
|
||||
log_service_info, mcp_start_task, schedule_check_mcp_live, set_layer, shutdown_telemetry,
|
||||
start_schedule_task,
|
||||
};
|
||||
|
||||
// 导出 CLI 功能
|
||||
pub use client::{Cli, Commands, run_cli};
|
||||
|
||||
// 导出 i18n 功能
|
||||
pub use mcp_common::{current_locale, init_locale_from_env, set_locale, t};
|
||||
|
||||
// 导出用于基准测试的组件
|
||||
pub use server::handlers::run_code_handler::{RunCodeMessageRequest, run_code_handler};
|
||||
355
qiming-mcp-proxy/mcp-proxy/src/main.rs
Normal file
355
qiming-mcp-proxy/mcp-proxy/src/main.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
// Windows 平台配置:隐藏控制台窗口
|
||||
// 当从 Tauri 等 GUI 应用启动时,不显示 CMD 窗口
|
||||
// 注意:这会影响所有 Windows 平台的运行,独立运行时也不会有控制台输出
|
||||
// 日志会写入文件(默认 ./logs/),可以通过日志文件查看运行状态
|
||||
|
||||
mod config;
|
||||
|
||||
use anyhow::Result;
|
||||
use backtrace::Backtrace;
|
||||
use clap::Parser;
|
||||
use log::{error, info, warn};
|
||||
use mcp_stdio_proxy::{
|
||||
AppConfig, AppState, Cli, get_proxy_manager, get_router, init_locale_from_env,
|
||||
init_tracer_provider, log_service_info, run_cli, start_schedule_task,
|
||||
};
|
||||
use run_code_rmcp::warm_up_all_envs;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::signal;
|
||||
use tracing_appender::rolling::{Builder, Rotation};
|
||||
use tracing_subscriber::layer::SubscriberExt as _;
|
||||
use tracing_subscriber::util::SubscriberInitExt as _;
|
||||
use tracing_subscriber::{EnvFilter, Layer as _};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 在最早期注册 panic hook,确保 panic 信息一定输出到 stderr
|
||||
// 当作为 stdio 子进程运行时(如 mcp-proxy convert),父进程通过 stderr pipe 捕获此输出
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let msg = if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else {
|
||||
"unknown panic".to_string()
|
||||
};
|
||||
let location = info
|
||||
.location()
|
||||
.map(|l| format!(" at {}:{}", l.file(), l.line()))
|
||||
.unwrap_or_default();
|
||||
eprintln!("❌ PANIC{}: {}", location, msg);
|
||||
}));
|
||||
|
||||
// 初始化 Rustls CryptoProvider(必须在任何使用 TLS 的代码之前)
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
// 解析命令行参数
|
||||
let cli = Cli::parse();
|
||||
|
||||
// 如果有子命令,运行 CLI 模式
|
||||
if cli.command.is_some() || cli.url.is_some() {
|
||||
return run_cli_mode(cli).await;
|
||||
}
|
||||
|
||||
// 否则运行传统的服务器模式
|
||||
run_server_mode().await
|
||||
}
|
||||
|
||||
/// 运行 CLI 模式
|
||||
async fn run_cli_mode(cli: Cli) -> Result<()> {
|
||||
// 初始化语言设置
|
||||
init_locale_from_env();
|
||||
|
||||
// 检查是否是需要自定义日志初始化的命令
|
||||
// convert 和 proxy 命令会根据自己的参数(--log-dir、--log-file)初始化日志,所以这里跳过
|
||||
let is_convert_command = matches!(cli.command, Some(mcp_stdio_proxy::Commands::Convert(_)));
|
||||
let is_proxy_command = matches!(cli.command, Some(mcp_stdio_proxy::Commands::Proxy(_)));
|
||||
let is_health_command = matches!(cli.command, Some(mcp_stdio_proxy::Commands::Health(_)));
|
||||
let has_custom_logging = is_convert_command || is_proxy_command;
|
||||
|
||||
// CLI 模式独立的日志配置
|
||||
// 跳过会自己初始化日志的命令,避免重复初始化导致 panic
|
||||
if !has_custom_logging && !cli.quiet {
|
||||
// CLI 模式默认只显示错误,避免 info/debug 日志污染输出
|
||||
let log_level = if cli.verbose {
|
||||
"debug"
|
||||
} else if is_health_command {
|
||||
// health 命令使用更严格的过滤,屏蔽 rmcp 库的噪音日志
|
||||
"off"
|
||||
} else {
|
||||
"error" // 默认只显示错误,屏蔽 info/warn/debug
|
||||
};
|
||||
|
||||
// CLI 模式的日志配置:
|
||||
// 1. 禁用 ANSI 颜色(避免污染 JSON)
|
||||
// 2. 输出到 stderr(stdout 用于 JSON-RPC 通信)
|
||||
// 3. 简化格式(无时间戳、无目标)
|
||||
// 4. 优先使用 RUST_LOG 环境变量,否则使用默认日志级别
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_target(false)
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_writer(std::io::stderr)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
// 运行 CLI 命令
|
||||
let result = run_cli(cli).await;
|
||||
if let Err(ref e) = result {
|
||||
// 确保错误信息在进程退出前写到 stderr,方便排查 stdio bridge 启动失败问题
|
||||
eprintln!("❌ CLI command failed: {:?}", e);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 运行传统的服务器模式
|
||||
async fn run_server_mode() -> Result<()> {
|
||||
// 初始化语言设置
|
||||
init_locale_from_env();
|
||||
|
||||
// 配置日志(保持原有的完整日志配置)
|
||||
let app_config = AppConfig::load_config()?;
|
||||
|
||||
// 打印配置信息到 stderr(在日志系统初始化之前)
|
||||
eprintln!("========================================");
|
||||
eprintln!("Starting MCP proxy service...");
|
||||
eprintln!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
eprintln!("Configuration loaded:");
|
||||
eprintln!(" - Port: {}", app_config.server.port);
|
||||
eprintln!(" - Log directory: {}", &app_config.log.path);
|
||||
eprintln!(" - Log level: {}", &app_config.log.level);
|
||||
eprintln!(" - Log retention days: {}", app_config.log.retain_days);
|
||||
mcp_stdio_proxy::env_init::init(&app_config);
|
||||
eprintln!("========================================");
|
||||
|
||||
app_config.log_path_init()?;
|
||||
let log_level = app_config.log.level.clone();
|
||||
let log_path = app_config.log.path.clone();
|
||||
let server_port = app_config.server.port;
|
||||
let retain_days = app_config.log.retain_days;
|
||||
|
||||
// 解析 RUST_LOG 环境变量
|
||||
let log_level_for_console = log_level.clone();
|
||||
let mut console_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level_for_console));
|
||||
|
||||
// 修复 rmcp 库的 span clone panic 问题
|
||||
// 过滤掉 rmcp 的 trace/debug 级别日志,只保留 warn/error,避免 span 生命周期管理问题
|
||||
// see: https://github.com/tokio-rs/tracing/issues/2778
|
||||
console_filter = console_filter
|
||||
.add_directive("rmcp=warn".parse().unwrap())
|
||||
.add_directive("run_code_rmcp=warn".parse().unwrap());
|
||||
|
||||
// 使用 tracing-subscriber 初始化日志记录器
|
||||
let console_layer = tracing_subscriber::fmt::layer()
|
||||
.pretty()
|
||||
.with_writer(std::io::stdout)
|
||||
.with_filter(console_filter);
|
||||
|
||||
// 日志写入到文件,使用 Builder 模式配置日志轮转和保留策略
|
||||
let log_path_for_file = log_path.clone();
|
||||
let file_appender = Builder::new()
|
||||
.rotation(Rotation::DAILY) // 按天滚动
|
||||
.filename_prefix("log") // 文件名前缀
|
||||
.max_log_files(retain_days as usize) // 保留最近 N 个日志文件
|
||||
.build(&log_path_for_file)?;
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let mut log_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level));
|
||||
|
||||
// 修复 rmcp 库的 span clone panic 问题(同样应用于文件日志)
|
||||
log_filter = log_filter
|
||||
.add_directive("rmcp=warn".parse().unwrap())
|
||||
.add_directive("run_code_rmcp=warn".parse().unwrap());
|
||||
|
||||
// 配置文件日志层:使用 compact 格式,避免显示完整的 span 嵌套链,减少日志膨胀
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.compact()
|
||||
.with_ansi(false)
|
||||
.with_writer(non_blocking)
|
||||
.with_filter(log_filter);
|
||||
|
||||
// 初始化 OpenTelemetry tracer provider
|
||||
init_tracer_provider("mcp-proxy", "0.1.0")?;
|
||||
|
||||
// 修复 rmcp 库的 span clone panic 问题(同样应用于 OpenTelemetry 层)
|
||||
// 只为 warn 和以上级别创建 span,避免过多的 span 导致 clone 问题
|
||||
let telemetry_filter = EnvFilter::new("warn")
|
||||
.add_directive("mcp_proxy=debug".parse().unwrap())
|
||||
.add_directive("mcp_stdio_proxy=debug".parse().unwrap())
|
||||
.add_directive("rmcp=error".parse().unwrap())
|
||||
.add_directive("run_code_rmcp=error".parse().unwrap());
|
||||
|
||||
// 配置 OpenTelemetry(添加过滤器以避免 span clone panic)
|
||||
let telemetry_layer = tracing_opentelemetry::layer().with_filter(telemetry_filter);
|
||||
|
||||
// 初始化 tracing 订阅器
|
||||
tracing_subscriber::registry()
|
||||
.with(console_layer)
|
||||
.with(file_layer)
|
||||
.with(telemetry_layer)
|
||||
.init();
|
||||
|
||||
// 记录服务信息
|
||||
log_service_info("mcp-proxy", "0.1.0")?;
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting MCP proxy service...");
|
||||
tracing::info!("Running in proxy server mode");
|
||||
tracing::info!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
tracing::info!("Configuration:");
|
||||
tracing::info!(" - Listen port: {}", server_port);
|
||||
tracing::info!(" - Log directory: {}", log_path);
|
||||
tracing::info!(" - Log level: {}", &app_config.log.level);
|
||||
tracing::info!(" - Log retention days: {}", retain_days);
|
||||
tracing::info!("Environment overrides:");
|
||||
if std::env::var("MCP_PROXY_PORT").is_ok() {
|
||||
tracing::info!(" - MCP_PROXY_PORT override detected: {}", server_port);
|
||||
}
|
||||
if let Ok(log_dir) = std::env::var("MCP_PROXY_LOG_DIR") {
|
||||
tracing::info!(" - MCP_PROXY_LOG_DIR override detected: {}", log_dir);
|
||||
}
|
||||
if let Ok(level) = std::env::var("MCP_PROXY_LOG_LEVEL") {
|
||||
tracing::info!(" - MCP_PROXY_LOG_LEVEL override detected: {}", level);
|
||||
}
|
||||
tracing::info!("========================================");
|
||||
|
||||
// 监听地址
|
||||
let addr = format!("0.0.0.0:{server_port}");
|
||||
tracing::info!("Binding to {addr}...");
|
||||
let listener = TcpListener::bind(&addr).await.map_err(|e| {
|
||||
tracing::error!("Failed to bind to {addr}: {e}");
|
||||
e
|
||||
})?;
|
||||
tracing::info!("Successfully bound to {addr}");
|
||||
|
||||
// 构建 axum 路由
|
||||
tracing::info!("Initializing application state...");
|
||||
let state = AppState::new(app_config.clone()).await;
|
||||
tracing::info!("Application state initialized");
|
||||
|
||||
// 初始化 MCP 路由
|
||||
tracing::info!("Initializing MCP router...");
|
||||
let app = get_router(state.clone()).await?;
|
||||
tracing::info!("MCP router initialized");
|
||||
|
||||
info!("MCP proxy started: {addr}");
|
||||
info!("Health endpoint: http://{addr}/health");
|
||||
info!("MCP list endpoint: http://{addr}/mcp/list");
|
||||
|
||||
// 启动定时任务,定期检查MCP服务状态
|
||||
tokio::spawn(start_schedule_task());
|
||||
info!("Background schedule task started");
|
||||
info!("Log rotation configured (keep last {retain_days} files)");
|
||||
|
||||
// 打印系统信息
|
||||
tracing::info!("System info:");
|
||||
tracing::info!(" - OS: {}", std::env::consts::OS);
|
||||
tracing::info!(" - Arch: {}", std::env::consts::ARCH);
|
||||
tracing::info!(
|
||||
" - Working directory: {:?}",
|
||||
std::env::current_dir().unwrap_or_default()
|
||||
);
|
||||
|
||||
// 注册关闭处理函数,确保在程序退出前执行清理
|
||||
tokio::spawn(async move {
|
||||
// 确保在程序退出前执行清理
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
// 记录详细的 panic 信息
|
||||
warn!("Panic hook triggered");
|
||||
|
||||
// 记录 panic 消息
|
||||
if let Some(s) = panic_info.payload().downcast_ref::<String>() {
|
||||
error!("Panic reason: {s}");
|
||||
} else if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
|
||||
error!("Panic reason: {s}");
|
||||
} else {
|
||||
error!("Panic reason: <unknown>");
|
||||
}
|
||||
|
||||
// 记录 panic 位置
|
||||
if let Some(location) = panic_info.location() {
|
||||
error!("Panic location: {}:{}", location.file(), location.line());
|
||||
}
|
||||
|
||||
// 尝试获取堆栈跟踪
|
||||
error!("Panic backtrace:");
|
||||
let backtrace = Backtrace::new();
|
||||
error!("{backtrace:?}");
|
||||
}));
|
||||
});
|
||||
|
||||
// 预热 uv/deno 环境依赖
|
||||
tokio::spawn(async move {
|
||||
info!("Warming up uv/deno runtime dependencies...");
|
||||
match warm_up_all_envs(None, None, None, None).await {
|
||||
Ok(_) => info!("Runtime dependency warm-up completed"),
|
||||
Err(e) => error!("Runtime dependency warm-up failed: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// 启动服务器,监听多种信号以实现优雅关闭
|
||||
info!("Starting HTTP server...");
|
||||
let server =
|
||||
axum::serve(listener, app.into_make_service()).with_graceful_shutdown(shutdown_signal());
|
||||
|
||||
// 运行服务器
|
||||
if let Err(e) = server.await {
|
||||
error!("HTTP server exited with error: {e}");
|
||||
}
|
||||
|
||||
// 服务器关闭后执行清理逻辑
|
||||
warn!("Shutdown signal received, starting cleanup...");
|
||||
|
||||
// 清理所有SSE服务
|
||||
match get_proxy_manager().cleanup_all_resources().await {
|
||||
Ok(_) => info!("Resource cleanup completed"),
|
||||
Err(e) => error!("Resource cleanup failed: {e}"),
|
||||
}
|
||||
|
||||
// 等待一小段时间确保所有资源都被清理
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
info!("Shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 监听多种终止信号
|
||||
async fn shutdown_signal() {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_crypto_provider_install() {
|
||||
// 测试 CryptoProvider 可以正常安装(或已被安装)
|
||||
// 注意:CryptoProvider 全局只能安装一次,多次安装会返回错误
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
// 无论首次安装还是已安装,只要能获取到默认 provider 即表示成功
|
||||
let provider = rustls::crypto::CryptoProvider::get_default();
|
||||
assert!(provider.is_some(), "CryptoProvider should be available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crypto_provider_get_default() {
|
||||
// 首先确保 CryptoProvider 已安装(忽略已安装的错误)
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// 测试可以正常获取默认 CryptoProvider
|
||||
let provider = rustls::crypto::CryptoProvider::get_default();
|
||||
assert!(
|
||||
provider.is_some(),
|
||||
"CryptoProvider should be available after installation"
|
||||
);
|
||||
}
|
||||
}
|
||||
245
qiming-mcp-proxy/mcp-proxy/src/mcp_error.rs
Normal file
245
qiming-mcp-proxy/mcp-proxy/src/mcp_error.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use axum::{
|
||||
Json,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use http::StatusCode;
|
||||
use mcp_common::t;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// MCP 应用错误类型
|
||||
///
|
||||
/// 包含错误码和详细错误信息,便于程序化处理
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
/// 服务未找到 (0001)
|
||||
#[error("{0}")]
|
||||
ServiceNotFound(String),
|
||||
|
||||
/// 服务在重启冷却期内 (0002)
|
||||
#[error("{0}")]
|
||||
ServiceRestartCooldown(String),
|
||||
|
||||
/// 服务正在启动中 (0003)
|
||||
#[error("{0}")]
|
||||
ServiceStartupInProgress(String),
|
||||
|
||||
/// 服务启动失败 (0004)
|
||||
#[error("{0}")]
|
||||
ServiceStartupFailed(String),
|
||||
|
||||
/// 后端连接错误 (0005)
|
||||
#[error("{0}")]
|
||||
BackendConnection(String),
|
||||
|
||||
/// 配置解析错误 (0006)
|
||||
#[error("{0}")]
|
||||
ConfigParse(String),
|
||||
|
||||
/// MCP 服务器错误 (0007)
|
||||
#[error("{0}")]
|
||||
McpServerError(String),
|
||||
|
||||
/// JSON 序列化错误 (0008)
|
||||
#[error("{0}")]
|
||||
SerdeJsonError(String),
|
||||
|
||||
/// IO 错误 (0009)
|
||||
#[error("{0}")]
|
||||
IoError(String),
|
||||
|
||||
/// 路由未找到 (0010)
|
||||
#[error("{0}")]
|
||||
RouteNotFound(String),
|
||||
|
||||
/// 无效的请求参数 (0011)
|
||||
#[error("{0}")]
|
||||
InvalidParameter(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
// ===========================================
|
||||
// 工厂方法 - 创建带国际化消息的错误
|
||||
// ===========================================
|
||||
|
||||
/// 创建服务未找到错误
|
||||
pub fn service_not_found(service: impl Into<String>) -> Self {
|
||||
Self::ServiceNotFound(
|
||||
t!(
|
||||
"errors.mcp_proxy.service_not_found",
|
||||
service = service.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建服务重启冷却期错误
|
||||
pub fn service_restart_cooldown(service: impl Into<String>) -> Self {
|
||||
Self::ServiceRestartCooldown(
|
||||
t!(
|
||||
"errors.mcp_proxy.service_restart_cooldown",
|
||||
service = service.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建服务启动中错误
|
||||
pub fn service_startup_in_progress(service: impl Into<String>) -> Self {
|
||||
Self::ServiceStartupInProgress(
|
||||
t!(
|
||||
"errors.mcp_proxy.service_startup_in_progress",
|
||||
service = service.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建服务启动失败错误
|
||||
pub fn service_startup_failed(mcp_id: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
Self::ServiceStartupFailed(
|
||||
t!(
|
||||
"errors.mcp_proxy.service_startup_failed",
|
||||
mcp_id = mcp_id.into(),
|
||||
reason = reason.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建后端连接错误
|
||||
pub fn backend_connection(detail: impl Into<String>) -> Self {
|
||||
Self::BackendConnection(
|
||||
t!(
|
||||
"errors.mcp_proxy.backend_connection",
|
||||
detail = detail.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建配置解析错误
|
||||
pub fn config_parse(detail: impl Into<String>) -> Self {
|
||||
Self::ConfigParse(t!("errors.mcp_proxy.config_parse", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建 MCP 服务器错误
|
||||
pub fn mcp_server_error(detail: impl Into<String>) -> Self {
|
||||
Self::McpServerError(
|
||||
t!("errors.mcp_proxy.mcp_server_error", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 JSON 序列化错误
|
||||
pub fn json_serialization(detail: impl Into<String>) -> Self {
|
||||
Self::SerdeJsonError(
|
||||
t!(
|
||||
"errors.mcp_proxy.json_serialization",
|
||||
detail = detail.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 IO 错误
|
||||
pub fn io_error(detail: impl Into<String>) -> Self {
|
||||
Self::IoError(t!("errors.mcp_proxy.io_error", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建路由未找到错误
|
||||
pub fn route_not_found(path: impl Into<String>) -> Self {
|
||||
Self::RouteNotFound(t!("errors.mcp_proxy.route_not_found", path = path.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建无效参数错误
|
||||
pub fn invalid_parameter(detail: impl Into<String>) -> Self {
|
||||
Self::InvalidParameter(
|
||||
t!("errors.mcp_proxy.invalid_parameter", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// 错误码和状态码
|
||||
// ===========================================
|
||||
|
||||
/// 获取错误码
|
||||
pub fn error_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ServiceNotFound(_) => "0001",
|
||||
Self::ServiceRestartCooldown(_) => "0002",
|
||||
Self::ServiceStartupInProgress(_) => "0003",
|
||||
Self::ServiceStartupFailed { .. } => "0004",
|
||||
Self::BackendConnection(_) => "0005",
|
||||
Self::ConfigParse(_) => "0006",
|
||||
Self::McpServerError(_) => "0007",
|
||||
Self::SerdeJsonError(_) => "0008",
|
||||
Self::IoError(_) => "0009",
|
||||
Self::RouteNotFound(_) => "0010",
|
||||
Self::InvalidParameter(_) => "0011",
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 HTTP 状态码
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::ServiceNotFound(_) => StatusCode::NOT_FOUND,
|
||||
Self::ServiceRestartCooldown(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::ServiceStartupInProgress(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Self::ServiceStartupFailed { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::BackendConnection(_) => StatusCode::BAD_GATEWAY,
|
||||
Self::ConfigParse(_) => StatusCode::BAD_REQUEST,
|
||||
Self::McpServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::SerdeJsonError(_) => StatusCode::BAD_REQUEST,
|
||||
Self::IoError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::RouteNotFound(_) => StatusCode::NOT_FOUND,
|
||||
Self::InvalidParameter(_) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// 从外部错误类型转换
|
||||
// ===========================================
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
Self::mcp_server_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::json_serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::io_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorOutput {
|
||||
pub code: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl ErrorOutput {
|
||||
pub fn with_code(code: &'static str, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response<axum::body::Body> {
|
||||
(
|
||||
self.status_code(),
|
||||
Json(ErrorOutput::with_code(self.error_code(), self.to_string())),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
33
qiming-mcp-proxy/mcp-proxy/src/model/app_state_model.rs
Normal file
33
qiming-mcp-proxy/mcp-proxy/src/model/app_state_model.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use crate::AppConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
inner: Arc<AppStateInner>,
|
||||
}
|
||||
#[allow(unused)]
|
||||
#[derive(Debug)]
|
||||
pub struct AppStateInner {
|
||||
pub addr: String,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
impl Deref for AppState {
|
||||
type Target = AppStateInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(config: AppConfig) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(AppStateInner {
|
||||
addr: format!("0.0.0.0:{}", config.server.port),
|
||||
config,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
978
qiming-mcp-proxy/mcp-proxy/src/model/global.rs
Normal file
978
qiming-mcp-proxy/mcp-proxy/src/model/global.rs
Normal file
@@ -0,0 +1,978 @@
|
||||
use axum::Router;
|
||||
use dashmap::DashMap;
|
||||
use log::{debug, error, info};
|
||||
use moka::future::Cache;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
use tokio::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::proxy::McpHandler;
|
||||
|
||||
use super::{CheckMcpStatusResponseStatus, McpConfig, McpProtocol, McpRouterPath, McpType};
|
||||
|
||||
// 全局单例路由表
|
||||
pub static GLOBAL_ROUTES: Lazy<Arc<DashMap<String, Router>>> =
|
||||
Lazy::new(|| Arc::new(DashMap::new()));
|
||||
|
||||
// 全局单例 ProxyHandlerManager
|
||||
pub static GLOBAL_PROXY_MANAGER: Lazy<ProxyHandlerManager> =
|
||||
Lazy::new(ProxyHandlerManager::default);
|
||||
|
||||
/// 动态路由服务
|
||||
#[derive(Clone)]
|
||||
pub struct DynamicRouterService(pub McpProtocol);
|
||||
|
||||
impl DynamicRouterService {
|
||||
// 注册动态 handler
|
||||
pub fn register_route(path: &str, handler: Router) {
|
||||
debug!("=== Register Route ===");
|
||||
debug!("Registration path: {}", path);
|
||||
GLOBAL_ROUTES.insert(path.to_string(), handler);
|
||||
debug!("=== Route registration completed ===");
|
||||
}
|
||||
|
||||
// 删除动态 handler
|
||||
pub fn delete_route(path: &str) {
|
||||
debug!("=== Delete route ===");
|
||||
debug!("Delete path: {}", path);
|
||||
GLOBAL_ROUTES.remove(path);
|
||||
debug!("=== Route deletion completed ===");
|
||||
}
|
||||
|
||||
// 获取动态 handler
|
||||
pub fn get_route(path: &str) -> Option<Router> {
|
||||
let result = GLOBAL_ROUTES.get(path).map(|entry| entry.value().clone());
|
||||
if result.is_some() {
|
||||
debug!("get_route('{}') = Some(Router)", path);
|
||||
} else {
|
||||
debug!("get_route('{}') = None", path);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// 获取所有已注册的路由(debug用)
|
||||
pub fn get_all_routes() -> Vec<String> {
|
||||
GLOBAL_ROUTES
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DynamicRouterService {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let routes = GLOBAL_ROUTES
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect::<Vec<_>>();
|
||||
write!(f, "DynamicRouterService {{ routes: {routes:?} }}")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RAII 进程管理设计
|
||||
// =============================================================================
|
||||
//
|
||||
// 设计目标:当 mcp_id 从 map 中移除时,自动释放对应的 MCP 进程资源
|
||||
//
|
||||
// 核心结构:
|
||||
// - McpProcessGuard: 进程生命周期守护器,实现 Drop trait 自动取消 CancellationToken
|
||||
// - McpService: 封装 McpHandler + McpProcessGuard + 服务状态,作为 map 的 value
|
||||
// - ProxyHandlerManager: 使用单一 DashMap<String, McpService> 管理所有服务
|
||||
//
|
||||
// 资源释放流程:
|
||||
// 1. 从 map 中 remove mcp_id
|
||||
// 2. McpService 被 drop
|
||||
// 3. McpProcessGuard::drop() 被调用
|
||||
// 4. CancellationToken 被 cancel
|
||||
// 5. 监听该 token 的 SseServer/子进程收到信号,自动退出
|
||||
// =============================================================================
|
||||
|
||||
/// MCP 进程生命周期守护器
|
||||
///
|
||||
/// 实现 RAII 模式:当此结构体被 drop 时,自动取消 CancellationToken,
|
||||
/// 触发关联的 SseServer 和子进程退出。
|
||||
///
|
||||
/// # 使用场景
|
||||
///
|
||||
/// 1. 从 `ProxyHandlerManager` 移除 mcp_id 时,自动清理进程
|
||||
/// 2. 服务重启时,旧服务自动被清理
|
||||
/// 3. 系统关闭时,所有服务自动清理
|
||||
pub struct McpProcessGuard {
|
||||
mcp_id: String,
|
||||
cancellation_token: CancellationToken,
|
||||
}
|
||||
|
||||
impl McpProcessGuard {
|
||||
pub fn new(mcp_id: String, cancellation_token: CancellationToken) -> Self {
|
||||
debug!("[RAII] Create process daemon: mcp_id={}", mcp_id);
|
||||
Self {
|
||||
mcp_id,
|
||||
cancellation_token,
|
||||
}
|
||||
}
|
||||
|
||||
/// 克隆 CancellationToken(用于传递给异步任务)
|
||||
pub fn clone_token(&self) -> CancellationToken {
|
||||
self.cancellation_token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpProcessGuard {
|
||||
fn drop(&mut self) {
|
||||
info!(
|
||||
"[RAII] The process daemon was dropped and canceled CancellationToken: mcp_id={}",
|
||||
self.mcp_id
|
||||
);
|
||||
self.cancellation_token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// McpProcessGuard 不实现 Clone,确保唯一所有权
|
||||
impl std::fmt::Debug for McpProcessGuard {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("McpProcessGuard")
|
||||
.field("mcp_id", &self.mcp_id)
|
||||
.field("is_cancelled", &self.cancellation_token.is_cancelled())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP 服务封装
|
||||
///
|
||||
/// 将 McpHandler、McpProcessGuard 和服务状态封装在一起,
|
||||
/// 作为 `ProxyHandlerManager` 中 DashMap 的 value。
|
||||
///
|
||||
/// # RAII 保证
|
||||
///
|
||||
/// 当 McpService 被 drop 时:
|
||||
/// 1. McpProcessGuard 被 drop,触发 CancellationToken 取消
|
||||
/// 2. 关联的子进程收到信号,自动退出
|
||||
pub struct McpService {
|
||||
/// 进程守护器(RAII 核心)
|
||||
process_guard: McpProcessGuard,
|
||||
/// MCP 透明代理处理器(可选,启动中时为 None)
|
||||
handler: Option<McpHandler>,
|
||||
/// 服务状态信息
|
||||
status: McpServiceStatusInfo,
|
||||
}
|
||||
|
||||
/// MCP 服务状态信息(不包含 CancellationToken,由 McpProcessGuard 管理)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpServiceStatusInfo {
|
||||
pub mcp_id: String,
|
||||
pub mcp_type: McpType,
|
||||
pub mcp_router_path: McpRouterPath,
|
||||
pub check_mcp_status_response_status: CheckMcpStatusResponseStatus,
|
||||
pub last_accessed: Instant,
|
||||
pub mcp_config: Option<McpConfig>,
|
||||
/// 连续健康检查失败次数(用于容错机制)
|
||||
pub consecutive_probe_failures: u32,
|
||||
}
|
||||
|
||||
impl McpServiceStatusInfo {
|
||||
pub fn new(
|
||||
mcp_id: String,
|
||||
mcp_type: McpType,
|
||||
mcp_router_path: McpRouterPath,
|
||||
check_mcp_status_response_status: CheckMcpStatusResponseStatus,
|
||||
) -> Self {
|
||||
Self {
|
||||
mcp_id,
|
||||
mcp_type,
|
||||
mcp_router_path,
|
||||
check_mcp_status_response_status,
|
||||
last_accessed: Instant::now(),
|
||||
mcp_config: None,
|
||||
consecutive_probe_failures: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_last_accessed(&mut self) {
|
||||
self.last_accessed = Instant::now();
|
||||
}
|
||||
|
||||
/// 重置健康检查失败计数
|
||||
pub fn reset_probe_failures(&mut self) {
|
||||
self.consecutive_probe_failures = 0;
|
||||
}
|
||||
|
||||
/// 增加健康检查失败计数,返回增加后的值
|
||||
pub fn increment_probe_failures(&mut self) -> u32 {
|
||||
self.consecutive_probe_failures += 1;
|
||||
self.consecutive_probe_failures
|
||||
}
|
||||
}
|
||||
|
||||
impl McpService {
|
||||
/// 创建新的 MCP 服务
|
||||
///
|
||||
/// # 参数
|
||||
/// - `mcp_id`: 服务唯一标识
|
||||
/// - `mcp_type`: 服务类型
|
||||
/// - `mcp_router_path`: 路由路径
|
||||
/// - `cancellation_token`: 用于控制进程生命周期的取消令牌
|
||||
pub fn new(
|
||||
mcp_id: String,
|
||||
mcp_type: McpType,
|
||||
mcp_router_path: McpRouterPath,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Self {
|
||||
let process_guard = McpProcessGuard::new(mcp_id.clone(), cancellation_token);
|
||||
let status = McpServiceStatusInfo::new(
|
||||
mcp_id,
|
||||
mcp_type,
|
||||
mcp_router_path,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
);
|
||||
Self {
|
||||
process_guard,
|
||||
handler: None,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 MCP Handler
|
||||
pub fn set_handler(&mut self, handler: McpHandler) {
|
||||
self.handler = Some(handler);
|
||||
}
|
||||
|
||||
/// 获取 MCP Handler
|
||||
pub fn handler(&self) -> Option<&McpHandler> {
|
||||
self.handler.as_ref()
|
||||
}
|
||||
|
||||
/// 获取服务状态
|
||||
pub fn status(&self) -> &McpServiceStatusInfo {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// 获取可变服务状态
|
||||
pub fn status_mut(&mut self) -> &mut McpServiceStatusInfo {
|
||||
&mut self.status
|
||||
}
|
||||
|
||||
/// 克隆 CancellationToken
|
||||
pub fn clone_token(&self) -> CancellationToken {
|
||||
self.process_guard.clone_token()
|
||||
}
|
||||
|
||||
/// 更新服务状态
|
||||
pub fn update_status(&mut self, status: CheckMcpStatusResponseStatus) {
|
||||
self.status.check_mcp_status_response_status = status;
|
||||
}
|
||||
|
||||
/// 更新最后访问时间
|
||||
pub fn update_last_accessed(&mut self) {
|
||||
self.status.update_last_accessed();
|
||||
}
|
||||
|
||||
/// 设置 MCP 配置
|
||||
pub fn set_mcp_config(&mut self, config: McpConfig) {
|
||||
self.status.mcp_config = Some(config);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for McpService {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("McpService")
|
||||
.field("process_guard", &self.process_guard)
|
||||
.field("handler", &self.handler.is_some())
|
||||
.field("status", &self.status)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 兼容层:保留 McpServiceStatus 以兼容现有代码
|
||||
// =============================================================================
|
||||
|
||||
/// MCP 服务状态(兼容层)
|
||||
///
|
||||
/// 保留此结构体以兼容现有代码,内部委托给 McpServiceStatusInfo
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpServiceStatus {
|
||||
pub mcp_id: String,
|
||||
pub mcp_type: McpType,
|
||||
pub mcp_router_path: McpRouterPath,
|
||||
pub cancellation_token: CancellationToken,
|
||||
pub check_mcp_status_response_status: CheckMcpStatusResponseStatus,
|
||||
pub last_accessed: Instant,
|
||||
pub mcp_config: Option<McpConfig>,
|
||||
/// 连续健康检查失败次数
|
||||
pub consecutive_probe_failures: u32,
|
||||
}
|
||||
|
||||
impl McpServiceStatus {
|
||||
pub fn new(
|
||||
mcp_id: String,
|
||||
mcp_type: McpType,
|
||||
mcp_router_path: McpRouterPath,
|
||||
cancellation_token: CancellationToken,
|
||||
check_mcp_status_response_status: CheckMcpStatusResponseStatus,
|
||||
) -> Self {
|
||||
Self {
|
||||
mcp_id,
|
||||
mcp_type,
|
||||
mcp_router_path,
|
||||
cancellation_token,
|
||||
check_mcp_status_response_status,
|
||||
last_accessed: Instant::now(),
|
||||
mcp_config: None,
|
||||
consecutive_probe_failures: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_mcp_config(mut self, mcp_config: McpConfig) -> Self {
|
||||
self.mcp_config = Some(mcp_config);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn update_last_accessed(&mut self) {
|
||||
self.last_accessed = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ProxyHandlerManager:使用 RAII 模式管理 MCP 服务
|
||||
// =============================================================================
|
||||
|
||||
/// MCP 代理管理器
|
||||
///
|
||||
/// 使用 RAII 模式管理 MCP 服务:
|
||||
/// - 从 map 中移除 mcp_id 时,自动释放对应的进程资源
|
||||
/// - 不需要显式调用 cleanup 方法(但仍提供显式清理接口)
|
||||
#[derive(Debug)]
|
||||
pub struct ProxyHandlerManager {
|
||||
/// 使用单一 DashMap 管理所有 MCP 服务(RAII 核心)
|
||||
services: DashMap<String, McpService>,
|
||||
}
|
||||
|
||||
impl Default for ProxyHandlerManager {
|
||||
fn default() -> Self {
|
||||
ProxyHandlerManager {
|
||||
services: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyHandlerManager {
|
||||
/// 添加 MCP 服务(RAII 模式)
|
||||
///
|
||||
/// 使用新的 RAII 结构创建服务,当服务被移除时会自动清理资源
|
||||
pub fn add_mcp_service(
|
||||
&self,
|
||||
mcp_id: String,
|
||||
mcp_type: McpType,
|
||||
mcp_router_path: McpRouterPath,
|
||||
cancellation_token: CancellationToken,
|
||||
) {
|
||||
let service = McpService::new(
|
||||
mcp_id.clone(),
|
||||
mcp_type,
|
||||
mcp_router_path,
|
||||
cancellation_token,
|
||||
);
|
||||
|
||||
// RAII: 如果已存在同名服务,insert 会返回旧服务,旧服务被 drop 时自动清理
|
||||
if let Some(old_service) = self.services.insert(mcp_id.clone(), service) {
|
||||
info!(
|
||||
"[RAII] Overwrite existing services, old services will be automatically cleaned up: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
drop(old_service);
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加 MCP 服务状态(兼容旧 API)
|
||||
///
|
||||
/// 保持与现有代码的兼容性,内部转换为新的 RAII 结构
|
||||
///
|
||||
/// 注意:`last_accessed` 会被重置为当前时间(插入视为新访问)
|
||||
pub fn add_mcp_service_status_and_proxy(
|
||||
&self,
|
||||
mcp_service_status: McpServiceStatus,
|
||||
proxy_handler: Option<McpHandler>,
|
||||
) {
|
||||
let mcp_id = mcp_service_status.mcp_id.clone();
|
||||
|
||||
// 创建 McpService 使用 RAII 模式
|
||||
// 注意:last_accessed 会在 McpServiceStatusInfo::new() 中重置为 Instant::now()
|
||||
let mut service = McpService::new(
|
||||
mcp_id.clone(),
|
||||
mcp_service_status.mcp_type,
|
||||
mcp_service_status.mcp_router_path,
|
||||
mcp_service_status.cancellation_token,
|
||||
);
|
||||
|
||||
// 设置初始状态
|
||||
service.status_mut().check_mcp_status_response_status =
|
||||
mcp_service_status.check_mcp_status_response_status;
|
||||
|
||||
// 设置配置(如果有)
|
||||
if let Some(config) = mcp_service_status.mcp_config {
|
||||
service.set_mcp_config(config);
|
||||
}
|
||||
|
||||
// 设置 handler(如果有)
|
||||
if let Some(handler) = proxy_handler {
|
||||
service.set_handler(handler);
|
||||
}
|
||||
|
||||
// RAII: 如果已存在同名服务,insert 会返回旧服务,旧服务被 drop 时自动清理
|
||||
if let Some(old_service) = self.services.insert(mcp_id.clone(), service) {
|
||||
info!(
|
||||
"[RAII] Overwrite existing services, old services will be automatically cleaned up: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
// old_service 在此作用域结束时 drop,触发 McpProcessGuard::drop()
|
||||
drop(old_service);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有的 MCP 服务状态(兼容旧 API)
|
||||
///
|
||||
/// 优化:先快速收集所有 keys,然后逐个获取详细信息
|
||||
/// 避免 iter() 长时间锁住多个分片,让其他写操作有机会执行
|
||||
pub fn get_all_mcp_service_status(&self) -> Vec<McpServiceStatus> {
|
||||
// 第一步:快速收集所有 keys(只 clone String,锁持有时间短)
|
||||
let keys: Vec<String> = self
|
||||
.services
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect();
|
||||
|
||||
// 第二步:逐个获取详细信息(每次只锁一个分片)
|
||||
keys.into_iter()
|
||||
.filter_map(|mcp_id| self.get_mcp_service_status(&mcp_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取 MCP 服务状态(兼容旧 API)
|
||||
pub fn get_mcp_service_status(&self, mcp_id: &str) -> Option<McpServiceStatus> {
|
||||
self.services.get(mcp_id).map(|entry| {
|
||||
let service = entry.value();
|
||||
let status = service.status();
|
||||
McpServiceStatus {
|
||||
mcp_id: status.mcp_id.clone(),
|
||||
mcp_type: status.mcp_type.clone(),
|
||||
mcp_router_path: status.mcp_router_path.clone(),
|
||||
cancellation_token: service.clone_token(),
|
||||
check_mcp_status_response_status: status.check_mcp_status_response_status.clone(),
|
||||
last_accessed: status.last_accessed,
|
||||
mcp_config: status.mcp_config.clone(),
|
||||
consecutive_probe_failures: status.consecutive_probe_failures,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新最后访问时间
|
||||
///
|
||||
/// 使用 entry API 确保原子性操作
|
||||
pub fn update_last_accessed(&self, mcp_id: &str) {
|
||||
self.services
|
||||
.entry(mcp_id.to_string())
|
||||
.and_modify(|service| service.update_last_accessed());
|
||||
}
|
||||
|
||||
/// 修改 MCP 服务状态 (Ready/Pending/Error)
|
||||
///
|
||||
/// 使用 entry API 确保原子性操作
|
||||
pub fn update_mcp_service_status(&self, mcp_id: &str, status: CheckMcpStatusResponseStatus) {
|
||||
self.services
|
||||
.entry(mcp_id.to_string())
|
||||
.and_modify(|service| service.update_status(status));
|
||||
}
|
||||
|
||||
/// 获取 MCP Handler
|
||||
pub fn get_proxy_handler(&self, mcp_id: &str) -> Option<McpHandler> {
|
||||
self.services
|
||||
.get(mcp_id)
|
||||
.and_then(|entry| entry.value().handler().cloned())
|
||||
}
|
||||
|
||||
/// 获取服务的 MCP 配置(用于自动重启)
|
||||
pub fn get_mcp_config(&self, mcp_id: &str) -> Option<McpConfig> {
|
||||
self.services
|
||||
.get(mcp_id)
|
||||
.and_then(|entry| entry.value().status().mcp_config.clone())
|
||||
}
|
||||
|
||||
/// 添加 MCP Handler 到已存在的服务
|
||||
///
|
||||
/// 使用 entry API 确保原子性操作
|
||||
pub fn add_proxy_handler(&self, mcp_id: &str, proxy_handler: McpHandler) {
|
||||
match self.services.entry(mcp_id.to_string()) {
|
||||
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
|
||||
entry.get_mut().set_handler(proxy_handler);
|
||||
}
|
||||
dashmap::mapref::entry::Entry::Vacant(_) => {
|
||||
warn!(
|
||||
"[RAII] Trying to add handler to non-existent service: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查服务是否存在
|
||||
pub fn contains_service(&self, mcp_id: &str) -> bool {
|
||||
self.services.contains_key(mcp_id)
|
||||
}
|
||||
|
||||
/// 获取服务数量
|
||||
pub fn service_count(&self) -> usize {
|
||||
self.services.len()
|
||||
}
|
||||
|
||||
/// 注册 MCP 配置到缓存
|
||||
pub async fn register_mcp_config(&self, mcp_id: &str, config: McpConfig) {
|
||||
GLOBAL_MCP_CONFIG_CACHE
|
||||
.insert(mcp_id.to_string(), config)
|
||||
.await;
|
||||
info!("MCP configuration registered in cache: {}", mcp_id);
|
||||
}
|
||||
|
||||
/// 从缓存获取 MCP 配置
|
||||
pub async fn get_mcp_config_from_cache(&self, mcp_id: &str) -> Option<McpConfig> {
|
||||
if let Some(config) = GLOBAL_MCP_CONFIG_CACHE.get(mcp_id).await {
|
||||
debug!("Get MCP configuration from cache: {}", mcp_id);
|
||||
Some(config)
|
||||
} else {
|
||||
debug!("MCP configuration not found in cache: {}", mcp_id);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 从缓存删除 MCP 配置
|
||||
pub async fn unregister_mcp_config(&self, mcp_id: &str) {
|
||||
GLOBAL_MCP_CONFIG_CACHE.invalidate(mcp_id).await;
|
||||
info!("MCP configuration removed from cache: {}", mcp_id);
|
||||
}
|
||||
|
||||
/// 清理资源 (RAII 模式简化版)
|
||||
///
|
||||
/// 通过 RAII 模式,从 DashMap 中移除服务会自动:
|
||||
/// 1. 触发 McpProcessGuard::drop()
|
||||
/// 2. 取消 CancellationToken
|
||||
/// 3. 关联的子进程收到信号退出
|
||||
///
|
||||
/// 此方法额外清理路由和缓存
|
||||
pub async fn cleanup_resources(&self, mcp_id: &str) -> Result<()> {
|
||||
info!("[RAII] Start cleaning up resources: mcp_id={}", mcp_id);
|
||||
|
||||
// 创建路径以构建要删除的路由路径
|
||||
let mcp_sse_router_path = McpRouterPath::new(mcp_id.to_string(), McpProtocol::Sse)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create SSE router path for {}: {}", mcp_id, e)
|
||||
})?;
|
||||
let base_sse_path = mcp_sse_router_path.base_path;
|
||||
|
||||
let mcp_stream_router_path = McpRouterPath::new(mcp_id.to_string(), McpProtocol::Stream)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create Stream router path for {}: {}", mcp_id, e)
|
||||
})?;
|
||||
let base_stream_path = mcp_stream_router_path.base_path;
|
||||
|
||||
// 移除相关路由
|
||||
DynamicRouterService::delete_route(&base_sse_path);
|
||||
DynamicRouterService::delete_route(&base_stream_path);
|
||||
|
||||
// RAII 核心:从 DashMap 移除会触发 McpProcessGuard::drop()
|
||||
// 这会自动取消 CancellationToken,进而触发子进程退出
|
||||
if self.services.remove(mcp_id).is_some() {
|
||||
info!(
|
||||
"[RAII] The service has been removed from the map, McpProcessGuard will automatically cancel the token: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[RAII] Service does not exist, skip removal: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
}
|
||||
|
||||
// 清理配置缓存
|
||||
self.unregister_mcp_config(mcp_id).await;
|
||||
|
||||
// 清理健康状态缓存
|
||||
GLOBAL_RESTART_TRACKER.clear_health_status(mcp_id);
|
||||
|
||||
info!(
|
||||
"[RAII] MCP service resource cleanup completed: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 系统关闭,清理所有资源
|
||||
///
|
||||
/// RAII 模式下,清除 DashMap 会自动释放所有资源
|
||||
pub async fn cleanup_all_resources(&self) -> Result<()> {
|
||||
info!("[RAII] Start cleaning up all MCP service resources");
|
||||
|
||||
// 收集所有 mcp_id
|
||||
let mcp_ids: Vec<String> = self
|
||||
.services
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect();
|
||||
|
||||
let count = mcp_ids.len();
|
||||
|
||||
// 逐个清理(包括路由和缓存)
|
||||
for mcp_id in mcp_ids {
|
||||
if let Err(e) = self.cleanup_resources(&mcp_id).await {
|
||||
error!(
|
||||
"[RAII] Failed to clean up resources: mcp_id={}, error={}",
|
||||
mcp_id, e
|
||||
);
|
||||
// 继续清理其他资源
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"[RAII] All MCP service resources have been cleaned up, and a total of {} services have been cleaned up.",
|
||||
count
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 仅移除服务(依赖 RAII 自动清理进程)
|
||||
///
|
||||
/// 从 DashMap 中移除服务,触发 RAII 自动清理。
|
||||
/// 不会清理路由和缓存,适用于需要快速移除服务的场景。
|
||||
pub fn remove_service(&self, mcp_id: &str) -> bool {
|
||||
if self.services.remove(mcp_id).is_some() {
|
||||
info!(
|
||||
"[RAII] The service has been removed and the process will be automatically cleaned up: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
true
|
||||
} else {
|
||||
debug!("[RAII] Service does not exist: mcp_id={}", mcp_id);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置健康检查失败计数
|
||||
///
|
||||
/// 使用 get_mut 避免为不存在的服务创建空 entry
|
||||
pub fn reset_probe_failures(&self, mcp_id: &str) {
|
||||
if let Some(mut entry) = self.services.get_mut(mcp_id) {
|
||||
entry.value_mut().status_mut().reset_probe_failures();
|
||||
}
|
||||
}
|
||||
|
||||
/// 增加健康检查失败计数,返回增加后的值
|
||||
///
|
||||
/// 使用 entry API 确保原子性操作
|
||||
pub fn increment_probe_failures(&self, mcp_id: &str) -> u32 {
|
||||
self.services
|
||||
.get_mut(mcp_id)
|
||||
.map(|mut entry| entry.value_mut().status_mut().increment_probe_failures())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 清理资源用于重启(保留配置缓存)
|
||||
///
|
||||
/// 与 cleanup_resources 不同,此方法不会清理配置缓存,
|
||||
/// 允许后续使用缓存的配置重新启动服务。
|
||||
pub async fn cleanup_resources_for_restart(&self, mcp_id: &str) -> Result<()> {
|
||||
info!(
|
||||
"[RAII] Start cleaning up resources for restart: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
|
||||
// 创建路径以构建要删除的路由路径
|
||||
let mcp_sse_router_path = McpRouterPath::new(mcp_id.to_string(), McpProtocol::Sse)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create SSE router path for {}: {}", mcp_id, e)
|
||||
})?;
|
||||
let base_sse_path = mcp_sse_router_path.base_path;
|
||||
|
||||
let mcp_stream_router_path = McpRouterPath::new(mcp_id.to_string(), McpProtocol::Stream)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create Stream router path for {}: {}", mcp_id, e)
|
||||
})?;
|
||||
let base_stream_path = mcp_stream_router_path.base_path;
|
||||
|
||||
// 移除相关路由
|
||||
DynamicRouterService::delete_route(&base_sse_path);
|
||||
DynamicRouterService::delete_route(&base_stream_path);
|
||||
|
||||
// RAII 核心:从 DashMap 移除会触发 McpProcessGuard::drop()
|
||||
if self.services.remove(mcp_id).is_some() {
|
||||
info!(
|
||||
"[RAII] The service has been removed from the map (for restart), McpProcessGuard will automatically cancel the token: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[RAII] Service does not exist, skip removal: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
}
|
||||
|
||||
// 注意:不清理配置缓存,保留用于重启
|
||||
// self.unregister_mcp_config(mcp_id).await; // 不调用
|
||||
|
||||
// 清理健康状态缓存
|
||||
GLOBAL_RESTART_TRACKER.clear_health_status(mcp_id);
|
||||
|
||||
info!(
|
||||
"[RAII] MCP service resource cleanup completed (for restart): mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP 配置缓存(使用 moka 实现 TTL)
|
||||
///
|
||||
/// ## 存储架构说明
|
||||
///
|
||||
/// MCP 配置存储在两个位置:
|
||||
///
|
||||
/// 1. **McpServiceStatus.mcp_config**(服务状态中)
|
||||
/// - 存储当前运行服务的配置
|
||||
/// - 随服务清理而被删除
|
||||
/// - 用于快速访问当前服务的配置
|
||||
///
|
||||
/// 2. **GLOBAL_MCP_CONFIG_CACHE**(全局缓存)
|
||||
/// - 独立于服务状态存储
|
||||
/// - 有 TTL(24 小时)
|
||||
/// - 用于服务重启时恢复配置
|
||||
///
|
||||
/// ## 为什么需要两处存储?
|
||||
///
|
||||
/// - 服务清理后,McpServiceStatus 被删除,但配置仍在缓存中
|
||||
/// - 下次请求到来时,可以从缓存恢复配置并重启服务
|
||||
/// - 实现了服务的自动重启能力
|
||||
///
|
||||
/// ## 优先级
|
||||
///
|
||||
/// 1. 请求 header 中的配置(最新)
|
||||
/// 2. 缓存中的配置(兜底)
|
||||
///
|
||||
/// ## TTL
|
||||
///
|
||||
/// - 24 小时(可配置)
|
||||
/// - max_capacity: 1000(防止内存溢出)
|
||||
pub struct McpConfigCache {
|
||||
cache: Cache<String, McpConfig>,
|
||||
}
|
||||
|
||||
impl McpConfigCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Cache::builder()
|
||||
.time_to_live(Duration::from_secs(24 * 60 * 60)) // 24 小时 TTL
|
||||
.max_capacity(1000) // 最多缓存 1000 个配置,防止内存溢出
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert(&self, mcp_id: String, config: McpConfig) {
|
||||
self.cache.insert(mcp_id.clone(), config).await;
|
||||
info!("MCP configuration cached: {} (TTL: 24h)", mcp_id);
|
||||
}
|
||||
|
||||
pub async fn get(&self, mcp_id: &str) -> Option<McpConfig> {
|
||||
self.cache.get(mcp_id).await
|
||||
}
|
||||
|
||||
pub async fn invalidate(&self, mcp_id: &str) {
|
||||
self.cache.invalidate(mcp_id).await;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn invalidate_all(&self) {
|
||||
self.cache.invalidate_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for McpConfigCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局配置缓存单例
|
||||
pub static GLOBAL_MCP_CONFIG_CACHE: Lazy<McpConfigCache> = Lazy::new(McpConfigCache::default);
|
||||
|
||||
/// MCP 服务重启追踪器
|
||||
///
|
||||
/// 用于防止服务频繁重启导致的无限循环
|
||||
///
|
||||
/// ## 重启限制
|
||||
///
|
||||
/// - 最小重启间隔:30 秒
|
||||
/// - 如果服务在 30 秒内被标记为需要重启,将跳过重启
|
||||
/// - 这防止了服务启动失败时的无限重启循环
|
||||
///
|
||||
/// ## 健康状态缓存
|
||||
///
|
||||
/// - 缓存后端健康状态,避免频繁检查
|
||||
/// - 缓存时间:5 秒(可配置)
|
||||
/// - 用于减少 `is_mcp_server_ready()` 调用频率
|
||||
pub struct RestartTracker {
|
||||
// mcp_id -> 最后重启时间
|
||||
last_restart: DashMap<String, Instant>,
|
||||
// mcp_id -> (健康状态, 检查时间)
|
||||
health_status: DashMap<String, (bool, Instant)>,
|
||||
// mcp_id -> 启动锁,防止并发启动同一服务
|
||||
startup_locks: DashMap<String, Arc<Mutex<()>>>,
|
||||
}
|
||||
|
||||
impl RestartTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_restart: DashMap::new(),
|
||||
health_status: DashMap::new(),
|
||||
startup_locks: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取缓存的健康状态
|
||||
///
|
||||
/// 如果缓存未过期(5秒内),返回缓存值
|
||||
/// 否则返回 None,表示需要重新检查
|
||||
pub fn get_cached_health_status(&self, mcp_id: &str) -> Option<bool> {
|
||||
let cache_duration = Duration::from_secs(5); // 5 秒缓存
|
||||
let now = Instant::now();
|
||||
|
||||
self.health_status.get(mcp_id).and_then(|entry| {
|
||||
let (is_healthy, check_time) = *entry.value();
|
||||
if now.duration_since(check_time) < cache_duration {
|
||||
Some(is_healthy)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新健康状态缓存
|
||||
pub fn update_health_status(&self, mcp_id: &str, is_healthy: bool) {
|
||||
self.health_status
|
||||
.insert(mcp_id.to_string(), (is_healthy, Instant::now()));
|
||||
}
|
||||
|
||||
/// 清除健康状态缓存
|
||||
pub fn clear_health_status(&self, mcp_id: &str) {
|
||||
self.health_status.remove(mcp_id);
|
||||
}
|
||||
|
||||
/// 检查是否可以重启服务
|
||||
///
|
||||
/// 返回 true 表示可以重启,false 表示在冷却期内
|
||||
///
|
||||
/// 注意:此方法仅检查是否可以重启,不会自动插入时间戳。
|
||||
/// 时间戳只在服务成功启动后通过 `record_restart()` 方法记录。
|
||||
pub fn can_restart(&self, mcp_id: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let min_restart_interval = Duration::from_secs(30); // 30 秒最小重启间隔
|
||||
|
||||
// 只检查,不自动插入时间戳
|
||||
if let Some(last_restart) = self.last_restart.get(mcp_id) {
|
||||
let elapsed = now.duration_since(*last_restart);
|
||||
if elapsed < min_restart_interval {
|
||||
warn!(
|
||||
"Service {} is in the cooldown period and is only {} seconds since its last restart. Restart is skipped.",
|
||||
mcp_id,
|
||||
elapsed.as_secs()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 不在冷却期内,但不自动更新时间戳
|
||||
true
|
||||
}
|
||||
|
||||
/// 记录服务成功重启
|
||||
///
|
||||
/// 此方法应在服务成功启动后调用,用于记录重启时间戳。
|
||||
/// 配合 `can_restart()` 使用,避免在服务启动失败时插入时间戳。
|
||||
pub fn record_restart(&self, mcp_id: &str) {
|
||||
self.last_restart.insert(mcp_id.to_string(), Instant::now());
|
||||
info!(
|
||||
"The service started successfully and the restart time was recorded: {}",
|
||||
mcp_id
|
||||
);
|
||||
}
|
||||
|
||||
/// 清除重启时间戳
|
||||
///
|
||||
/// 当服务启动失败时,可选择调用此方法清除时间戳,
|
||||
/// 允许立即重试而不必等待冷却期。
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_restart(&self, mcp_id: &str) {
|
||||
self.last_restart.remove(mcp_id);
|
||||
info!("Restart timestamp cleared for service {}", mcp_id);
|
||||
}
|
||||
|
||||
/// 尝试获取服务启动锁
|
||||
///
|
||||
/// 返回 Some(OwnedMutexGuard) 表示获取成功,可以继续启动服务
|
||||
/// 返回 None 表示服务正在启动中,应该跳过本次启动
|
||||
///
|
||||
/// # 使用方式
|
||||
///
|
||||
/// ```ignore
|
||||
/// if let Some(_guard) = GLOBAL_RESTART_TRACKER.try_acquire_startup_lock(&mcp_id) {
|
||||
/// // 获取到锁,可以启动服务
|
||||
/// let result = start_service().await;
|
||||
/// // _guard 在作用域结束时自动释放
|
||||
/// } else {
|
||||
/// // 未获取到锁,服务正在启动中
|
||||
/// return Ok(Response::503);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn try_acquire_startup_lock(&self, mcp_id: &str) -> Option<OwnedMutexGuard<()>> {
|
||||
// 使用 entry API 确保原子性,避免竞态条件
|
||||
let lock = self
|
||||
.startup_locks
|
||||
.entry(mcp_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone();
|
||||
|
||||
// 尝试获取 owned 锁,锁会一直保持到返回的 guard 被 drop
|
||||
match lock.try_lock_owned() {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(_) => {
|
||||
// 锁被占用,服务正在启动中
|
||||
debug!("Service {} is starting, skip this startup", mcp_id);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理服务启动锁
|
||||
///
|
||||
/// 当服务启动完成或失败后,应该清理启动锁以允许后续重试
|
||||
/// 注意:正常情况下锁会随 MutexGuard 自动释放,此方法用于异常清理
|
||||
#[allow(dead_code)]
|
||||
pub fn cleanup_startup_lock(&self, mcp_id: &str) {
|
||||
self.startup_locks.remove(mcp_id);
|
||||
debug!("Cleaned startup lock for service {}", mcp_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RestartTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局重启追踪器单例
|
||||
pub static GLOBAL_RESTART_TRACKER: Lazy<RestartTracker> = Lazy::new(RestartTracker::default);
|
||||
|
||||
// 提供一个便捷的函数来获取全局 ProxyHandlerManager
|
||||
pub fn get_proxy_manager() -> &'static ProxyHandlerManager {
|
||||
&GLOBAL_PROXY_MANAGER
|
||||
}
|
||||
71
qiming-mcp-proxy/mcp-proxy/src/model/http_result.rs
Normal file
71
qiming-mcp-proxy/mcp-proxy/src/model/http_result.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HttpResult<T> {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub data: Option<T>,
|
||||
pub tid: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
impl<T> HttpResult<T> {
|
||||
pub fn success(data: T, tid: Option<String>) -> Self {
|
||||
HttpResult {
|
||||
code: "0000".to_string(),
|
||||
message: "成功".to_string(),
|
||||
data: Some(data),
|
||||
tid,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(code: &str, message: &str, tid: Option<String>) -> Self {
|
||||
HttpResult {
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
data: None,
|
||||
tid,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize> Serialize for HttpResult<T> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("HttpResult", 5)?;
|
||||
state.serialize_field("code", &self.code)?;
|
||||
state.serialize_field("message", &self.message)?;
|
||||
state.serialize_field("data", &self.data)?;
|
||||
state.serialize_field("tid", &self.tid)?;
|
||||
let is_success = self.code == "0000";
|
||||
state.serialize_field("success", &is_success)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize> IntoResponse for HttpResult<T> {
|
||||
fn into_response(self) -> Response {
|
||||
match serde_json::to_string(&self) {
|
||||
Ok(body) => (
|
||||
StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
body,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/plain")],
|
||||
"Internal Server Error",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
104
qiming-mcp-proxy/mcp-proxy/src/model/mcp_check_status_model.rs
Normal file
104
qiming-mcp-proxy/mcp-proxy/src/model/mcp_check_status_model.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{McpProtocol, McpType};
|
||||
|
||||
//check mcp服务状态的请求参数
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct CheckMcpStatusRequestParams {
|
||||
//mcp的id,必须有
|
||||
#[serde(rename = "mcpId")]
|
||||
pub mcp_id: String,
|
||||
//mcp的json配置,必须有
|
||||
#[serde(rename = "mcpJsonConfig")]
|
||||
pub mcp_json_config: String,
|
||||
//mcp类型,必须有,默认:OneShot
|
||||
#[serde(rename = "mcpType", default = "default_mcp_type")]
|
||||
pub mcp_type: McpType,
|
||||
//后端MCP服务的协议类型(可选),用于指定连接到后端服务时使用的协议
|
||||
//如果不指定,则使用客户端协议(由路由路径决定)
|
||||
#[serde(rename = "backendProtocol")]
|
||||
#[allow(dead_code)] // 为未来的功能预留
|
||||
pub backend_protocol: Option<McpProtocol>,
|
||||
}
|
||||
|
||||
//默认的mcp类型
|
||||
fn default_mcp_type() -> McpType {
|
||||
McpType::OneShot
|
||||
}
|
||||
|
||||
//check mcp服务状态的响应参数
|
||||
#[derive(Deserialize, Debug, Serialize)]
|
||||
pub struct CheckMcpStatusResponseParams {
|
||||
//是否就绪, READY 状态,表示 true
|
||||
pub ready: bool,
|
||||
//状态
|
||||
pub status: McpStatusResponseEnum,
|
||||
//消息
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl CheckMcpStatusResponseParams {
|
||||
pub fn new(ready: bool, status: CheckMcpStatusResponseStatus, message: Option<String>) -> Self {
|
||||
//检查是否error,是的话,取error枚举里面的错误,放在 message里
|
||||
let mut message = message;
|
||||
if let CheckMcpStatusResponseStatus::Error(err) = status.clone() {
|
||||
message = Some(err.to_string());
|
||||
}
|
||||
let status = McpStatusResponseEnum::from(status);
|
||||
|
||||
Self {
|
||||
ready,
|
||||
status,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//check mcp服务状态的响应 status 枚举: READY,PENDING,ERROR
|
||||
#[derive(Deserialize, Debug, Serialize, Clone)]
|
||||
pub enum McpStatusResponseEnum {
|
||||
//就绪
|
||||
Ready,
|
||||
//处理中
|
||||
Pending,
|
||||
//错误
|
||||
Error,
|
||||
}
|
||||
|
||||
//check mcp服务状态的响应 status 枚举: READY,PENDING,ERROR
|
||||
#[derive(Deserialize, Debug, Serialize, Clone, Default)]
|
||||
pub enum CheckMcpStatusResponseStatus {
|
||||
//就绪
|
||||
Ready,
|
||||
//处理中
|
||||
#[default]
|
||||
Pending,
|
||||
//错误
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl From<CheckMcpStatusResponseStatus> for McpStatusResponseEnum {
|
||||
fn from(value: CheckMcpStatusResponseStatus) -> Self {
|
||||
match value {
|
||||
CheckMcpStatusResponseStatus::Ready => Self::Ready,
|
||||
CheckMcpStatusResponseStatus::Pending => Self::Pending,
|
||||
CheckMcpStatusResponseStatus::Error(_) => Self::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for CheckMcpStatusResponseParams {
|
||||
fn into_response(self) -> Response {
|
||||
if let Ok(body) = serde_json::to_string(&self) {
|
||||
(StatusCode::OK, body).into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"serde_json::to_string error".to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
98
qiming-mcp-proxy/mcp-proxy/src/model/mcp_config.rs
Normal file
98
qiming-mcp-proxy/mcp-proxy/src/model/mcp_config.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{McpProtocol, mcp_router_model::McpServerConfig};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpConfig {
|
||||
//mcp_id
|
||||
#[serde(rename = "mcpId")]
|
||||
pub mcp_id: String,
|
||||
//mcp_json_config,可能没有
|
||||
#[serde(rename = "mcpJsonConfig")]
|
||||
pub mcp_json_config: Option<String>,
|
||||
//mcp类型,默认为持续运行
|
||||
#[serde(default = "default_mcp_type", rename = "mcpType")]
|
||||
pub mcp_type: McpType,
|
||||
//客户端协议(用于暴露给客户端的API接口类型)
|
||||
#[serde(default = "default_mcp_protocol", rename = "clientProtocol")]
|
||||
pub client_protocol: McpProtocol,
|
||||
// 解析后的服务器配置(可选)
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub server_config: Option<McpServerConfig>,
|
||||
}
|
||||
|
||||
fn default_mcp_protocol() -> McpProtocol {
|
||||
McpProtocol::Sse
|
||||
}
|
||||
|
||||
fn default_mcp_type() -> McpType {
|
||||
McpType::OneShot
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub enum McpType {
|
||||
// 持续运行
|
||||
Persistent,
|
||||
// 一次性任务
|
||||
#[default]
|
||||
OneShot,
|
||||
}
|
||||
|
||||
impl FromStr for McpType {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"persistent" => Ok(McpType::Persistent),
|
||||
"oneShot" => Ok(McpType::OneShot),
|
||||
_ => Err(anyhow::anyhow!("无效的 MCP 类型: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpConfig {
|
||||
pub fn new(
|
||||
mcp_id: String,
|
||||
mcp_json_config: Option<String>,
|
||||
mcp_type: McpType,
|
||||
client_protocol: McpProtocol,
|
||||
) -> Self {
|
||||
Self {
|
||||
mcp_id,
|
||||
mcp_json_config,
|
||||
mcp_type,
|
||||
client_protocol,
|
||||
server_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_json(json: &str) -> Result<Self> {
|
||||
let config: McpConfig = serde_json::from_str(json)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 从 JSON 字符串创建并解析服务器配置
|
||||
pub fn from_json_with_server(
|
||||
mcp_id: String,
|
||||
mcp_json_config: String,
|
||||
mcp_type: McpType,
|
||||
client_protocol: McpProtocol,
|
||||
) -> Result<Self> {
|
||||
let mcp_json_server_parameters =
|
||||
crate::model::McpJsonServerParameters::from(mcp_json_config.clone());
|
||||
let server_config = mcp_json_server_parameters
|
||||
.try_get_first_mcp_server()
|
||||
.context("Failed to parse MCP server config")?;
|
||||
|
||||
Ok(Self {
|
||||
mcp_id,
|
||||
mcp_json_config: Some(mcp_json_config),
|
||||
mcp_type,
|
||||
client_protocol,
|
||||
server_config: Some(server_config),
|
||||
})
|
||||
}
|
||||
}
|
||||
1292
qiming-mcp-proxy/mcp-proxy/src/model/mcp_router_model.rs
Normal file
1292
qiming-mcp-proxy/mcp-proxy/src/model/mcp_router_model.rs
Normal file
File diff suppressed because it is too large
Load Diff
22
qiming-mcp-proxy/mcp-proxy/src/model/mod.rs
Normal file
22
qiming-mcp-proxy/mcp-proxy/src/model/mod.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
mod app_state_model;
|
||||
mod global;
|
||||
mod http_result;
|
||||
mod mcp_check_status_model;
|
||||
mod mcp_config;
|
||||
mod mcp_router_model;
|
||||
|
||||
pub use app_state_model::AppState;
|
||||
pub use global::{
|
||||
DynamicRouterService, GLOBAL_RESTART_TRACKER, McpServiceStatus, ProxyHandlerManager,
|
||||
get_proxy_manager,
|
||||
};
|
||||
pub use http_result::HttpResult;
|
||||
pub use mcp_check_status_model::{
|
||||
CheckMcpStatusRequestParams, CheckMcpStatusResponseParams, CheckMcpStatusResponseStatus,
|
||||
};
|
||||
pub use mcp_config::{McpConfig, McpType};
|
||||
pub use mcp_router_model::{
|
||||
AddRouteParams, GLOBAL_SSE_MCP_ROUTES_PREFIX, GLOBAL_STREAM_MCP_ROUTES_PREFIX,
|
||||
McpJsonServerParameters, McpProtocol, McpProtocolPath, McpRouterPath, McpServerCommandConfig,
|
||||
McpServerConfig,
|
||||
};
|
||||
78
qiming-mcp-proxy/mcp-proxy/src/proxy/mod.rs
Normal file
78
qiming-mcp-proxy/mcp-proxy/src/proxy/mod.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! Proxy module - re-exports handler types from proxy libraries
|
||||
//!
|
||||
//! This module provides a unified interface for proxy handlers by re-exporting
|
||||
//! types from mcp-sse-proxy, mcp-streamable-proxy, and mcp-common libraries.
|
||||
|
||||
// Re-export SseHandler as ProxyHandler for backward compatibility
|
||||
// SseHandler is used because it's based on rmcp 0.10 which supports both
|
||||
// SSE server mode and CLI stdio mode used in the main project
|
||||
pub use mcp_sse_proxy::SseHandler as ProxyHandler;
|
||||
|
||||
// Re-export SseServerHandler (unified handler for SSE server mode)
|
||||
pub use mcp_sse_proxy::SseServerHandler;
|
||||
|
||||
// Re-export StreamProxyHandler with an alias to distinguish from SSE ProxyHandler
|
||||
// Both mcp-sse-proxy and mcp-streamable-proxy export ProxyHandler, so we use an alias
|
||||
pub use mcp_streamable_proxy::ProxyHandler as StreamProxyHandler;
|
||||
|
||||
// Re-export ToolFilter from mcp-common
|
||||
pub use mcp_common::ToolFilter;
|
||||
|
||||
// Re-export client connection types for high-level API (each from its own library)
|
||||
pub use mcp_sse_proxy::{McpClientConfig, SseClientConnection};
|
||||
pub use mcp_streamable_proxy::StreamClientConnection;
|
||||
|
||||
// Re-export Builder APIs for server creation
|
||||
pub use mcp_sse_proxy::server_builder::{BackendConfig as SseBackendConfig, SseServerBuilder};
|
||||
pub use mcp_streamable_proxy::server_builder::{
|
||||
BackendConfig as StreamBackendConfig, StreamServerBuilder,
|
||||
};
|
||||
|
||||
/// Unified handler enum that can hold either SSE or Stream handler
|
||||
///
|
||||
/// This allows ProxyHandlerManager to store handlers of either type
|
||||
/// while providing a common interface for status checks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum McpHandler {
|
||||
/// SSE protocol handler (from mcp-sse-proxy)
|
||||
/// Holds SseServerHandler which unifies SseHandler and BackendSessionHandler
|
||||
Sse(Box<SseServerHandler>),
|
||||
/// Streamable HTTP protocol handler (from mcp-streamable-proxy)
|
||||
Stream(Box<StreamProxyHandler>),
|
||||
}
|
||||
|
||||
impl McpHandler {
|
||||
/// Check if the underlying MCP server is ready
|
||||
pub async fn is_mcp_server_ready(&self) -> bool {
|
||||
match self {
|
||||
McpHandler::Sse(h) => h.is_mcp_server_ready().await,
|
||||
McpHandler::Stream(h) => h.is_mcp_server_ready().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the backend connection is terminated
|
||||
pub async fn is_terminated_async(&self) -> bool {
|
||||
match self {
|
||||
McpHandler::Sse(h) => h.is_terminated_async().await,
|
||||
McpHandler::Stream(h) => h.is_terminated_async().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProxyHandler> for McpHandler {
|
||||
fn from(handler: ProxyHandler) -> Self {
|
||||
McpHandler::Sse(Box::new(SseServerHandler::Sse(handler)))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SseServerHandler> for McpHandler {
|
||||
fn from(handler: SseServerHandler) -> Self {
|
||||
McpHandler::Sse(Box::new(handler))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StreamProxyHandler> for McpHandler {
|
||||
fn from(handler: StreamProxyHandler) -> Self {
|
||||
McpHandler::Stream(Box::new(handler))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use axum::extract::Path;
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::{
|
||||
AppError, get_proxy_manager,
|
||||
model::{CheckMcpStatusResponseParams, CheckMcpStatusResponseStatus, HttpResult},
|
||||
};
|
||||
|
||||
///根据 mcpId,检查 mcp 透明代理服务是否正在运行的状态
|
||||
// #[axum::debug_handler]
|
||||
pub async fn check_mcp_is_status_handler(
|
||||
Path(mcp_id): Path<String>,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
info!("mcp_id: {mcp_id}");
|
||||
|
||||
let status = get_proxy_manager()
|
||||
.get_mcp_service_status(&mcp_id)
|
||||
.map(|mcp_service_status| mcp_service_status.check_mcp_status_response_status.clone());
|
||||
|
||||
if let Some(status) = status {
|
||||
match status.clone() {
|
||||
CheckMcpStatusResponseStatus::Ready => Ok(HttpResult::success(
|
||||
CheckMcpStatusResponseParams::new(true, status, None),
|
||||
None,
|
||||
)),
|
||||
CheckMcpStatusResponseStatus::Pending => Ok(HttpResult::success(
|
||||
CheckMcpStatusResponseParams::new(false, status, None),
|
||||
None,
|
||||
)),
|
||||
CheckMcpStatusResponseStatus::Error(err) => Ok(HttpResult::success(
|
||||
CheckMcpStatusResponseParams::new(false, status, Some(err)),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
warn!("mcp_id: {mcp_id} does not exist");
|
||||
Ok(HttpResult::success(
|
||||
CheckMcpStatusResponseParams::new(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Error("mcp_id 不存在".to_string()),
|
||||
None,
|
||||
),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use axum::{extract::Path, response::IntoResponse};
|
||||
|
||||
use crate::{AppError, get_proxy_manager, model::HttpResult};
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
|
||||
// #[axum::debug_handler]
|
||||
pub async fn delete_route_handler(
|
||||
Path(mcp_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 删除动态路由,以及清理资源
|
||||
get_proxy_manager()
|
||||
.cleanup_resources(&mcp_id)
|
||||
.await
|
||||
.map_err(|e| AppError::mcp_server_error(e.to_string()))?;
|
||||
|
||||
// 返回成功信息
|
||||
let data = json!({
|
||||
"mcp_id": mcp_id,
|
||||
"message": format!("已删除路由: {}", mcp_id)
|
||||
});
|
||||
|
||||
Ok(HttpResult::success(data, None))
|
||||
}
|
||||
11
qiming-mcp-proxy/mcp-proxy/src/server/handlers/health.rs
Normal file
11
qiming-mcp-proxy/mcp-proxy/src/server/handlers/health.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use crate::AppError;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
///健康检查:health
|
||||
pub async fn get_health() -> Result<impl IntoResponse, AppError> {
|
||||
Ok("health".to_string())
|
||||
}
|
||||
|
||||
pub async fn get_ready() -> Result<impl IntoResponse, AppError> {
|
||||
Ok("ready".to_string())
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::{Json, extract::State, response::IntoResponse};
|
||||
use http::Uri;
|
||||
use log::{debug, error};
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppError;
|
||||
use crate::model::{
|
||||
AddRouteParams, HttpResult, McpConfig, McpProtocolPath, McpServerConfig, McpType,
|
||||
};
|
||||
use crate::model::{AppState, McpRouterPath};
|
||||
use crate::server::task::integrate_server_with_axum;
|
||||
use serde_json::json;
|
||||
|
||||
// 修改 add_route_handler 函数,使用新的集成方法
|
||||
#[instrument]
|
||||
// #[axum::debug_handler]
|
||||
pub async fn add_route_handler(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Json(params): Json<AddRouteParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 获取请求路径
|
||||
let request_path = uri.path().to_string();
|
||||
debug!("Request path: {}", request_path);
|
||||
debug!("Full URI: {:?}", uri);
|
||||
|
||||
let mcp_protocol = McpRouterPath::from_uri_prefix_protocol(&request_path);
|
||||
if let Some(mcp_protocol) = mcp_protocol {
|
||||
// 生成mcp_id, 使用uuid,去掉-
|
||||
let mcp_id = Uuid::now_v7().to_string().replace("-", "");
|
||||
|
||||
//根据 mcp_id 和协议,生成 mcp_router_path
|
||||
let mcp_router_path = McpRouterPath::new(mcp_id, mcp_protocol)
|
||||
.map_err(|e| AppError::mcp_server_error(e.to_string()))?;
|
||||
|
||||
let mcp_plugin_json = params.mcp_json_config;
|
||||
// 将mcp_plugin_json转换为 McpServerConfig 结构体
|
||||
let mcp_server_config =
|
||||
McpServerConfig::try_from(mcp_plugin_json.clone()).expect("解析 MCP 配置失败");
|
||||
|
||||
let mcp_type = params.mcp_type.unwrap_or(McpType::default());
|
||||
|
||||
debug!("Client protocol: {:?}", mcp_router_path.mcp_protocol);
|
||||
|
||||
// 构建完整的 McpConfig(用于自动重启)
|
||||
let full_mcp_config = McpConfig {
|
||||
mcp_id: mcp_router_path.mcp_id.clone(),
|
||||
client_protocol: mcp_router_path.mcp_protocol.clone(),
|
||||
mcp_type: mcp_type.clone(),
|
||||
mcp_json_config: Some(mcp_plugin_json),
|
||||
server_config: Some(mcp_server_config.clone()),
|
||||
};
|
||||
|
||||
// 使用新的集成方法,后端协议在函数内部解析
|
||||
let _ = integrate_server_with_axum(
|
||||
mcp_server_config.clone(),
|
||||
mcp_router_path.clone(),
|
||||
full_mcp_config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to start MCP service: {e}");
|
||||
AppError::mcp_server_error(e.to_string())
|
||||
})?;
|
||||
|
||||
//区分 mcp协议
|
||||
let mcp_protocol = mcp_router_path.mcp_protocol_path.clone();
|
||||
|
||||
let data = match mcp_protocol {
|
||||
McpProtocolPath::SsePath(sse_path) => {
|
||||
// 返回 mcp_id 和 sse_path
|
||||
let data = json!({
|
||||
"mcp_id": mcp_router_path.mcp_id,
|
||||
"sse_path": sse_path.sse_path,
|
||||
"message_path": sse_path.message_path,
|
||||
"mcp_type": mcp_type
|
||||
});
|
||||
data
|
||||
}
|
||||
|
||||
McpProtocolPath::StreamPath(stream_path) => {
|
||||
// 返回 mcp_id 和 stream_path
|
||||
let data = json!({
|
||||
"mcp_id": mcp_router_path.mcp_id,
|
||||
"stream_path": stream_path.stream_path,
|
||||
"mcp_type": mcp_type
|
||||
});
|
||||
data
|
||||
}
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(data, None))
|
||||
} else {
|
||||
//返回 bad request,400 错误
|
||||
return Err(AppError::invalid_parameter("无效的请求路径"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use anyhow::Result;
|
||||
use axum::{Json, extract::State, http::uri::Uri};
|
||||
use log::{debug, error, info};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
AppError, get_proxy_manager,
|
||||
model::{
|
||||
AppState, CheckMcpStatusRequestParams, CheckMcpStatusResponseParams,
|
||||
CheckMcpStatusResponseStatus, GLOBAL_RESTART_TRACKER, HttpResult, McpConfig, McpProtocol,
|
||||
McpRouterPath, McpServiceStatus, McpType,
|
||||
},
|
||||
server::mcp_start_task,
|
||||
};
|
||||
|
||||
/// 创建响应结果的辅助函数
|
||||
fn create_response(
|
||||
ready: bool,
|
||||
status: CheckMcpStatusResponseStatus,
|
||||
message: Option<String>,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
let response = CheckMcpStatusResponseParams::new(ready, status, message);
|
||||
|
||||
Ok(HttpResult::success(response, None))
|
||||
}
|
||||
|
||||
/// 检查mcp服务状态,根据 mcp_id 获取有无对应的mcp透明代理服务,如果没有则取 mcp_json_config 中的配置,生成mcp透明代理服务;
|
||||
/// 这里根据 mcp_json_config配置,启动服务需要异步,不要阻塞,如果服务没准备好,返回 PENDING 状态;
|
||||
/// 如果服务启动失败,返回 ERROR 状态;
|
||||
/// 如果服务启动成功,返回 READY 状态;
|
||||
#[instrument]
|
||||
pub async fn check_mcp_status_handler(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Json(params): Json<CheckMcpStatusRequestParams>,
|
||||
mcp_protocol: McpProtocol,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
// 使用全局 ProxyHandlerManager
|
||||
let proxy_manager = get_proxy_manager();
|
||||
|
||||
// 检查服务状态
|
||||
let status = proxy_manager
|
||||
.get_mcp_service_status(¶ms.mcp_id)
|
||||
.map(|mcp_service_status| mcp_service_status.check_mcp_status_response_status.clone());
|
||||
|
||||
if let Some(status) = status {
|
||||
match status {
|
||||
CheckMcpStatusResponseStatus::Error(error_msg) => {
|
||||
// 如果有错误状态,返回错误信息,另外删除掉 ERROR 的记录,方便下次检查状态,重新启动服务
|
||||
// Error 状态不更新 last_accessed,因为服务已失败
|
||||
if let Err(e) = proxy_manager.cleanup_resources(¶ms.mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", params.mcp_id, e);
|
||||
}
|
||||
// 返回错误信息
|
||||
return create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Error(error_msg),
|
||||
None,
|
||||
);
|
||||
}
|
||||
CheckMcpStatusResponseStatus::Pending => {
|
||||
// 如果状态是 Pending,说明服务正在启动中,直接返回 Pending
|
||||
// 不要再次尝试启动!
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} status is Pending and the service is starting",
|
||||
params.mcp_id
|
||||
);
|
||||
// 更新最后访问时间,避免启动过程中因超时被清理
|
||||
// 用户调用 check_status 表明对服务有兴趣,应该延长超时
|
||||
proxy_manager.update_last_accessed(¶ms.mcp_id);
|
||||
return create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
Some("服务正在启动中...".to_string()),
|
||||
);
|
||||
}
|
||||
CheckMcpStatusResponseStatus::Ready => {
|
||||
// 如果已经在运行,继续检查服务是否真的可用
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} status is Ready, check the backend health status",
|
||||
params.mcp_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 proxy_handler 是否存在
|
||||
let proxy_handler = proxy_manager.get_proxy_handler(¶ms.mcp_id);
|
||||
|
||||
if let Some(handler) = proxy_handler {
|
||||
// 调用透明代理的 is_mcp_server_ready 方法检查健康状态
|
||||
let ready_status = handler.is_mcp_server_ready().await;
|
||||
|
||||
// 使用 update 方法更新状态(不是修改克隆)
|
||||
if ready_status {
|
||||
proxy_manager
|
||||
.update_mcp_service_status(¶ms.mcp_id, CheckMcpStatusResponseStatus::Ready);
|
||||
}
|
||||
// 更新最后访问时间
|
||||
proxy_manager.update_last_accessed(¶ms.mcp_id);
|
||||
|
||||
let status = if ready_status {
|
||||
CheckMcpStatusResponseStatus::Ready
|
||||
} else {
|
||||
CheckMcpStatusResponseStatus::Pending
|
||||
};
|
||||
|
||||
return create_response(ready_status, status, None);
|
||||
}
|
||||
|
||||
// ===== 服务不存在,需要启动 =====
|
||||
// 使用启动锁防止并发启动同一服务
|
||||
let _startup_guard = match GLOBAL_RESTART_TRACKER.try_acquire_startup_lock(¶ms.mcp_id) {
|
||||
Some(guard) => {
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} Obtained the startup lock successfully and started to start the service",
|
||||
params.mcp_id
|
||||
);
|
||||
guard
|
||||
}
|
||||
None => {
|
||||
// 锁被占用,服务正在启动中
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} The startup lock is occupied and the service is starting. Return to Pending.",
|
||||
params.mcp_id
|
||||
);
|
||||
return create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
Some("服务正在启动中...".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 双重检查:获取锁后再次检查服务是否已存在
|
||||
if proxy_manager.get_proxy_handler(¶ms.mcp_id).is_some() {
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} Double check found that the service already exists, return Ready",
|
||||
params.mcp_id
|
||||
);
|
||||
return create_response(true, CheckMcpStatusResponseStatus::Ready, None);
|
||||
}
|
||||
|
||||
// 如果服务状态已存在(可能是 Pending),也不要重复启动
|
||||
if proxy_manager
|
||||
.get_mcp_service_status(¶ms.mcp_id)
|
||||
.is_some()
|
||||
{
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} The service status already exists and may be starting. Return to Pending.",
|
||||
params.mcp_id
|
||||
);
|
||||
return create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
Some("服务正在启动中...".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// 启动服务(持有锁的情况下)
|
||||
spawn_mcp_service(
|
||||
¶ms.mcp_id,
|
||||
params.mcp_json_config,
|
||||
params.mcp_type,
|
||||
mcp_protocol.clone(),
|
||||
)?;
|
||||
|
||||
// 返回 PENDING 状态,锁会在函数返回时自动释放
|
||||
create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
Some("服务正在启动中...".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
// SSE协议专用的状态检查处理函数
|
||||
#[instrument]
|
||||
// #[axum::debug_handler]
|
||||
pub async fn check_mcp_status_handler_sse(
|
||||
state: State<AppState>,
|
||||
uri: Uri,
|
||||
params: Json<CheckMcpStatusRequestParams>,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
check_mcp_status_handler(state, uri, params, McpProtocol::Sse).await
|
||||
}
|
||||
|
||||
// Stream协议专用的状态检查处理函数
|
||||
#[instrument]
|
||||
// #[axum::debug_handler]
|
||||
pub async fn check_mcp_status_handler_stream(
|
||||
state: State<AppState>,
|
||||
uri: Uri,
|
||||
params: Json<CheckMcpStatusRequestParams>,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
check_mcp_status_handler(state, uri, params, McpProtocol::Stream).await
|
||||
}
|
||||
|
||||
/// 异步启动MCP服务
|
||||
///
|
||||
/// 注意:此函数假设调用方已持有启动锁,不会重复检查!
|
||||
///
|
||||
/// # 参数
|
||||
/// - `mcp_id`: MCP服务的唯一标识
|
||||
/// - `mcp_json_config`: MCP服务的JSON配置
|
||||
/// - `mcp_type`: MCP服务类型(OneShot或Persistent)
|
||||
/// - `client_protocol`: 客户端使用的协议(决定暴露的API接口类型)
|
||||
fn spawn_mcp_service(
|
||||
mcp_id: &str,
|
||||
mcp_json_config: String,
|
||||
mcp_type: McpType,
|
||||
client_protocol: McpProtocol,
|
||||
) -> Result<(), AppError> {
|
||||
let mcp_id = mcp_id.to_string();
|
||||
info!(
|
||||
"[spawn_mcp_service] mcp_id={} Start starting the service",
|
||||
mcp_id
|
||||
);
|
||||
|
||||
// 使用全局 ProxyHandlerManager
|
||||
let proxy_manager = get_proxy_manager();
|
||||
|
||||
// 设置初始化状态 - 使用客户端协议创建路由路径
|
||||
let mcp_router_path = McpRouterPath::new(mcp_id.clone(), client_protocol.clone())
|
||||
.map_err(|e| AppError::mcp_server_error(e.to_string()))?;
|
||||
let mcp_service_status = McpServiceStatus::new(
|
||||
mcp_id.clone(),
|
||||
mcp_type.clone(),
|
||||
mcp_router_path,
|
||||
CancellationToken::new(),
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
);
|
||||
|
||||
// RAII: 如果已存在同名服务,会自动清理旧服务
|
||||
proxy_manager.add_mcp_service_status_and_proxy(mcp_service_status, None);
|
||||
|
||||
// 异步启动 mcp 透明代理服务
|
||||
let mcp_id_clone = mcp_id.clone();
|
||||
|
||||
// 使用客户端协议创建配置
|
||||
let mcp_config: McpConfig = McpConfig::new(
|
||||
mcp_id_clone.clone(),
|
||||
Some(mcp_json_config),
|
||||
mcp_type,
|
||||
client_protocol,
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
info!(
|
||||
"[spawn_mcp_service] mcp_id={} tokio::spawn starts executing mcp_start_task",
|
||||
mcp_id_clone
|
||||
);
|
||||
match mcp_start_task(mcp_config).await {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"[spawn_mcp_service] mcp_id={} mcp_start_task successful, set status to Ready",
|
||||
mcp_id_clone
|
||||
);
|
||||
get_proxy_manager()
|
||||
.update_mcp_service_status(&mcp_id_clone, CheckMcpStatusResponseStatus::Ready);
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("启动MCP服务失败: {e}");
|
||||
error!(
|
||||
"[spawn_mcp_service] mcp_id={} mcp_start_task failed: {}",
|
||||
mcp_id_clone, e
|
||||
);
|
||||
get_proxy_manager().update_mcp_service_status(
|
||||
&mcp_id_clone,
|
||||
CheckMcpStatusResponseStatus::Error(error_msg),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
info!(
|
||||
"[spawn_mcp_service] mcp_id={} Service startup task has been submitted",
|
||||
mcp_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
13
qiming-mcp-proxy/mcp-proxy/src/server/handlers/mod.rs
Normal file
13
qiming-mcp-proxy/mcp-proxy/src/server/handlers/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
mod check_mcp_is_status;
|
||||
mod delete_route_handler;
|
||||
mod health;
|
||||
mod mcp_add_handler;
|
||||
mod mcp_check_status_handler;
|
||||
pub mod run_code_handler;
|
||||
pub use check_mcp_is_status::check_mcp_is_status_handler;
|
||||
pub use delete_route_handler::delete_route_handler;
|
||||
pub use health::get_health;
|
||||
pub use health::get_ready;
|
||||
pub use mcp_add_handler::add_route_handler;
|
||||
pub use mcp_check_status_handler::{check_mcp_status_handler_sse, check_mcp_status_handler_stream};
|
||||
pub use run_code_handler::run_code_handler;
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{Json, response::IntoResponse};
|
||||
use http::StatusCode;
|
||||
use log::{debug, error, info};
|
||||
use run_code_rmcp::{CodeExecutor, LanguageScript, RunCodeHttpResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
///代码运行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunCodeMessageRequest {
|
||||
//js运行参数
|
||||
pub json_param: HashMap<String, Value>,
|
||||
//运行的代码
|
||||
pub code: String,
|
||||
//前端生成的随机uid,用于查找websocket连接,发送执行过程中的log日志
|
||||
pub uid: String,
|
||||
pub engine_type: String,
|
||||
}
|
||||
|
||||
impl RunCodeMessageRequest {
|
||||
//获取语言脚本
|
||||
pub fn get_language_script(&self) -> LanguageScript {
|
||||
match self.engine_type.as_str() {
|
||||
"js" => LanguageScript::Js,
|
||||
"ts" => LanguageScript::Ts,
|
||||
"python" => LanguageScript::Python,
|
||||
_ => LanguageScript::Js,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行js/ts/python代码,通过 uv/deno 命令方式执行
|
||||
// #[axum::debug_handler]
|
||||
pub async fn run_code_handler(
|
||||
Json(run_code_message_request): Json<RunCodeMessageRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
//json_param: HashMap<String, Value> 转换为 json 对象 Value
|
||||
let params = match serde_json::to_value(run_code_message_request.json_param.clone()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Run_code_handler parameter serialization failed: {e:?}");
|
||||
return Err(AppError::from(e));
|
||||
}
|
||||
};
|
||||
|
||||
//执行代码
|
||||
let code = run_code_message_request.code.clone();
|
||||
let language = run_code_message_request.get_language_script();
|
||||
|
||||
debug!("run_code_handler language:{language:?}");
|
||||
debug!("run_code_handler code:{code:?}");
|
||||
debug!("run_code_handler params:{params:?}");
|
||||
let result = match CodeExecutor::execute_with_params(&code, language, Some(params), None).await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("run_code_handler execution failed: {e:?}");
|
||||
return Err(AppError::from(e));
|
||||
}
|
||||
};
|
||||
|
||||
if !result.success {
|
||||
let error_message = result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("run_code_handler执行失败但未提供错误信息");
|
||||
error!("run_code_handler execution returns failure: {error_message}");
|
||||
}
|
||||
|
||||
let data = match serde_json::to_value(&result) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("run_code_handler serialization result failed: {e:?}");
|
||||
return Err(AppError::from(e));
|
||||
}
|
||||
};
|
||||
//打印结果
|
||||
info!("run_code_handler result:{:?}", &result.success);
|
||||
debug!("run_code_handler result:{:?}", &data);
|
||||
//返回结果,使用 RunCodeHttpResult 封装执行结果
|
||||
let run_code_http_result = RunCodeHttpResult {
|
||||
data,
|
||||
success: result.success,
|
||||
error: result.error.clone(),
|
||||
};
|
||||
let body = serde_json::to_string(&run_code_http_result)?;
|
||||
Ok((StatusCode::OK, body).into_response())
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Request,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use log::{debug, error, info, warn};
|
||||
use tower::Service;
|
||||
|
||||
use crate::{
|
||||
DynamicRouterService, get_proxy_manager, mcp_start_task,
|
||||
model::{
|
||||
CheckMcpStatusResponseStatus, GLOBAL_RESTART_TRACKER, HttpResult, McpConfig, McpRouterPath,
|
||||
McpType,
|
||||
},
|
||||
server::middlewares::extract_trace_id,
|
||||
};
|
||||
|
||||
impl Service<Request<Body>> for DynamicRouterService {
|
||||
type Response = Response;
|
||||
type Error = Infallible;
|
||||
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<Body>) -> Self::Future {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
let headers = req.headers().clone();
|
||||
|
||||
// DEBUG: 详细路径解析日志
|
||||
debug!("=== Path analysis begins ===");
|
||||
debug!("Original request path: {}", path);
|
||||
debug!("Path contains wildcard parameters: {:?}", req.extensions());
|
||||
|
||||
// 提取 trace_id
|
||||
let trace_id = extract_trace_id();
|
||||
|
||||
// 创建根 span (使用 debug_span 减少日志量)
|
||||
let span = tracing::debug_span!(
|
||||
"DynamicRouterService",
|
||||
otel.name = "HTTP Request",
|
||||
http.method = %method,
|
||||
http.route = %path,
|
||||
http.url = %req.uri(),
|
||||
mcp.protocol = format!("{:?}", self.0),
|
||||
trace_id = %trace_id,
|
||||
);
|
||||
|
||||
// 记录请求头信息
|
||||
if let Some(content_type) = headers.get("content-type") {
|
||||
span.record("http.request.content_type", format!("{:?}", content_type));
|
||||
}
|
||||
if let Some(content_length) = headers.get("content-length") {
|
||||
span.record(
|
||||
"http.request.content_length",
|
||||
format!("{:?}", content_length),
|
||||
);
|
||||
}
|
||||
|
||||
debug!("Request path: {path}");
|
||||
|
||||
// 解析路由路径
|
||||
let mcp_router_path = McpRouterPath::from_url(&path);
|
||||
|
||||
match mcp_router_path {
|
||||
Some(mcp_router_path) => {
|
||||
let mcp_id = mcp_router_path.mcp_id.clone();
|
||||
let base_path = mcp_router_path.base_path.clone();
|
||||
|
||||
span.record("mcp.id", &mcp_id);
|
||||
span.record("mcp.base_path", &base_path);
|
||||
|
||||
debug!("=== Path analysis results ===");
|
||||
debug!("Parsed mcp_id: {}", mcp_id);
|
||||
debug!("Parsed base_path: {}", base_path);
|
||||
debug!("Request path: {} vs base_path: {}", path, base_path);
|
||||
debug!("=== Path analysis ends ===");
|
||||
|
||||
Box::pin(async move {
|
||||
let _guard = span.enter();
|
||||
|
||||
// 先尝试查找已注册的路由
|
||||
debug!("===Route lookup process ===");
|
||||
debug!("Find base_path: '{}'", base_path);
|
||||
|
||||
if let Some(router_entry) = DynamicRouterService::get_route(&base_path) {
|
||||
debug!(
|
||||
"✅ Find the registered route: base_path={}, path={}",
|
||||
base_path, path
|
||||
);
|
||||
|
||||
// ===== 检查后端健康状态 =====
|
||||
let mcp_id_for_check = McpRouterPath::from_url(&path);
|
||||
if let Some(router_path) = mcp_id_for_check {
|
||||
let proxy_manager = get_proxy_manager();
|
||||
|
||||
// ===== 首先检查服务状态 =====
|
||||
// 如果服务状态是 Pending,说明服务正在初始化中(uvx/npx 下载中)
|
||||
// 此时不应该做健康检查,应该等待
|
||||
if let Some(service_status) =
|
||||
proxy_manager.get_mcp_service_status(&router_path.mcp_id)
|
||||
{
|
||||
match &service_status.check_mcp_status_response_status {
|
||||
CheckMcpStatusResponseStatus::Pending => {
|
||||
debug!(
|
||||
"[MCP status check] mcp_id={} The status is Pending, the service is being initialized, and 503 is returned.",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let message = format!(
|
||||
"服务 {} 正在初始化中,请稍后再试",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0003", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
CheckMcpStatusResponseStatus::Error(err) => {
|
||||
// Error 状态:只清理,不重启
|
||||
// 避免有问题的 MCP 服务无限重启循环
|
||||
warn!(
|
||||
"[MCP status check] mcp_id={} status is Error: {}, clean up resources and return error",
|
||||
router_path.mcp_id, err
|
||||
);
|
||||
// 清理资源
|
||||
if let Err(e) = proxy_manager
|
||||
.cleanup_resources(&router_path.mcp_id)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
"[MCP status check] mcp_id={} Failed to clean up resources: {}",
|
||||
router_path.mcp_id, e
|
||||
);
|
||||
}
|
||||
// 返回错误,不尝试重启
|
||||
let message = format!(
|
||||
"服务 {} 启动失败: {}",
|
||||
router_path.mcp_id, err
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0005", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
CheckMcpStatusResponseStatus::Ready => {
|
||||
debug!(
|
||||
"[MCP status check] mcp_id={} status is Ready, continue to check the backend health status",
|
||||
router_path.mcp_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 检查启动锁状态 =====
|
||||
// 如果锁被占用,说明服务正在启动中
|
||||
let startup_guard = GLOBAL_RESTART_TRACKER
|
||||
.try_acquire_startup_lock(&router_path.mcp_id);
|
||||
|
||||
if startup_guard.is_none() {
|
||||
// 锁被占用,服务正在启动中,返回 503
|
||||
debug!(
|
||||
"[Startup lock check] mcp_id={} The startup lock is occupied, the service is starting, and 503 is returned.",
|
||||
router_path.mcp_id
|
||||
);
|
||||
span.record("mcp.startup_in_progress", true);
|
||||
let message =
|
||||
format!("服务 {} 正在启动中,请稍后再试", router_path.mcp_id);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0003", &message, None);
|
||||
span.record("http.response.status_code", 503u16);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// 获取到锁,现在可以安全地检查健康状态
|
||||
let _startup_guard = startup_guard.unwrap();
|
||||
debug!(
|
||||
"[Start lock check] mcp_id={} Successfully obtained the startup lock and started health check",
|
||||
router_path.mcp_id
|
||||
);
|
||||
|
||||
if let Some(handler) =
|
||||
proxy_manager.get_proxy_handler(&router_path.mcp_id)
|
||||
{
|
||||
// ===== 健康检查(带缓存)=====
|
||||
let is_healthy = if let Some(cached) = GLOBAL_RESTART_TRACKER
|
||||
.get_cached_health_status(&router_path.mcp_id)
|
||||
{
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} Use cache status: is_healthy={}",
|
||||
router_path.mcp_id, cached
|
||||
);
|
||||
cached
|
||||
} else {
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} Cache miss, start actual health check...",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let status = handler.is_mcp_server_ready().await;
|
||||
GLOBAL_RESTART_TRACKER
|
||||
.update_health_status(&router_path.mcp_id, status);
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} Actual health check result: is_healthy={}",
|
||||
router_path.mcp_id, status
|
||||
);
|
||||
status
|
||||
};
|
||||
|
||||
if is_healthy {
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} The backend service is normal, release the lock and use routing",
|
||||
router_path.mcp_id
|
||||
);
|
||||
// 释放锁,使用路由
|
||||
drop(_startup_guard);
|
||||
debug!("=== Route search ended (successful) ===");
|
||||
return handle_request_with_router(req, router_entry, &path)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 不健康,获取服务类型以决定是否重启
|
||||
let mcp_type = proxy_manager
|
||||
.get_mcp_service_status(&router_path.mcp_id)
|
||||
.map(|s| s.mcp_type.clone());
|
||||
|
||||
// 清理资源
|
||||
warn!(
|
||||
"[Health check] mcp_id={} The backend service is unhealthy, clean up resources.",
|
||||
router_path.mcp_id
|
||||
);
|
||||
if let Err(e) =
|
||||
proxy_manager.cleanup_resources(&router_path.mcp_id).await
|
||||
{
|
||||
error!(
|
||||
"[Clean up resources] mcp_id={} Failed to clean up resources: error={}",
|
||||
router_path.mcp_id, e
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[Clean up resources] mcp_id={} Clean up resources successfully",
|
||||
router_path.mcp_id
|
||||
);
|
||||
}
|
||||
|
||||
// OneShot 类型:只清理,不重启
|
||||
// OneShot 服务执行完成后进程会退出,这是正常行为,不应该自动重启
|
||||
// 用户需要通过 check_status 接口显式启动新的 OneShot 服务
|
||||
if matches!(mcp_type, Some(McpType::OneShot)) {
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} is a OneShot type, does not automatically restart, and returns that the service has ended",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let message = format!(
|
||||
"OneShot 服务 {} 已结束,请重新启动",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0006", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// Persistent 类型:清理后重启
|
||||
info!(
|
||||
"[Restart process] mcp_id={} is Persistent type, start to restart the service",
|
||||
router_path.mcp_id
|
||||
);
|
||||
|
||||
// 从配置获取 mcp_config 并启动服务
|
||||
// 优先从请求 header 获取配置
|
||||
if let Some(mcp_config) =
|
||||
req.extensions().get::<McpConfig>().cloned()
|
||||
&& mcp_config.mcp_json_config.is_some()
|
||||
{
|
||||
info!(
|
||||
"[Restart process] mcp_id={} Use the request header to configure the restart service",
|
||||
mcp_config.mcp_id
|
||||
);
|
||||
proxy_manager
|
||||
.register_mcp_config(&mcp_config.mcp_id, mcp_config.clone())
|
||||
.await;
|
||||
return start_mcp_and_handle_request(req, mcp_config).await;
|
||||
}
|
||||
|
||||
// 从缓存获取配置
|
||||
if let Some(mcp_config) = proxy_manager
|
||||
.get_mcp_config_from_cache(&router_path.mcp_id)
|
||||
.await
|
||||
{
|
||||
info!(
|
||||
"[Restart process] mcp_id={} Restart the service using cache configuration",
|
||||
router_path.mcp_id
|
||||
);
|
||||
return start_mcp_and_handle_request(req, mcp_config).await;
|
||||
}
|
||||
|
||||
// 无法获取配置
|
||||
warn!(
|
||||
"[Restart Process] mcp_id={} Unable to obtain the configuration and unable to restart the service",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let message =
|
||||
format!("服务 {} 不健康且无法获取配置", router_path.mcp_id);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0004", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
} else {
|
||||
// handler 不存在,但路由存在
|
||||
// 检查服务类型,OneShot 不自动重启
|
||||
let mcp_type = proxy_manager
|
||||
.get_mcp_service_status(&router_path.mcp_id)
|
||||
.map(|s| s.mcp_type.clone());
|
||||
|
||||
if matches!(mcp_type, Some(McpType::OneShot)) {
|
||||
debug!(
|
||||
"[Service Check] mcp_id={} is OneShot type and the handler does not exist, so it will not restart automatically.",
|
||||
router_path.mcp_id
|
||||
);
|
||||
// 清理残留状态
|
||||
if let Err(e) =
|
||||
proxy_manager.cleanup_resources(&router_path.mcp_id).await
|
||||
{
|
||||
error!(
|
||||
"[Clean up resources] mcp_id={} Failed to clean up resources: {}",
|
||||
router_path.mcp_id, e
|
||||
);
|
||||
}
|
||||
let message = format!(
|
||||
"OneShot 服务 {} 已结束,请重新启动",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0006", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// Persistent 类型:继续进入启动流程
|
||||
warn!(
|
||||
"The route exists but the handler does not exist. Enter the restart process: base_path={}",
|
||||
base_path
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 无法解析路由路径,直接使用路由
|
||||
debug!("=== Route search ended (successful) ===");
|
||||
return handle_request_with_router(req, router_entry, &path).await;
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"❌ No registered route found: base_path='{}', path='{}'",
|
||||
base_path, path
|
||||
);
|
||||
|
||||
// 显示所有已注册的路由
|
||||
let all_routes = DynamicRouterService::get_all_routes();
|
||||
debug!("Currently registered route: {:?}", all_routes);
|
||||
debug!("=== Route search ended (failed) ===");
|
||||
}
|
||||
|
||||
// 未找到路由,尝试启动服务
|
||||
warn!(
|
||||
"No matching path found, try to start the service: base_path={base_path}, path={path}"
|
||||
);
|
||||
span.record("error.route_not_found", true);
|
||||
|
||||
// ===== 提前解析 mcp_id 用于配置获取 =====
|
||||
let mcp_router_path_for_config = McpRouterPath::from_url(&path);
|
||||
|
||||
// ===== 配置获取优先级 =====
|
||||
let proxy_manager = get_proxy_manager();
|
||||
|
||||
// 优先级 1: 从请求 header 中获取配置(最新)
|
||||
if let Some(mcp_config) = req.extensions().get::<McpConfig>().cloned()
|
||||
&& mcp_config.mcp_json_config.is_some()
|
||||
{
|
||||
// 检查重启限制(防止无限循环)
|
||||
if !GLOBAL_RESTART_TRACKER.can_restart(&mcp_config.mcp_id) {
|
||||
warn!(
|
||||
"Service {} skips startup during restart cooldown period",
|
||||
mcp_config.mcp_id
|
||||
);
|
||||
span.record("error.restart_in_cooldown", true);
|
||||
let message =
|
||||
format!("服务 {} 在重启冷却期内,请稍后再试", mcp_config.mcp_id);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0002", &message, None);
|
||||
span.record("http.response.status_code", 429u16); // Too Many Requests
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// 尝试获取启动锁,防止并发启动同一服务
|
||||
let _startup_guard = match GLOBAL_RESTART_TRACKER
|
||||
.try_acquire_startup_lock(&mcp_config.mcp_id)
|
||||
{
|
||||
Some(guard) => guard,
|
||||
None => {
|
||||
warn!(
|
||||
"Service {} is starting, skip this startup",
|
||||
mcp_config.mcp_id
|
||||
);
|
||||
span.record("error.startup_in_progress", true);
|
||||
let message =
|
||||
format!("服务 {} 正在启动中,请稍后再试", mcp_config.mcp_id);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0003", &message, None);
|
||||
span.record("http.response.status_code", 503u16); // Service Unavailable
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Use request header configuration to start the service: {}",
|
||||
mcp_config.mcp_id
|
||||
);
|
||||
// 同时更新缓存
|
||||
proxy_manager
|
||||
.register_mcp_config(&mcp_config.mcp_id, mcp_config.clone())
|
||||
.await;
|
||||
|
||||
// _startup_guard 会在作用域结束时自动释放
|
||||
return start_mcp_and_handle_request(req, mcp_config).await;
|
||||
}
|
||||
|
||||
// 优先级 2: 从 moka 缓存中获取配置(兜底)
|
||||
// 注意:OneShot 类型不从缓存自动启动,需要用户显式请求(带 header 配置)
|
||||
if let Some(mcp_id_for_cache) =
|
||||
mcp_router_path_for_config.as_ref().map(|p| &p.mcp_id)
|
||||
&& let Some(mcp_config) = proxy_manager
|
||||
.get_mcp_config_from_cache(mcp_id_for_cache)
|
||||
.await
|
||||
{
|
||||
// OneShot 类型不从缓存自动启动
|
||||
// 避免已回收的 OneShot 服务被意外重启
|
||||
if matches!(mcp_config.mcp_type, McpType::OneShot) {
|
||||
info!(
|
||||
"[Startup check] mcp_id={} is a OneShot type, does not automatically start from the cache, and requires an explicit request from the user",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
let message = format!(
|
||||
"OneShot 服务 {} 需要通过 check_status 接口启动",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0007", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// 检查重启限制(防止无限循环)
|
||||
if !GLOBAL_RESTART_TRACKER.can_restart(mcp_id_for_cache) {
|
||||
warn!(
|
||||
"Service {} skips startup during restart cooldown period",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
span.record("error.restart_in_cooldown", true);
|
||||
let message =
|
||||
format!("服务 {} 在重启冷却期内,请稍后再试", mcp_id_for_cache);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0002", &message, None);
|
||||
span.record("http.response.status_code", 429u16); // Too Many Requests
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// 尝试获取启动锁,防止并发启动同一服务
|
||||
let _startup_guard = match GLOBAL_RESTART_TRACKER
|
||||
.try_acquire_startup_lock(mcp_id_for_cache)
|
||||
{
|
||||
Some(guard) => guard,
|
||||
None => {
|
||||
warn!(
|
||||
"Service {} is starting, skip this startup",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
span.record("error.startup_in_progress", true);
|
||||
let message =
|
||||
format!("服务 {} 正在启动中,请稍后再试", mcp_id_for_cache);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0003", &message, None);
|
||||
span.record("http.response.status_code", 503u16); // Service Unavailable
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Start the service using cached configuration: {}",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
// _startup_guard 会在作用域结束时自动释放
|
||||
return start_mcp_and_handle_request(req, mcp_config).await;
|
||||
}
|
||||
|
||||
// 优先级 3: 无法获取配置,返回错误
|
||||
warn!(
|
||||
"No matching path was found, and the configuration was not obtained, so the MCP service could not be started: {path}"
|
||||
);
|
||||
span.record("error.mcp_config_missing", true);
|
||||
|
||||
let message =
|
||||
format!("未找到匹配的路径,且未获取到配置,无法启动MCP服务: {path}");
|
||||
let http_result: HttpResult<String> = HttpResult::error("0001", &message, None);
|
||||
span.record("http.response.status_code", 404u16);
|
||||
span.record("error.message", &message);
|
||||
Ok(http_result.into_response())
|
||||
})
|
||||
}
|
||||
None => {
|
||||
warn!("Request path resolution failed: {path}");
|
||||
span.record("error.path_parse_failed", true);
|
||||
|
||||
let message = format!("请求路径解析失败: {path}");
|
||||
let http_result: HttpResult<String> = HttpResult::error("0001", &message, None);
|
||||
Box::pin(async move {
|
||||
let _guard = span.enter();
|
||||
span.record("http.response.status_code", 400u16);
|
||||
span.record("error.message", &message);
|
||||
Ok(http_result.into_response())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用给定的路由处理请求
|
||||
async fn handle_request_with_router(
|
||||
req: Request<Body>,
|
||||
router_entry: axum::Router,
|
||||
path: &str,
|
||||
) -> Result<Response, Infallible> {
|
||||
// 获取匹配路径的Router,并处理请求
|
||||
let trace_id = extract_trace_id();
|
||||
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
|
||||
info!(
|
||||
"[handle_request_with_router] Handle request: {} {}",
|
||||
method, path
|
||||
);
|
||||
|
||||
// 记录请求头中的关键信息
|
||||
if let Some(content_type) = req.headers().get("content-type")
|
||||
&& let Ok(content_type_str) = content_type.to_str()
|
||||
{
|
||||
debug!(
|
||||
"[handle_request_with_router] Content-Type: {}",
|
||||
content_type_str
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(content_length) = req.headers().get("content-length")
|
||||
&& let Ok(content_length_str) = content_length.to_str()
|
||||
{
|
||||
debug!(
|
||||
"[handle_request_with_router] Content-Length: {}",
|
||||
content_length_str
|
||||
);
|
||||
}
|
||||
|
||||
// 记录 x-mcp-json 头信息(如果存在)
|
||||
if let Some(mcp_json) = req.headers().get("x-mcp-json")
|
||||
&& let Ok(mcp_json_str) = mcp_json.to_str()
|
||||
{
|
||||
debug!(
|
||||
"[handle_request_with_router] MCP-JSON Header: {}",
|
||||
mcp_json_str
|
||||
);
|
||||
}
|
||||
|
||||
// 记录查询参数
|
||||
if let Some(query) = uri.query() {
|
||||
debug!("[handle_request_with_router] Query: {}", query);
|
||||
}
|
||||
|
||||
// 使用 debug_span 减少日志量,因为 DynamicRouterService 已经记录了请求信息
|
||||
// 移除 #[tracing::instrument] 避免 span 嵌套导致的日志膨胀问题
|
||||
let span = tracing::debug_span!(
|
||||
"handle_request_with_router",
|
||||
otel.name = "Handle Request with Router",
|
||||
component = "router",
|
||||
trace_id = %trace_id,
|
||||
http.method = %method,
|
||||
http.path = %path,
|
||||
);
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
let mut service = router_entry.into_service();
|
||||
match service.call(req).await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
|
||||
// 记录响应头信息
|
||||
debug!(
|
||||
"[handle_request_with_router]Response status: {}, response header: {response:?}",
|
||||
status
|
||||
);
|
||||
|
||||
span.record("http.response.status_code", status.as_u16());
|
||||
Ok(response)
|
||||
}
|
||||
Err(error) => {
|
||||
span.record("error.router_service_error", true);
|
||||
span.record("error.message", format!("{:?}", error));
|
||||
error!("[handle_request_with_router] error: {error:?}");
|
||||
Ok(axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动MCP服务并处理请求
|
||||
async fn start_mcp_and_handle_request(
|
||||
req: Request<Body>,
|
||||
mcp_config: McpConfig,
|
||||
) -> Result<Response, Infallible> {
|
||||
let request_path = req.uri().path().to_string();
|
||||
let trace_id = extract_trace_id();
|
||||
debug!("Request path: {request_path}");
|
||||
|
||||
// 使用 debug_span 减少日志量,移除 #[tracing::instrument] 避免 span 嵌套
|
||||
let span = tracing::debug_span!(
|
||||
"start_mcp_and_handle_request",
|
||||
otel.name = "Start MCP and Handle Request",
|
||||
component = "mcp_startup",
|
||||
mcp.id = %mcp_config.mcp_id,
|
||||
mcp.type = ?mcp_config.mcp_type,
|
||||
mcp.config.has_config = mcp_config.mcp_json_config.is_some(),
|
||||
trace_id = %trace_id,
|
||||
);
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
let ret = mcp_start_task(mcp_config).await;
|
||||
|
||||
if let Ok((router, _)) = ret {
|
||||
span.record("mcp.startup.success", true);
|
||||
handle_request_with_router(req, router, &request_path).await
|
||||
} else {
|
||||
span.record("mcp.startup.failed", true);
|
||||
span.record("error.mcp_startup_failed", true);
|
||||
span.record("error.message", format!("{:?}", ret));
|
||||
warn!("MCP service startup failed: {ret:?}");
|
||||
Ok(axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
}
|
||||
177
qiming-mcp-proxy/mcp-proxy/src/server/middlewares/auth.rs
Normal file
177
qiming-mcp-proxy/mcp-proxy/src/server/middlewares/auth.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
// use super::TokenVerify;
|
||||
// use axum::{
|
||||
// extract::{FromRequestParts, Query, Request, State},
|
||||
// http::{request::Parts, StatusCode},
|
||||
// middleware::Next,
|
||||
// response::{IntoResponse, Response},
|
||||
// };
|
||||
// use axum_extra::{
|
||||
// headers::{authorization::Bearer, Authorization},
|
||||
// TypedHeader,
|
||||
// };
|
||||
// use serde::Deserialize;
|
||||
// use tracing::warn;
|
||||
//
|
||||
// #[derive(Debug, Deserialize)]
|
||||
// struct Params {
|
||||
// token: String,
|
||||
// }
|
||||
//
|
||||
// pub async fn verify_token<T>(State(state): State<T>, req: Request, next: Next) -> Response
|
||||
// where
|
||||
// T: TokenVerify + Clone + Send + Sync + 'static,
|
||||
// {
|
||||
// let (mut parts, body) = req.into_parts();
|
||||
// match extract_token(&state, &mut parts).await {
|
||||
// Ok(token) => {
|
||||
// let mut req = Request::from_parts(parts, body);
|
||||
// match set_user(&state, &token, &mut req) {
|
||||
// Ok(_) => next.run(req).await,
|
||||
// Err(msg) => (StatusCode::FORBIDDEN, msg).into_response(),
|
||||
// }
|
||||
// }
|
||||
// Err(msg) => (StatusCode::UNAUTHORIZED, msg).into_response(),
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// pub async fn extract_user<T>(State(state): State<T>, req: Request, next: Next) -> Response
|
||||
// where
|
||||
// T: TokenVerify + Clone + Send + Sync + 'static,
|
||||
// {
|
||||
// let (mut parts, body) = req.into_parts();
|
||||
// let req = if let Ok(token) = extract_token(&state, &mut parts).await {
|
||||
// let mut req = Request::from_parts(parts, body);
|
||||
// let _ = set_user(&state, &token, &mut req);
|
||||
// req
|
||||
// } else {
|
||||
// Request::from_parts(parts, body)
|
||||
// };
|
||||
//
|
||||
// next.run(req).await
|
||||
// }
|
||||
//
|
||||
// async fn extract_token<T>(state: &T, parts: &mut Parts) -> Result<String, String>
|
||||
// where
|
||||
// T: TokenVerify + Clone + Send + Sync + 'static,
|
||||
// {
|
||||
// match TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state).await {
|
||||
// Ok(TypedHeader(Authorization(bearer))) => Ok(bearer.token().to_string()),
|
||||
// Err(e) => {
|
||||
// if e.is_missing() {
|
||||
// match Query::<Params>::from_request_parts(parts, &state).await {
|
||||
// Ok(params) => Ok(params.token.clone()),
|
||||
// Err(e) => {
|
||||
// let msg = format!("parse query params failed: {}", e);
|
||||
// warn!(msg);
|
||||
// Err(msg)
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// let msg = format!("parse Authorization header failed: {}", e);
|
||||
// warn!(msg);
|
||||
// Err(msg)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fn set_user<T>(state: &T, token: &str, req: &mut Request) -> Result<(), String>
|
||||
// where
|
||||
// T: TokenVerify + Clone + Send + Sync + 'static,
|
||||
// {
|
||||
// match state.verify(token) {
|
||||
// Ok(user) => {
|
||||
// req.extensions_mut().insert(user);
|
||||
// Ok(())
|
||||
// }
|
||||
// Err(e) => {
|
||||
// let msg = format!("verify token failed: {:?}", e);
|
||||
// warn!(msg);
|
||||
// Err(msg)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// use super::*;
|
||||
// use crate::{DecodingKey, EncodingKey, User};
|
||||
// use anyhow::Result;
|
||||
// use axum::{body::Body, middleware::from_fn_with_state, routing::get, Router};
|
||||
// use std::sync::Arc;
|
||||
// use tower::ServiceExt;
|
||||
//
|
||||
// #[derive(Clone)]
|
||||
// struct AppState(Arc<AppStateInner>);
|
||||
//
|
||||
// struct AppStateInner {
|
||||
// ek: EncodingKey,
|
||||
// dk: DecodingKey,
|
||||
// }
|
||||
//
|
||||
// impl TokenVerify for AppState {
|
||||
// type Error = ();
|
||||
//
|
||||
// fn verify(&self, token: &str) -> Result<User, Self::Error> {
|
||||
// self.0.dk.verify(token).map_err(|_| ())
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// async fn handler(_req: Request) -> impl IntoResponse {
|
||||
// (StatusCode::OK, "ok")
|
||||
// }
|
||||
//
|
||||
// #[tokio::test]
|
||||
// async fn verify_token_middleware_should_work() -> Result<()> {
|
||||
// let encoding_pem = include_str!("../../fixtures/encoding.pem");
|
||||
// let decoding_pem = include_str!("../../fixtures/decoding.pem");
|
||||
// // let ek = EncodingKey::load(encoding_pem)?;
|
||||
// // let dk = DecodingKey::load(decoding_pem)?;
|
||||
// let state = AppState(Arc::new(AppStateInner { ek, dk }));
|
||||
//
|
||||
// let user = User::new(1, "soddy", "soddygo@acme.org");
|
||||
// let token = state.0.ek.sign(user)?;
|
||||
//
|
||||
// let app = Router::new()
|
||||
// .route("/", get(handler))
|
||||
// .layer(from_fn_with_state(state.clone(), verify_token::<AppState>))
|
||||
// .with_state(state);
|
||||
//
|
||||
// // good token
|
||||
// let req = Request::builder()
|
||||
// .uri("/")
|
||||
// .header("Authorization", format!("Bearer {}", token))
|
||||
// .body(Body::empty())?;
|
||||
// let res = app.clone().oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::OK);
|
||||
//
|
||||
// // good token in query params
|
||||
// let req = Request::builder()
|
||||
// .uri(format!("/?token={}", token))
|
||||
// .body(Body::empty())?;
|
||||
// let res = app.clone().oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::OK);
|
||||
//
|
||||
// // no token
|
||||
// let req = Request::builder().uri("/").body(Body::empty())?;
|
||||
// let res = app.clone().oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
//
|
||||
// // bad token
|
||||
// let req = Request::builder()
|
||||
// .uri("/")
|
||||
// .header("Authorization", "Bearer bad-token")
|
||||
// .body(Body::empty())?;
|
||||
// let res = app.clone().oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||||
//
|
||||
// // bad token in query params
|
||||
// let req = Request::builder()
|
||||
// .uri("/?token=bad-token")
|
||||
// .body(Body::empty())?;
|
||||
// let res = app.oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||||
//
|
||||
// Ok(())
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,147 @@
|
||||
//! HTTP request/response logging middleware
|
||||
//!
|
||||
//! This middleware logs all HTTP requests and responses at TRACE level.
|
||||
//! Using TRACE level avoids excessive log output for frequent MCP API calls.
|
||||
//!
|
||||
//! To enable HTTP logging, set:
|
||||
//! - `RUST_LOG=trace` (global)
|
||||
//! - `RUST_LOG=mcp_proxy=trace` (module-specific)
|
||||
|
||||
use axum::{
|
||||
extract::{MatchedPath, Request},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use tracing::trace;
|
||||
|
||||
/// HTTP request/response logging middleware
|
||||
///
|
||||
/// Logs incoming requests with details (method, uri, route, headers)
|
||||
/// and outgoing responses (status, duration, content length).
|
||||
///
|
||||
/// # Log Level
|
||||
/// Uses TRACE level because MCP API calls are very frequent.
|
||||
/// Enable with `RUST_LOG=trace` or `RUST_LOG=mcp_proxy=trace`.
|
||||
///
|
||||
/// # Middleware Order
|
||||
/// Should be placed before `opentelemetry_tracing_middleware` to log
|
||||
/// request entry first, while avoiding duplication of completion logs.
|
||||
pub async fn http_logging_middleware(request: Request, next: Next) -> Response {
|
||||
// Extract all needed data before moving the request
|
||||
let method = request.method().clone();
|
||||
let uri = request.uri().clone();
|
||||
let version = request.version();
|
||||
|
||||
// Get matched route path (extract to owned String to avoid borrow)
|
||||
let route = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(|path| path.as_str().to_owned())
|
||||
.unwrap_or_else(|| "<unknown>".to_owned());
|
||||
|
||||
// Get request headers of interest (extract to owned Strings to avoid borrow)
|
||||
let headers = request.headers();
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>")
|
||||
.to_owned();
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>")
|
||||
.to_owned();
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>")
|
||||
.to_owned();
|
||||
|
||||
// Log incoming request at TRACE level (for frequent MCP API calls)
|
||||
trace!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
route = %route,
|
||||
version = ?version,
|
||||
content_type = %content_type,
|
||||
content_length = %content_length,
|
||||
user_agent = %user_agent,
|
||||
"HTTP request received"
|
||||
);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let response = next.run(request).await;
|
||||
let duration = start.elapsed();
|
||||
|
||||
// Get response details
|
||||
let status = response.status();
|
||||
let response_content_length = response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>");
|
||||
|
||||
// Log response at TRACE level
|
||||
trace!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
route = %route,
|
||||
status = %status,
|
||||
duration_ms = %duration.as_millis(),
|
||||
response_content_length = %response_content_length,
|
||||
"HTTP response sent"
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Router, body::Body, http::StatusCode, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_logging_middleware() {
|
||||
// Create a test handler
|
||||
async fn test_handler() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/test", get(test_handler))
|
||||
.layer(axum::middleware::from_fn(http_logging_middleware));
|
||||
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_logging_with_headers() {
|
||||
async fn test_handler() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/test", get(test_handler))
|
||||
.layer(axum::middleware::from_fn(http_logging_middleware));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/test")
|
||||
.header("content-type", "application/json")
|
||||
.header("user-agent", "test-agent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::model::{McpConfig, McpRouterPath, McpType};
|
||||
|
||||
/// 提取mcp的json配置,从请求的header上,可能没有,也可能有
|
||||
pub(crate) async fn mcp_json_config_extract(
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let path = req.uri().path().to_string();
|
||||
debug!("Request path: {path}");
|
||||
//检查请求路径,是否 /mcp 开头
|
||||
let check_mcp_path = McpRouterPath::check_mcp_path(&path);
|
||||
if check_mcp_path {
|
||||
//请求路径,可能是: /mcp/{mcp_id}/sse,或者 /mcp/{mcp_id}/message
|
||||
let mcp_router_path = McpRouterPath::from_url(&path);
|
||||
if let Some(mcp_router_path) = mcp_router_path {
|
||||
let mcp_id = mcp_router_path.mcp_id.clone();
|
||||
// 解析header中的 x-mcp-json 字段,这个对应的是 mcp 的json配置
|
||||
// 现在这个字段是base64编码过的,需要先解码
|
||||
let mcp_json_config = req
|
||||
.headers()
|
||||
.get("x-mcp-json")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|encoded| {
|
||||
// 将 base64 编码的值解码为原始 JSON 字符串
|
||||
let decoded = BASE64
|
||||
.decode(encoded)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok());
|
||||
debug!("Parsed MCP configuration, x-mcp-json={:?}", &decoded);
|
||||
|
||||
decoded
|
||||
});
|
||||
|
||||
// 解析header中的 x-mcp-type 字段,这个对应的是 mcp 的类型
|
||||
let mcp_type = req
|
||||
.headers()
|
||||
.get("x-mcp-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|s| McpType::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mcp_protocol = mcp_router_path.mcp_protocol.clone();
|
||||
let mcp_config = McpConfig::new(mcp_id, mcp_json_config, mcp_type, mcp_protocol);
|
||||
|
||||
req.extensions_mut().insert(mcp_config);
|
||||
}
|
||||
}
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use axum::extract::Request;
|
||||
use log::debug;
|
||||
use tower::{Layer, Service};
|
||||
|
||||
use crate::{
|
||||
get_proxy_manager,
|
||||
model::{AppState, McpRouterPath},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MySseRouterLayer {
|
||||
state: AppState,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub struct MySseRouterService<S> {
|
||||
inner: S,
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
impl MySseRouterLayer {
|
||||
pub fn new(state: AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for MySseRouterLayer {
|
||||
type Service = MySseRouterService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
MySseRouterService {
|
||||
inner,
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Service<Request<B>> for MySseRouterService<S>
|
||||
where
|
||||
S: Service<Request<B>>,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = S::Future;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<B>) -> Self::Future {
|
||||
let path = req.uri().path().to_string();
|
||||
|
||||
// ===== 复用现有逻辑:检查是否为 MCP 请求 =====
|
||||
let check_mcp_path = McpRouterPath::check_mcp_path(&path);
|
||||
if check_mcp_path && let Some(mcp_router_path) = McpRouterPath::from_url(&path) {
|
||||
let mcp_id = mcp_router_path.mcp_id.clone();
|
||||
|
||||
// ===== 更新最后访问时间 =====
|
||||
debug!("Update last access time, request access MCP ID: {}", mcp_id);
|
||||
get_proxy_manager().update_last_accessed(&mcp_id);
|
||||
}
|
||||
|
||||
self.inner.call(req)
|
||||
}
|
||||
}
|
||||
47
qiming-mcp-proxy/mcp-proxy/src/server/middlewares/mod.rs
Normal file
47
qiming-mcp-proxy/mcp-proxy/src/server/middlewares/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
mod auth;
|
||||
mod http_logging;
|
||||
mod mcp_router_json;
|
||||
mod mcp_update_latest_layer;
|
||||
mod opentelemetry_middleware;
|
||||
mod server_time;
|
||||
|
||||
use crate::model::AppState;
|
||||
use axum::Router;
|
||||
use axum::middleware::from_fn;
|
||||
use http_logging::http_logging_middleware;
|
||||
use mcp_router_json::mcp_json_config_extract;
|
||||
use opentelemetry_middleware::opentelemetry_tracing_middleware;
|
||||
use server_time::ServerTimeLayer;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
pub use mcp_update_latest_layer::MySseRouterLayer;
|
||||
pub use opentelemetry_middleware::extract_trace_id;
|
||||
|
||||
// pub use auth::{extract_user, verify_token};
|
||||
|
||||
// pub trait TokenVerify {
|
||||
// type Error: fmt::Debug;
|
||||
// fn verify(&self, token: &str) -> Result<User, Self::Error>;
|
||||
// }
|
||||
|
||||
const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
const SERVER_TIME_HEADER: &str = "x-server-time";
|
||||
|
||||
pub fn set_layer(app: Router, state: AppState) -> Router {
|
||||
app.layer(
|
||||
ServiceBuilder::new()
|
||||
// HTTP 请求/响应日志中间件 (TRACE level, 用于调试频繁的 MCP API 调用)
|
||||
.layer(from_fn(http_logging_middleware))
|
||||
// OpenTelemetry 追踪中间件 - 自动生成 trace_id 和 span
|
||||
.layer(from_fn(opentelemetry_tracing_middleware))
|
||||
// MCP 配置提取中间件
|
||||
.layer(from_fn(mcp_json_config_extract))
|
||||
// HTTP 压缩
|
||||
.layer(CompressionLayer::new().gzip(true).br(true).deflate(true))
|
||||
// 服务器时间响应头
|
||||
.layer(ServerTimeLayer)
|
||||
// SSE 路由层
|
||||
.layer(MySseRouterLayer::new(state.clone())),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use axum::{
|
||||
extract::{MatchedPath, Request},
|
||||
http::{HeaderMap, HeaderValue},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use opentelemetry::{Context, trace::TraceContextExt};
|
||||
use std::time::Instant;
|
||||
use tracing::{Instrument, debug_span};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
use super::{REQUEST_ID_HEADER, SERVER_TIME_HEADER};
|
||||
|
||||
/// OpenTelemetry 追踪中间件
|
||||
///
|
||||
/// 功能:
|
||||
/// 1. 自动创建 OpenTelemetry span 和 trace
|
||||
/// 2. 在响应头中添加 x-request-id (trace_id)
|
||||
/// 3. 在响应头中添加 x-server-time (请求处理时间)
|
||||
/// 4. 记录 HTTP 请求的语义化属性
|
||||
pub async fn opentelemetry_tracing_middleware(request: Request, next: Next) -> Response {
|
||||
let start_time = Instant::now();
|
||||
|
||||
// 提取请求信息
|
||||
let method = request.method().to_string();
|
||||
let uri = request.uri().to_string();
|
||||
let route = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(|matched_path| matched_path.as_str().to_owned())
|
||||
.unwrap_or_else(|| request.uri().path().to_owned());
|
||||
let version = format!("{:?}", request.version());
|
||||
let user_agent = request
|
||||
.headers()
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// 创建 OpenTelemetry span
|
||||
let span = debug_span!(
|
||||
"http_request",
|
||||
otel.name = format!("{} {}", method, route).as_str(),
|
||||
otel.kind = "server",
|
||||
http.method = method.as_str(),
|
||||
http.url = uri.as_str(),
|
||||
http.route = route.as_str(),
|
||||
http.scheme = "http",
|
||||
http.version = version.as_str(),
|
||||
http.user_agent = user_agent,
|
||||
);
|
||||
|
||||
// 设置 OpenTelemetry 属性
|
||||
let otel_cx = Context::current();
|
||||
if let Err(error) = span.set_parent(otel_cx) {
|
||||
tracing::warn!(target: "telemetry", %method, %uri, ?error, "failed to attach OpenTelemetry parent context");
|
||||
}
|
||||
|
||||
// 获取 trace_id
|
||||
let trace_id = span.context().span().span_context().trace_id().to_string();
|
||||
|
||||
// 如果 trace_id 全为0,生成一个随机的 trace_id
|
||||
let trace_id = if trace_id == "00000000000000000000000000000000" {
|
||||
use uuid::Uuid;
|
||||
Uuid::new_v4().simple().to_string()
|
||||
} else {
|
||||
trace_id
|
||||
};
|
||||
|
||||
async move {
|
||||
// 执行请求处理
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
// 计算处理时间
|
||||
let duration = start_time.elapsed();
|
||||
let duration_micros = duration.as_micros();
|
||||
|
||||
// 记录响应状态码
|
||||
let status_code = response.status().as_u16();
|
||||
|
||||
// 添加响应头
|
||||
let headers = response.headers_mut();
|
||||
|
||||
// 添加 trace_id 到响应头
|
||||
if let Ok(trace_header) = HeaderValue::from_str(&trace_id) {
|
||||
headers.insert(REQUEST_ID_HEADER, trace_header);
|
||||
}
|
||||
|
||||
// 添加服务器处理时间到响应头 (微秒)
|
||||
if let Ok(time_header) = HeaderValue::from_str(&duration_micros.to_string()) {
|
||||
headers.insert(SERVER_TIME_HEADER, time_header);
|
||||
}
|
||||
|
||||
// 记录请求完成日志(改为 debug 级别,减少日志量)
|
||||
tracing::debug!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
status = %status_code,
|
||||
duration_micros = %duration_micros,
|
||||
trace_id = %trace_id,
|
||||
"HTTP request completed"
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 从当前 OpenTelemetry 上下文中提取 trace_id
|
||||
///
|
||||
/// 这个函数可以在任何地方调用来获取当前请求的 trace_id
|
||||
pub fn extract_trace_id() -> String {
|
||||
let current_span = tracing::Span::current();
|
||||
let context = current_span.context();
|
||||
let span_ref = context.span();
|
||||
let span_context = span_ref.span_context();
|
||||
|
||||
let trace_id = if span_context.is_valid() {
|
||||
span_context.trace_id().to_string()
|
||||
} else {
|
||||
"00000000000000000000000000000000".to_string()
|
||||
};
|
||||
|
||||
// 如果 trace_id 全为0,生成一个随机的 trace_id
|
||||
if trace_id == "00000000000000000000000000000000" {
|
||||
use uuid::Uuid;
|
||||
Uuid::new_v4().simple().to_string()
|
||||
} else {
|
||||
trace_id
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求头中提取现有的 trace_id(如果有的话)
|
||||
///
|
||||
/// 支持标准的 OpenTelemetry 传播头:
|
||||
/// - traceparent (W3C Trace Context)
|
||||
/// - x-trace-id (自定义)
|
||||
#[allow(dead_code)]
|
||||
pub fn extract_trace_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
// 尝试从 W3C Trace Context 中提取
|
||||
if let Some(traceparent) = headers.get("traceparent")
|
||||
&& let Ok(traceparent_str) = traceparent.to_str()
|
||||
{
|
||||
// traceparent 格式: 00-{trace_id}-{span_id}-{flags}
|
||||
let parts: Vec<&str> = traceparent_str.split('-').collect();
|
||||
if parts.len() >= 2 {
|
||||
return Some(parts[1].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从自定义头中提取
|
||||
if let Some(trace_id) = headers.get("x-trace-id")
|
||||
&& let Ok(trace_id_str) = trace_id.to_str()
|
||||
{
|
||||
return Some(trace_id_str.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
#[test]
|
||||
fn test_extract_trace_from_headers_traceparent() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"traceparent",
|
||||
HeaderValue::from_static("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
|
||||
);
|
||||
|
||||
let trace_id = extract_trace_from_headers(&headers);
|
||||
assert_eq!(
|
||||
trace_id,
|
||||
Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_trace_from_headers_custom() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-trace-id",
|
||||
HeaderValue::from_static("custom-trace-id-123"),
|
||||
);
|
||||
|
||||
let trace_id = extract_trace_from_headers(&headers);
|
||||
assert_eq!(trace_id, Some("custom-trace-id-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_trace_from_headers_none() {
|
||||
let headers = HeaderMap::new();
|
||||
let trace_id = extract_trace_from_headers(&headers);
|
||||
assert_eq!(trace_id, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::{REQUEST_ID_HEADER, SERVER_TIME_HEADER};
|
||||
use axum::{extract::Request, response::Response};
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
use tower::{Layer, Service};
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerTimeLayer;
|
||||
|
||||
impl<S> Layer<S> for ServerTimeLayer {
|
||||
type Service = ServerTimeMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
ServerTimeMiddleware { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerTimeMiddleware<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> Service<Request> for ServerTimeMiddleware<S>
|
||||
where
|
||||
S: Service<Request, Response = Response> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
// `BoxFuture` is a type alias for `Pin<Box<dyn Future + Send + 'a>>`
|
||||
type Future =
|
||||
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, request: Request) -> Self::Future {
|
||||
let start = Instant::now();
|
||||
let future = self.inner.call(request);
|
||||
Box::pin(async move {
|
||||
let mut res: Response = future.await?;
|
||||
let elapsed = format!("{}us", start.elapsed().as_micros());
|
||||
match elapsed.parse() {
|
||||
Ok(v) => {
|
||||
res.headers_mut().insert(SERVER_TIME_HEADER, v);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Parse elapsed time failed: {} for request {:?}",
|
||||
e,
|
||||
res.headers().get(REQUEST_ID_HEADER)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
18
qiming-mcp-proxy/mcp-proxy/src/server/mod.rs
Normal file
18
qiming-mcp-proxy/mcp-proxy/src/server/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
pub mod handlers;
|
||||
mod mcp_dynamic_router_service;
|
||||
mod middlewares;
|
||||
pub mod protocol_detector;
|
||||
mod router_layer;
|
||||
mod task;
|
||||
pub mod telemetry;
|
||||
|
||||
pub use handlers::{get_health, get_ready};
|
||||
|
||||
pub use middlewares::set_layer;
|
||||
|
||||
pub use protocol_detector::{detect_mcp_protocol, detect_mcp_protocol_with_headers};
|
||||
pub use router_layer::get_router;
|
||||
pub use task::{mcp_start_task, schedule_check_mcp_live, start_schedule_task};
|
||||
pub use telemetry::{
|
||||
create_telemetry_layer, init_tracer_provider, log_service_info, shutdown_telemetry,
|
||||
};
|
||||
87
qiming-mcp-proxy/mcp-proxy/src/server/protocol_detector.rs
Normal file
87
qiming-mcp-proxy/mcp-proxy/src/server/protocol_detector.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! Protocol Detection (Re-export Layer)
|
||||
//!
|
||||
//! This module re-exports protocol detection functions and provides
|
||||
//! a convenient combined detection function.
|
||||
//!
|
||||
//! Detection logic: If SSE detected → Sse, else → Stream (default fallback)
|
||||
|
||||
// Re-export the detection functions
|
||||
pub use mcp_sse_proxy::is_sse_with_headers;
|
||||
|
||||
use crate::model::McpProtocol;
|
||||
use anyhow::Result;
|
||||
use log::info;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Automatically detect the MCP service protocol type
|
||||
///
|
||||
/// Convenience wrapper around [`detect_mcp_protocol_with_headers`] that passes no
|
||||
/// custom headers.
|
||||
pub async fn detect_mcp_protocol(url: &str) -> Result<McpProtocol> {
|
||||
detect_mcp_protocol_with_headers(url, None).await
|
||||
}
|
||||
|
||||
/// Automatically detect the MCP service protocol type, with optional custom headers
|
||||
///
|
||||
/// Detection logic:
|
||||
/// 1. First try to detect SSE protocol (GET /sse returns text/event-stream)
|
||||
/// 2. If not SSE, default to Streamable HTTP (modern MCP standard)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - The URL to detect
|
||||
/// * `headers` - Optional custom headers to include in the detection request
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the detected protocol type (Sse or Stream)
|
||||
pub async fn detect_mcp_protocol_with_headers(
|
||||
url: &str,
|
||||
headers: Option<&HashMap<String, String>>,
|
||||
) -> Result<McpProtocol> {
|
||||
info!(
|
||||
"Start automatically detecting MCP service protocol: {}",
|
||||
url
|
||||
);
|
||||
|
||||
// Try SSE first
|
||||
if is_sse_with_headers(url, headers).await {
|
||||
info!("SSE protocol detected: {}", url);
|
||||
return Ok(McpProtocol::Sse);
|
||||
}
|
||||
|
||||
// Default to Streamable HTTP (modern MCP standard)
|
||||
info!("Default uses Streamable HTTP protocol: {}", url);
|
||||
Ok(McpProtocol::Stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detect_invalid_url() {
|
||||
// Invalid URL should default to Stream
|
||||
let result = detect_mcp_protocol("not-a-url").await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), McpProtocol::Stream);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detect_nonexistent_server() {
|
||||
// Non-existent server should default to Stream
|
||||
let result = detect_mcp_protocol("http://localhost:99999/mcp").await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), McpProtocol::Stream);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detect_with_headers_nonexistent_server() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
|
||||
let result =
|
||||
detect_mcp_protocol_with_headers("http://localhost:99999/mcp", Some(&headers)).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), McpProtocol::Stream);
|
||||
}
|
||||
}
|
||||
82
qiming-mcp-proxy/mcp-proxy/src/server/router_layer.rs
Normal file
82
qiming-mcp-proxy/mcp-proxy/src/server/router_layer.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use http::Method;
|
||||
use tower_http::cors::{self, CorsLayer};
|
||||
|
||||
use crate::{
|
||||
AppError, AppState, DynamicRouterService,
|
||||
model::{GLOBAL_SSE_MCP_ROUTES_PREFIX, GLOBAL_STREAM_MCP_ROUTES_PREFIX, McpProtocol},
|
||||
server::handlers::check_mcp_is_status_handler,
|
||||
};
|
||||
|
||||
use super::{
|
||||
get_health, get_ready,
|
||||
handlers::{
|
||||
add_route_handler, check_mcp_status_handler_sse, check_mcp_status_handler_stream,
|
||||
delete_route_handler, run_code_handler,
|
||||
},
|
||||
set_layer,
|
||||
};
|
||||
|
||||
/// 获取路由
|
||||
pub async fn get_router(state: AppState) -> Result<Router, AppError> {
|
||||
let health = Router::new()
|
||||
.route("/health", get(get_health))
|
||||
.route("/ready", get(get_ready));
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
// allow `GET` and `POST` when accessing the resource
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PATCH,
|
||||
Method::DELETE,
|
||||
Method::PUT,
|
||||
])
|
||||
.allow_origin(cors::Any)
|
||||
.allow_headers(cors::Any);
|
||||
|
||||
let api = Router::new()
|
||||
// .layer(from_fn_with_state(state.clone(), verify_token::<AppState>))
|
||||
//mcp sse 协议路由
|
||||
.route_service(
|
||||
&format!("{GLOBAL_SSE_MCP_ROUTES_PREFIX}/proxy/{{*path}}"),
|
||||
DynamicRouterService(McpProtocol::Sse),
|
||||
)
|
||||
.route(
|
||||
&format!("{GLOBAL_SSE_MCP_ROUTES_PREFIX}/add"),
|
||||
post(add_route_handler),
|
||||
)
|
||||
.route("/mcp/config/delete/{mcp_id}", delete(delete_route_handler))
|
||||
.route(
|
||||
"/mcp/check/status/{mcp_id}",
|
||||
get(check_mcp_is_status_handler),
|
||||
)
|
||||
.route(
|
||||
&format!("{GLOBAL_SSE_MCP_ROUTES_PREFIX}/check_status"),
|
||||
post(check_mcp_status_handler_sse),
|
||||
)
|
||||
//mcp stream 协议路由
|
||||
.route_service(
|
||||
&format!("{GLOBAL_STREAM_MCP_ROUTES_PREFIX}/proxy/{{*path}}"),
|
||||
DynamicRouterService(McpProtocol::Stream),
|
||||
)
|
||||
.route(
|
||||
&format!("{GLOBAL_STREAM_MCP_ROUTES_PREFIX}/check_status"),
|
||||
post(check_mcp_status_handler_stream),
|
||||
)
|
||||
.route("/api/run_code_with_log", post(run_code_handler))
|
||||
.layer(DefaultBodyLimit::max(20 * 1024 * 1024))
|
||||
.layer(cors);
|
||||
|
||||
// 创建基本路由
|
||||
let app: Router<AppState> = Router::new().merge(health).merge(api);
|
||||
|
||||
// 添加状态
|
||||
let app = app.with_state(state.clone());
|
||||
let router = set_layer(app, state);
|
||||
Ok(router)
|
||||
}
|
||||
592
qiming-mcp-proxy/mcp-proxy/src/server/task/mcp_start_task.rs
Normal file
592
qiming-mcp-proxy/mcp-proxy/src/server/task/mcp_start_task.rs
Normal file
@@ -0,0 +1,592 @@
|
||||
//! MCP Service Start Task
|
||||
//!
|
||||
//! This module handles starting MCP services using the Builder APIs from
|
||||
//! mcp-sse-proxy and mcp-streamable-proxy libraries.
|
||||
//!
|
||||
//! The refactored implementation removes direct rmcp dependency by delegating
|
||||
//! protocol-specific logic to the proxy libraries.
|
||||
|
||||
use crate::{
|
||||
AppError, DynamicRouterService, get_proxy_manager,
|
||||
model::GLOBAL_RESTART_TRACKER,
|
||||
model::{
|
||||
CheckMcpStatusResponseStatus, McpConfig, McpProtocol, McpProtocolPath, McpRouterPath,
|
||||
McpServerCommandConfig, McpServerConfig, McpServiceStatus, McpType,
|
||||
},
|
||||
proxy::{
|
||||
McpHandler, SseBackendConfig, SseServerBuilder, StreamBackendConfig, StreamServerBuilder,
|
||||
},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use log::{debug, info};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Start an MCP service based on configuration
|
||||
///
|
||||
/// This function creates and configures an MCP proxy service based on the
|
||||
/// provided configuration. It supports both SSE and Streamable HTTP client
|
||||
/// protocols, with automatic backend protocol detection for URL-based services.
|
||||
pub async fn mcp_start_task(
|
||||
mcp_config: McpConfig,
|
||||
) -> Result<(axum::Router, tokio_util::sync::CancellationToken)> {
|
||||
let mcp_id = mcp_config.mcp_id.clone();
|
||||
let client_protocol = mcp_config.client_protocol.clone();
|
||||
|
||||
// Create router path based on client protocol (determines exposed API interface)
|
||||
let mcp_router_path: McpRouterPath = McpRouterPath::new(mcp_id, client_protocol)
|
||||
.map_err(|e| AppError::mcp_server_error(e.to_string()))?;
|
||||
|
||||
let mcp_json_config = mcp_config
|
||||
.mcp_json_config
|
||||
.clone()
|
||||
.expect("mcp_json_config is required");
|
||||
|
||||
let mcp_server_config = McpServerConfig::try_from(mcp_json_config)?;
|
||||
|
||||
// Use the integrated method to create the server
|
||||
integrate_server_with_axum(
|
||||
mcp_server_config.clone(),
|
||||
mcp_router_path.clone(),
|
||||
mcp_config.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Integrate MCP server with axum router
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Determines backend protocol (stdio, SSE, or Streamable HTTP)
|
||||
/// 2. Creates the appropriate server using Builder APIs
|
||||
/// 3. Registers the handler with ProxyManager
|
||||
/// 4. Sets up dynamic routing
|
||||
pub async fn integrate_server_with_axum(
|
||||
mcp_config: McpServerConfig,
|
||||
mcp_router_path: McpRouterPath,
|
||||
full_mcp_config: McpConfig,
|
||||
) -> Result<(axum::Router, tokio_util::sync::CancellationToken)> {
|
||||
let mcp_type = full_mcp_config.mcp_type.clone();
|
||||
let base_path = mcp_router_path.base_path.clone();
|
||||
let mcp_id = mcp_router_path.mcp_id.clone();
|
||||
|
||||
// Determine backend protocol from configuration
|
||||
let backend_protocol = match &mcp_config {
|
||||
// Command-line config: use stdio protocol
|
||||
McpServerConfig::Command(_) => McpProtocol::Stdio,
|
||||
// URL config: parse type field or auto-detect
|
||||
McpServerConfig::Url(url_config) => {
|
||||
// Merge headers + auth_token for protocol detection
|
||||
let mut detection_headers = normalize_headers(&url_config.headers).unwrap_or_default();
|
||||
if let Some(auth_token) = &url_config.auth_token {
|
||||
let value = if auth_token.starts_with("Bearer ") {
|
||||
auth_token.clone()
|
||||
} else {
|
||||
format!("Bearer {}", auth_token)
|
||||
};
|
||||
detection_headers.insert("Authorization".to_string(), value);
|
||||
}
|
||||
let detection_headers_ref = if detection_headers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&detection_headers)
|
||||
};
|
||||
|
||||
// Check type field first
|
||||
if let Some(type_str) = &url_config.r#type {
|
||||
match type_str.parse::<McpProtocol>() {
|
||||
Ok(protocol) => {
|
||||
debug!(
|
||||
"Using configured protocol type: {} -> {:?}",
|
||||
type_str, protocol
|
||||
);
|
||||
protocol
|
||||
}
|
||||
Err(_) => {
|
||||
// If parsing fails, auto-detect
|
||||
debug!("Protocol type '{}' unrecognized, auto-detecting", type_str);
|
||||
let detected_protocol = crate::server::detect_mcp_protocol_with_headers(
|
||||
url_config.get_url(),
|
||||
detection_headers_ref,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Protocol type '{}' unrecognized and auto-detection failed: {}",
|
||||
type_str,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
debug!(
|
||||
"Auto-detected protocol: {:?} (original config: '{}')",
|
||||
detected_protocol, type_str
|
||||
);
|
||||
detected_protocol
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No type field, auto-detect
|
||||
debug!("No type field specified, auto-detecting protocol");
|
||||
|
||||
crate::server::detect_mcp_protocol_with_headers(
|
||||
url_config.get_url(),
|
||||
detection_headers_ref,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Auto-detection failed: {}", e))?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"MCP ID: {}, client protocol: {:?}, backend protocol: {:?}",
|
||||
mcp_id, mcp_router_path.mcp_protocol, backend_protocol
|
||||
);
|
||||
|
||||
// Create server based on client protocol using Builder APIs
|
||||
let (router, ct, handler) = match mcp_router_path.mcp_protocol.clone() {
|
||||
// ================ Client uses SSE protocol ================
|
||||
McpProtocol::Sse => {
|
||||
let sse_path = match &mcp_router_path.mcp_protocol_path {
|
||||
McpProtocolPath::SsePath(sse_path) => sse_path,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Build backend config for SSE
|
||||
let backend_config = if matches!(backend_protocol, McpProtocol::Stream) {
|
||||
// Streamable HTTP backend: connect via mcp-streamable-proxy (rmcp 1.4.0),
|
||||
// then pass as BackendBridge to decouple mcp-sse-proxy from mcp-streamable-proxy
|
||||
let bridge = connect_stream_backend(&mcp_config, &mcp_id).await?;
|
||||
SseBackendConfig::BackendBridge(bridge)
|
||||
} else {
|
||||
build_sse_backend_config(&mcp_config, backend_protocol)?
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Creating SSE server, sse_path={}, post_path={}",
|
||||
sse_path.sse_path, sse_path.message_path
|
||||
);
|
||||
|
||||
// 对于 OneShot 服务,使用更短的 keep_alive 间隔(5秒)来保持后端活跃
|
||||
// 防止后端进程因空闲超时而退出
|
||||
let keep_alive_secs = if matches!(mcp_type, McpType::OneShot) {
|
||||
5
|
||||
} else {
|
||||
15
|
||||
};
|
||||
|
||||
// 对于 OneShot 服务,禁用 stateful 模式以加快响应速度
|
||||
// stateful=false 会跳过 MCP 初始化步骤,直接处理请求
|
||||
let stateful = !matches!(mcp_type, McpType::OneShot);
|
||||
|
||||
let (router, ct, handler) = SseServerBuilder::new(backend_config)
|
||||
.mcp_id(mcp_id.clone())
|
||||
.sse_path(sse_path.sse_path.clone())
|
||||
.post_path(sse_path.message_path.clone())
|
||||
.keep_alive(keep_alive_secs)
|
||||
.stateful(stateful)
|
||||
.build()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"SSE server build failed - MCP ID: {}, type: {:?}",
|
||||
mcp_id, mcp_type
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"SSE server started - MCP ID: {}, type: {:?}",
|
||||
mcp_router_path.mcp_id, mcp_type
|
||||
);
|
||||
|
||||
(router, ct, McpHandler::Sse(Box::new(handler)))
|
||||
}
|
||||
|
||||
// ================ Client uses Streamable HTTP protocol ================
|
||||
McpProtocol::Stream => {
|
||||
// Build backend config for Stream
|
||||
let backend_config = build_stream_backend_config(&mcp_config, backend_protocol)?;
|
||||
|
||||
let (router, ct, handler) = StreamServerBuilder::new(backend_config)
|
||||
.mcp_id(mcp_id.clone())
|
||||
.stateful(false)
|
||||
.build()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Stream server build failed - MCP ID: {}, type: {:?}",
|
||||
mcp_id, mcp_type
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Streamable HTTP server started - MCP ID: {}, type: {:?}",
|
||||
mcp_router_path.mcp_id, mcp_type
|
||||
);
|
||||
|
||||
(router, ct, McpHandler::Stream(Box::new(handler)))
|
||||
}
|
||||
|
||||
// Client stdio protocol is not supported in server mode
|
||||
McpProtocol::Stdio => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Client protocol cannot be Stdio. McpRouterPath::new does not support creating Stdio protocol router paths"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Clone cancellation token for monitoring
|
||||
let ct_clone = ct.clone();
|
||||
let mcp_id_clone = mcp_id.clone();
|
||||
|
||||
// Store MCP service status with full mcp_config for auto-restart
|
||||
let mcp_service_status = McpServiceStatus::new(
|
||||
mcp_id_clone.clone(),
|
||||
mcp_type.clone(),
|
||||
mcp_router_path.clone(),
|
||||
ct_clone.clone(),
|
||||
CheckMcpStatusResponseStatus::Ready,
|
||||
)
|
||||
.with_mcp_config(full_mcp_config.clone());
|
||||
|
||||
// Add MCP service status and proxy handler to global manager
|
||||
let proxy_manager = get_proxy_manager();
|
||||
proxy_manager.add_mcp_service_status_and_proxy(mcp_service_status, Some(handler));
|
||||
|
||||
// ===== 新增:注册配置到缓存 =====
|
||||
proxy_manager
|
||||
.register_mcp_config(&mcp_id, full_mcp_config.clone())
|
||||
.await;
|
||||
|
||||
// Add base path fallback handler for SSE protocol
|
||||
let router = if matches!(mcp_router_path.mcp_protocol, McpProtocol::Sse) {
|
||||
let modified_router = router.fallback(base_path_fallback_handler);
|
||||
info!("SSE base path handler added, base_path: {}", base_path);
|
||||
modified_router
|
||||
} else {
|
||||
router
|
||||
};
|
||||
|
||||
// Register route to global route table
|
||||
info!(
|
||||
"Registering route: base_path={}, mcp_id={}",
|
||||
base_path, mcp_id
|
||||
);
|
||||
info!(
|
||||
"SSE path config: sse_path={}, post_path={}",
|
||||
match &mcp_router_path.mcp_protocol_path {
|
||||
McpProtocolPath::SsePath(sse_path) => &sse_path.sse_path,
|
||||
_ => "N/A",
|
||||
},
|
||||
match &mcp_router_path.mcp_protocol_path {
|
||||
McpProtocolPath::SsePath(sse_path) => &sse_path.message_path,
|
||||
_ => "N/A",
|
||||
}
|
||||
);
|
||||
DynamicRouterService::register_route(&base_path, router.clone());
|
||||
info!("Route registration complete: base_path={}", base_path);
|
||||
|
||||
// 记录重启时间戳(仅在服务成功启动后)
|
||||
GLOBAL_RESTART_TRACKER.record_restart(&mcp_id);
|
||||
|
||||
Ok((router, ct))
|
||||
}
|
||||
|
||||
/// Connect to a Streamable HTTP backend and return a BackendBridge
|
||||
///
|
||||
/// This lives in mcp-proxy (not mcp-sse-proxy) because it uses mcp-streamable-proxy types.
|
||||
/// The returned `Arc<dyn BackendBridge>` is protocol-agnostic, allowing mcp-sse-proxy
|
||||
/// to use it without depending on mcp-streamable-proxy.
|
||||
async fn connect_stream_backend(
|
||||
mcp_config: &McpServerConfig,
|
||||
mcp_id: &str,
|
||||
) -> Result<std::sync::Arc<dyn mcp_common::BackendBridge>> {
|
||||
use crate::proxy::{StreamClientConnection, StreamProxyHandler};
|
||||
|
||||
let url_config = match mcp_config {
|
||||
McpServerConfig::Url(url_config) => url_config,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Stream backend requires URL-based config"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let url = url_config.get_url();
|
||||
info!(
|
||||
"Connecting to Streamable HTTP backend (SSE frontend) \
|
||||
- MCP ID: {}, URL: {}",
|
||||
mcp_id, url
|
||||
);
|
||||
|
||||
let mut config = mcp_common::McpClientConfig::new(url.to_string());
|
||||
let normalized = normalize_headers(&url_config.headers);
|
||||
if let Some(ref headers) = normalized {
|
||||
for (k, v) in headers {
|
||||
config = config.with_header(k, v);
|
||||
}
|
||||
}
|
||||
// auth_token 合并到 Authorization header(与 build_sse_backend_config 逻辑一致)
|
||||
if let Some(ref auth_token) = url_config.auth_token {
|
||||
let value = if auth_token.starts_with("Bearer ") {
|
||||
auth_token.clone()
|
||||
} else {
|
||||
format!("Bearer {}", auth_token)
|
||||
};
|
||||
config = config.with_header("Authorization", value);
|
||||
}
|
||||
|
||||
let conn = StreamClientConnection::connect(config).await?;
|
||||
let proxy_handler =
|
||||
StreamProxyHandler::with_mcp_id(conn.into_running_service(), mcp_id.to_string());
|
||||
|
||||
Ok(std::sync::Arc::new(proxy_handler))
|
||||
}
|
||||
|
||||
/// Build SSE backend configuration from MCP server config
|
||||
fn build_sse_backend_config(
|
||||
mcp_config: &McpServerConfig,
|
||||
backend_protocol: McpProtocol,
|
||||
) -> Result<SseBackendConfig> {
|
||||
match mcp_config {
|
||||
McpServerConfig::Command(cmd_config) => {
|
||||
log_command_details(cmd_config);
|
||||
Ok(SseBackendConfig::Stdio {
|
||||
command: cmd_config.command.clone(),
|
||||
args: cmd_config.args.clone(),
|
||||
env: cmd_config.env.clone(),
|
||||
})
|
||||
}
|
||||
McpServerConfig::Url(url_config) => match backend_protocol {
|
||||
McpProtocol::Stdio => Err(anyhow::anyhow!(
|
||||
"URL-based MCP service cannot use Stdio protocol"
|
||||
)),
|
||||
McpProtocol::Sse => {
|
||||
info!("Connecting to SSE backend: {}", url_config.get_url());
|
||||
Ok(SseBackendConfig::SseUrl {
|
||||
url: url_config.get_url().to_string(),
|
||||
headers: normalize_headers(&url_config.headers),
|
||||
})
|
||||
}
|
||||
McpProtocol::Stream => Err(anyhow::anyhow!(
|
||||
"Stream backend should be handled via connect_stream_backend(), \
|
||||
not build_sse_backend_config()"
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build Stream backend configuration from MCP server config
|
||||
fn build_stream_backend_config(
|
||||
mcp_config: &McpServerConfig,
|
||||
backend_protocol: McpProtocol,
|
||||
) -> Result<StreamBackendConfig> {
|
||||
match mcp_config {
|
||||
McpServerConfig::Command(cmd_config) => {
|
||||
log_command_details(cmd_config);
|
||||
Ok(StreamBackendConfig::Stdio {
|
||||
command: cmd_config.command.clone(),
|
||||
args: cmd_config.args.clone(),
|
||||
env: cmd_config.env.clone(),
|
||||
})
|
||||
}
|
||||
McpServerConfig::Url(url_config) => {
|
||||
match backend_protocol {
|
||||
McpProtocol::Stdio => Err(anyhow::anyhow!(
|
||||
"URL-based MCP service cannot use Stdio protocol"
|
||||
)),
|
||||
McpProtocol::Sse => {
|
||||
// Note: StreamServerBuilder currently only supports Streamable HTTP URL backend
|
||||
// SSE backend with Stream frontend would require protocol conversion
|
||||
// For now, we return an error for this combination
|
||||
Err(anyhow::anyhow!(
|
||||
"SSE backend with Streamable HTTP frontend is not yet supported. \
|
||||
Please use SSE frontend or configure a Streamable HTTP backend."
|
||||
))
|
||||
}
|
||||
McpProtocol::Stream => {
|
||||
info!(
|
||||
"Connecting to Streamable HTTP backend: {}",
|
||||
url_config.get_url()
|
||||
);
|
||||
Ok(StreamBackendConfig::Url {
|
||||
url: url_config.get_url().to_string(),
|
||||
headers: normalize_headers(&url_config.headers),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范化 headers:确保 Authorization header 有 "Bearer " 前缀
|
||||
///
|
||||
/// 与 client 模式 (`convert.rs:build_mcp_config`) 行为一致,
|
||||
/// 对没有 "Bearer " 前缀的 Authorization header 自动添加前缀。
|
||||
fn normalize_headers(headers: &Option<HashMap<String, String>>) -> Option<HashMap<String, String>> {
|
||||
headers.as_ref().map(|h| {
|
||||
h.iter()
|
||||
.map(|(k, v)| {
|
||||
if k.eq_ignore_ascii_case("Authorization") && !v.starts_with("Bearer ") {
|
||||
(k.clone(), format!("Bearer {}", v))
|
||||
} else {
|
||||
(k.clone(), v.clone())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Log command execution details for debugging
|
||||
fn log_command_details(mcp_config: &McpServerCommandConfig) {
|
||||
let args_str = mcp_config
|
||||
.args
|
||||
.as_ref()
|
||||
.map_or(String::new(), |args| args.join(" "));
|
||||
|
||||
info!("Executing command: {} {}", mcp_config.command, args_str);
|
||||
|
||||
// 只输出 env 变量的 key 列表,避免泄露敏感 value
|
||||
if let Some(env_vars) = &mcp_config.env {
|
||||
let keys: Vec<&String> = env_vars.keys().collect();
|
||||
if !keys.is_empty() {
|
||||
debug!("Config env keys: {:?}", keys);
|
||||
}
|
||||
}
|
||||
|
||||
// 输出进程级关键环境变量(PATH 摘要 + 镜像变量)
|
||||
debug!(
|
||||
"Process PATH: {}",
|
||||
mcp_common::diagnostic::format_path_summary(3)
|
||||
);
|
||||
for (key, val) in mcp_common::diagnostic::collect_mirror_env_vars() {
|
||||
debug!("Process env: {}={}", key, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Base path fallback handler - supports direct access to base path with automatic redirection
|
||||
#[axum::debug_handler]
|
||||
async fn base_path_fallback_handler(
|
||||
method: axum::http::Method,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let path = uri.path();
|
||||
info!("Base path handler: {} {}", method, path);
|
||||
|
||||
// Determine if SSE or Stream protocol
|
||||
if path.contains("/sse/proxy/") {
|
||||
// SSE protocol handling
|
||||
match method {
|
||||
axum::http::Method::GET => {
|
||||
// Extract MCP ID from path
|
||||
let mcp_id = path.split("/sse/proxy/").nth(1);
|
||||
|
||||
if let Some(mcp_id) = mcp_id {
|
||||
// Check if MCP service exists
|
||||
let proxy_manager = get_proxy_manager();
|
||||
if proxy_manager.get_mcp_service_status(mcp_id).is_none() {
|
||||
// MCP service not found
|
||||
(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
format!("MCP service '{}' not found", mcp_id).to_string(),
|
||||
)
|
||||
} else {
|
||||
// MCP service exists, check Accept header
|
||||
let accept_header = headers.get("accept");
|
||||
if let Some(accept) = accept_header {
|
||||
let accept_str = accept.to_str().unwrap_or("");
|
||||
if accept_str.contains("text/event-stream") {
|
||||
// Correct Accept header, redirect to /sse
|
||||
let redirect_uri = format!("{}/sse", path);
|
||||
info!("SSE redirect to: {}", redirect_uri);
|
||||
(
|
||||
axum::http::StatusCode::FOUND,
|
||||
[("Location", redirect_uri.to_string())],
|
||||
"Redirecting to SSE endpoint".to_string(),
|
||||
)
|
||||
} else {
|
||||
// Incorrect Accept header
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
"SSE error: Invalid Accept header, expected 'text/event-stream'".to_string(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No Accept header
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
"SSE error: Missing Accept header, expected 'text/event-stream'"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cannot extract MCP ID from path
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
"SSE error: Invalid SSE path".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
axum::http::Method::POST => {
|
||||
// POST request redirect to /message
|
||||
let redirect_uri = format!("{}/message", path);
|
||||
info!("SSE redirect to: {}", redirect_uri);
|
||||
(
|
||||
axum::http::StatusCode::FOUND,
|
||||
[("Location", redirect_uri.to_string())],
|
||||
"Redirecting to message endpoint".to_string(),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// Other methods return 405 Method Not Allowed
|
||||
(
|
||||
axum::http::StatusCode::METHOD_NOT_ALLOWED,
|
||||
[("Allow", "GET, POST".to_string())],
|
||||
"Only GET and POST methods are allowed".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if path.contains("/stream/proxy/") {
|
||||
// Stream protocol handling - return success directly without redirect
|
||||
match method {
|
||||
axum::http::Method::GET => {
|
||||
// GET request returns server info
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
[("Content-Type", "application/json".to_string())],
|
||||
r#"{"jsonrpc":"2.0","result":{"info":"Streamable MCP Server","version":"1.0"}}"#.to_string(),
|
||||
)
|
||||
}
|
||||
axum::http::Method::POST => {
|
||||
// POST request returns success, let StreamableHttpService handle
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
[("Content-Type", "application/json".to_string())],
|
||||
r#"{"jsonrpc":"2.0","result":{"message":"Stream request received","protocol":"streamable-http"}}"#.to_string(),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// Other methods return 405 Method Not Allowed
|
||||
(
|
||||
axum::http::StatusCode::METHOD_NOT_ALLOWED,
|
||||
[("Allow", "GET, POST".to_string())],
|
||||
"Only GET and POST methods are allowed".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown protocol
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
"Unknown protocol or path".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
qiming-mcp-proxy/mcp-proxy/src/server/task/mod.rs
Normal file
7
qiming-mcp-proxy/mcp-proxy/src/server/task/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod mcp_start_task;
|
||||
mod schedule_check_mcp_live;
|
||||
mod schedule_task;
|
||||
|
||||
pub use mcp_start_task::{integrate_server_with_axum, mcp_start_task};
|
||||
pub use schedule_check_mcp_live::schedule_check_mcp_live;
|
||||
pub use schedule_task::start_schedule_task;
|
||||
@@ -0,0 +1,195 @@
|
||||
use crate::get_proxy_manager;
|
||||
use crate::model::{CheckMcpStatusResponseStatus, GLOBAL_RESTART_TRACKER, McpType};
|
||||
use crate::server::task::mcp_start_task::mcp_start_task;
|
||||
use tokio::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
// OneShot 服务超时时间:5分钟无活动则清理
|
||||
const ONESHOT_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
// 连续健康检查失败阈值:连续失败 3 次才触发重启
|
||||
const MAX_PROBE_FAILURES: u32 = 3;
|
||||
|
||||
/// 定期检查全局动态 router 里的 MCP 服务状态
|
||||
///
|
||||
/// ## 处理逻辑
|
||||
///
|
||||
/// 1. Error 状态 → 清理资源
|
||||
/// 2. 空闲超时(5分钟)→ 清理资源(资源回收)
|
||||
/// 3. 健康检查(只对 Ready 状态)
|
||||
/// - Pending → 跳过(等待启动完成)
|
||||
/// - Ready → 执行探测
|
||||
/// - 成功 → 重置失败计数
|
||||
/// - 失败 → 失败计数 + 1
|
||||
/// - 连续失败 >= 3 → 重启后端服务
|
||||
pub async fn schedule_check_mcp_live() {
|
||||
// 获取全局动态 router
|
||||
let proxy_manager = get_proxy_manager();
|
||||
// 获取所有 mcp 服务状态
|
||||
let mcp_service_statuses = proxy_manager.get_all_mcp_service_status();
|
||||
|
||||
// 打印当前有多少个 mcp 插件服务在运行
|
||||
info!(
|
||||
"There are currently {} mcp plug-in services running",
|
||||
mcp_service_statuses.len()
|
||||
);
|
||||
|
||||
// 遍历所有 mcp 服务状态
|
||||
for mcp_service_status in mcp_service_statuses {
|
||||
// 获取服务信息
|
||||
let mcp_id = mcp_service_status.mcp_id.clone();
|
||||
let mcp_type = mcp_service_status.mcp_type.clone();
|
||||
let cancellation_token = mcp_service_status.cancellation_token.clone();
|
||||
|
||||
// 1. 如果 mcp 的状态是 ERROR,则清理资源
|
||||
if let CheckMcpStatusResponseStatus::Error(_) =
|
||||
mcp_service_status.check_mcp_status_response_status
|
||||
{
|
||||
if let Err(e) = proxy_manager.cleanup_resources(&mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据 MCP 类型进行不同处理
|
||||
match mcp_type {
|
||||
McpType::Persistent => {
|
||||
// 检查持久化服务是否已被取消或子进程已终止
|
||||
if cancellation_token.is_cancelled() {
|
||||
info!(
|
||||
"The persistent MCP service {mcp_id} has been manually canceled and resources are being cleaned up."
|
||||
);
|
||||
if let Err(e) = proxy_manager.cleanup_resources(&mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查子进程是否还在运行
|
||||
if let Some(handler) = proxy_manager.get_proxy_handler(&mcp_id)
|
||||
&& handler.is_terminated_async().await
|
||||
{
|
||||
info!(
|
||||
"The persistent MCP service {mcp_id} child process ended abnormally and cleaned up resources."
|
||||
);
|
||||
if let Err(e) = proxy_manager.cleanup_resources(&mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
McpType::OneShot => {
|
||||
// 2. 检查空闲超时(基于所有请求的活动)
|
||||
let idle_time = mcp_service_status.last_accessed.elapsed();
|
||||
|
||||
// 空闲超时 → 清理资源
|
||||
if idle_time > ONESHOT_TIMEOUT {
|
||||
info!(
|
||||
"OneShot service {} idle timeout (idle time: {} seconds), clean up resources",
|
||||
mcp_id,
|
||||
idle_time.as_secs()
|
||||
);
|
||||
if let Err(e) = proxy_manager.cleanup_resources(&mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 健康检查(只对 Ready 状态)
|
||||
// Pending 状态跳过探测,等待启动完成
|
||||
if !matches!(
|
||||
mcp_service_status.check_mcp_status_response_status,
|
||||
CheckMcpStatusResponseStatus::Ready
|
||||
) {
|
||||
// Pending 状态跳过探测
|
||||
continue;
|
||||
}
|
||||
|
||||
// 执行健康探测
|
||||
let handler = proxy_manager.get_proxy_handler(&mcp_id);
|
||||
if let Some(handler) = handler {
|
||||
let is_terminated = handler.is_terminated_async().await;
|
||||
|
||||
if is_terminated {
|
||||
let failures = proxy_manager.increment_probe_failures(&mcp_id);
|
||||
info!(
|
||||
"OneShot service {} health check failed ({}/{})",
|
||||
mcp_id, failures, MAX_PROBE_FAILURES
|
||||
);
|
||||
|
||||
if failures >= MAX_PROBE_FAILURES {
|
||||
info!(
|
||||
"OneShot service {} failed continuously {} times, triggering a restart",
|
||||
mcp_id, failures
|
||||
);
|
||||
restart_mcp_service(&mcp_id, proxy_manager).await;
|
||||
}
|
||||
} else {
|
||||
// 探测成功,重置失败计数
|
||||
proxy_manager.reset_probe_failures(&mcp_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重启 MCP 服务
|
||||
///
|
||||
/// ## 重启流程
|
||||
///
|
||||
/// 1. 检查重启冷却期(30秒)
|
||||
/// 2. 获取配置(从服务状态或缓存)
|
||||
/// 3. 清理旧资源(保留配置缓存)
|
||||
/// 4. 重新启动服务(复用 mcp_start_task)
|
||||
async fn restart_mcp_service(mcp_id: &str, proxy_manager: &crate::model::ProxyHandlerManager) {
|
||||
// 1. 检查重启冷却期
|
||||
if !GLOBAL_RESTART_TRACKER.can_restart(mcp_id) {
|
||||
info!(
|
||||
"Service {} is skipped during the restart cooling period.",
|
||||
mcp_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取配置(优先从服务状态,其次从缓存)
|
||||
let mcp_config = proxy_manager.get_mcp_config(mcp_id);
|
||||
let mcp_config = match mcp_config {
|
||||
Some(config) => Some(config),
|
||||
None => proxy_manager.get_mcp_config_from_cache(mcp_id).await,
|
||||
};
|
||||
|
||||
let Some(mcp_config) = mcp_config else {
|
||||
warn!(
|
||||
"Service {} has no configuration and cannot be restarted. Clean up resources.",
|
||||
mcp_id
|
||||
);
|
||||
if let Err(e) = proxy_manager.cleanup_resources(mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// 3. 清理旧资源(保留配置缓存)
|
||||
if let Err(e) = proxy_manager.cleanup_resources_for_restart(mcp_id).await {
|
||||
error!("Cleanup service {} resource failed: {}", mcp_id, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 重新启动服务(复用 mcp_start_task,自动设置 Pending 状态)
|
||||
match mcp_start_task(mcp_config).await {
|
||||
Ok((_router, _cancellation_token)) => {
|
||||
// 重置失败计数(已在新的服务实例中初始化为 0)
|
||||
// 注意:此时 mcp_id 对应的是新的服务实例
|
||||
proxy_manager.reset_probe_failures(mcp_id);
|
||||
// 记录重启时间
|
||||
GLOBAL_RESTART_TRACKER.record_restart(mcp_id);
|
||||
info!("Service {} restarted successfully", mcp_id);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Service {} failed to restart: {}", mcp_id, e);
|
||||
// 重启失败,设置 Error 状态
|
||||
// 注意:此时服务已被清理,无法设置状态,只能记录日志
|
||||
// 下次请求到来时会触发重新启动
|
||||
}
|
||||
}
|
||||
}
|
||||
65
qiming-mcp-proxy/mcp-proxy/src/server/task/schedule_task.rs
Normal file
65
qiming-mcp-proxy/mcp-proxy/src/server/task/schedule_task.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use crate::server::task::schedule_check_mcp_live;
|
||||
use log::{debug, info, warn};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::time::{Duration, interval};
|
||||
|
||||
/// 启动定时任务,定期检查MCP服务状态
|
||||
///
|
||||
/// 这个函数会创建一个tokio定时任务,每隔指定的时间间隔执行一次`schedule_check_mcp_live`函数
|
||||
/// 用于检查和清理不再需要的MCP服务资源
|
||||
pub async fn start_schedule_task() {
|
||||
info!("Start the MCP service status check scheduled task");
|
||||
|
||||
// 创建一个tokio定时器,每20秒执行一次
|
||||
let mut interval = interval(Duration::from_secs(20));
|
||||
|
||||
// 使用原子布尔值来跟踪任务是否正在执行
|
||||
let is_running = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// 启动一个新的异步任务
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
// 等待下一个时间点
|
||||
interval.tick().await;
|
||||
|
||||
// 检查是否有任务正在执行
|
||||
if is_running.load(Ordering::SeqCst) {
|
||||
warn!(
|
||||
"The last MCP service status check task has not been completed and this execution will be skipped."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 标记任务开始执行
|
||||
is_running.store(true, Ordering::SeqCst);
|
||||
|
||||
// 执行MCP服务状态检查
|
||||
debug!("Perform periodic checks on MCP service status...");
|
||||
|
||||
// 在一个新的任务中执行检查,这样可以捕获任何异常
|
||||
let is_running_clone = is_running.clone();
|
||||
tokio::spawn(async move {
|
||||
// 执行检查任务
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(10), // 设置超时时间为10秒,小于间隔时间
|
||||
schedule_check_mcp_live(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
debug!("MCP service status check completed");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("MCP service status check task timed out");
|
||||
}
|
||||
}
|
||||
|
||||
// 无论成功还是失败,都标记任务已完成
|
||||
is_running_clone.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
info!("MCP service status check scheduled task has been started");
|
||||
}
|
||||
60
qiming-mcp-proxy/mcp-proxy/src/server/telemetry.rs
Normal file
60
qiming-mcp-proxy/mcp-proxy/src/server/telemetry.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use anyhow::Result;
|
||||
use opentelemetry::global;
|
||||
use opentelemetry_sdk::trace::{RandomIdGenerator, Sampler, SdkTracerProvider};
|
||||
|
||||
/// 初始化 OpenTelemetry tracer provider
|
||||
///
|
||||
/// 这个函数必须在创建 telemetry layer 之前调用
|
||||
pub fn init_tracer_provider(_service_name: &str, _service_version: &str) -> Result<()> {
|
||||
// 创建 tracer provider
|
||||
let tracer_provider = SdkTracerProvider::builder()
|
||||
.with_sampler(Sampler::AlwaysOn)
|
||||
.with_id_generator(RandomIdGenerator::default())
|
||||
.build();
|
||||
|
||||
// 设置全局 tracer provider
|
||||
global::set_tracer_provider(tracer_provider);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建增强的 OpenTelemetry layer
|
||||
///
|
||||
/// 这个函数创建一个配置好的 OpenTelemetry layer,可以与现有的 tracing 配置集成
|
||||
/// 注意:必须先调用 init_tracer_provider()
|
||||
pub fn create_telemetry_layer() -> impl tracing_subscriber::Layer<tracing_subscriber::Registry> {
|
||||
tracing_opentelemetry::layer()
|
||||
}
|
||||
|
||||
/// 记录服务启动信息
|
||||
///
|
||||
/// 在 telemetry 系统初始化后调用,记录服务的基本信息
|
||||
pub fn log_service_info(service_name: &str, service_version: &str) -> Result<()> {
|
||||
tracing::info!(
|
||||
service_name = %service_name,
|
||||
service_version = %service_version,
|
||||
"Service started with OpenTelemetry tracing enabled"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 优雅关闭 OpenTelemetry
|
||||
pub fn shutdown_telemetry() {
|
||||
tracing::info!("Shutting down OpenTelemetry");
|
||||
// 注意:在新版本的 OpenTelemetry 中,shutdown 方法可能不同
|
||||
// 这里我们简单地记录关闭信息
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_log_service_info() {
|
||||
let result = log_service_info("test-service", "0.1.0");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// 清理
|
||||
shutdown_telemetry();
|
||||
}
|
||||
}
|
||||
157
qiming-mcp-proxy/mcp-proxy/src/tests/coze_mcp_test.rs
Normal file
157
qiming-mcp-proxy/mcp-proxy/src/tests/coze_mcp_test.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! Coze MCP Service Integration Tests
|
||||
//!
|
||||
//! This module tests the protocol conversion from Streamable HTTP (backend) to SSE (frontend)
|
||||
//! when connecting to Coze MCP services.
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! These tests require the following environment variables:
|
||||
//!
|
||||
//! - `COZE_PLUGIN_ID` - Your Coze plugin ID
|
||||
//! - `COZE_BEARER_TOKEN` - Your Coze API Bearer token
|
||||
//!
|
||||
//! # Running the tests
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Set environment variables and run the test
|
||||
//! COZE_PLUGIN_ID="your_plugin_id" \
|
||||
//! COZE_BEARER_TOKEN="your_bearer_token" \
|
||||
//! cargo test -p mcp-stdio-proxy test_coze_streamable_to_sse_proxy
|
||||
//!
|
||||
//! # Or run with logging
|
||||
//! COZE_PLUGIN_ID="your_plugin_id" \
|
||||
//! COZE_BEARER_TOKEN="your_bearer_token" \
|
||||
//! RUST_LOG=debug cargo test -p mcp-stdio-proxy test_coze_streamable_to_sse_proxy
|
||||
//! ```
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{
|
||||
mcp_start_task,
|
||||
model::{McpConfig, McpProtocol, McpType},
|
||||
proxy::{McpClientConfig, SseClientConnection},
|
||||
};
|
||||
|
||||
/// Builds Coze MCP configuration from environment variables
|
||||
fn get_coze_config() -> Result<String> {
|
||||
let plugin_id = std::env::var("COZE_PLUGIN_ID")
|
||||
.map_err(|_| anyhow::anyhow!("COZE_PLUGIN_ID environment variable not set"))?;
|
||||
let bearer_token = std::env::var("COZE_BEARER_TOKEN")
|
||||
.map_err(|_| anyhow::anyhow!("COZE_BEARER_TOKEN environment variable not set"))?;
|
||||
|
||||
let config = r#"{
|
||||
"mcpServers": {
|
||||
"coze_plugin_tianyancha": {
|
||||
"url": "https://mcp.coze.cn/v1/plugins/PLUGIN_ID",
|
||||
"headers": {
|
||||
"Authorization": "Bearer BEARER_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
Ok(config
|
||||
.replace("PLUGIN_ID", &plugin_id)
|
||||
.replace("BEARER_TOKEN", &bearer_token))
|
||||
}
|
||||
|
||||
/// Test: Streamable HTTP backend to SSE frontend protocol conversion
|
||||
///
|
||||
/// This test verifies that the mcp-proxy correctly:
|
||||
/// 1. Configures SSE protocol for the client (frontend)
|
||||
/// 2. Auto-detects Streamable HTTP protocol for the Coze backend
|
||||
/// 3. Transparently converts between the two protocols
|
||||
/// 4. Returns valid tools/list responses
|
||||
#[tokio::test]
|
||||
#[ignore] // Mark as ignored since it requires network access
|
||||
async fn test_coze_streamable_to_sse_proxy() -> Result<()> {
|
||||
// Initialize logging for test
|
||||
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
|
||||
|
||||
println!("🧪 Starting Coze MCP test: Streamable HTTP -> SSE conversion");
|
||||
|
||||
// Step 1: Create configuration with SSE client protocol
|
||||
// The backend protocol (Streamable HTTP) will be auto-detected
|
||||
let coze_config = get_coze_config()?;
|
||||
let mcp_config = McpConfig::from_json_with_server(
|
||||
"coze_plugin_tianyancha".to_string(),
|
||||
coze_config,
|
||||
McpType::OneShot,
|
||||
McpProtocol::Sse, // SSE frontend (client protocol)
|
||||
)?;
|
||||
|
||||
println!("✅ Configuration created with SSE client protocol");
|
||||
|
||||
// Step 2: Start MCP service
|
||||
// The proxy will auto-detect the backend protocol and create appropriate routes
|
||||
let (router, ct) = mcp_start_task(mcp_config).await?;
|
||||
println!("✅ MCP service started");
|
||||
|
||||
// Step 3: Start HTTP server with the router
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
println!("✅ HTTP server listening on 127.0.0.1:{}", port);
|
||||
|
||||
// Spawn server in background
|
||||
let server_handle = tokio::spawn(async move {
|
||||
axum::serve(listener, router.into_make_service())
|
||||
.await
|
||||
.expect("Server error");
|
||||
});
|
||||
|
||||
// Step 4: Wait for backend to be ready
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
println!("✅ Backend ready");
|
||||
|
||||
// Step 5: Construct SSE endpoint path
|
||||
// The SSE server exposes endpoints at /mcp/sse/proxy/{mcp_id}/sse
|
||||
let sse_url = format!(
|
||||
"http://127.0.0.1:{}/mcp/sse/proxy/coze_plugin_tianyancha/sse",
|
||||
port
|
||||
);
|
||||
|
||||
// Step 6: Connect SSE client
|
||||
let client_config = McpClientConfig::new(sse_url.to_string());
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
SseClientConnection::connect(client_config.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("SSE connection timeout (30s)"))?
|
||||
.map_err(|e| anyhow::anyhow!("SSE connection failed: {}", e))?;
|
||||
println!("✅ SSE client connected to {}", sse_url);
|
||||
|
||||
// Step 7: Get tools list using the high-level API
|
||||
let tools = tokio::time::timeout(Duration::from_secs(30), conn.list_tools())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("list_tools timeout (30s)"))?
|
||||
.map_err(|e| anyhow::anyhow!("list_tools failed: {}", e))?;
|
||||
println!("📋 Received tools/list response: {} tools", tools.len());
|
||||
|
||||
// Step 8: Verify response structure
|
||||
if tools.is_empty() {
|
||||
println!("⚠️ Warning: tools/list returned empty array");
|
||||
} else {
|
||||
println!("✅ Found {} tools:", tools.len());
|
||||
for tool in &tools {
|
||||
let desc = tool.description.as_deref().unwrap_or("no description");
|
||||
println!(" - {} : {}", tool.name, desc);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 9: Verify tool structure
|
||||
for tool in &tools {
|
||||
assert!(!tool.name.is_empty(), "Tool name should not be empty");
|
||||
// Description is optional, so we just check the name
|
||||
println!(" ✓ Tool '{}' has valid structure", tool.name);
|
||||
}
|
||||
|
||||
// Step 10: Cleanup
|
||||
ct.cancel();
|
||||
server_handle.abort();
|
||||
println!("🧹 Cleanup complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
12
qiming-mcp-proxy/mcp-proxy/src/tests/mod.rs
Normal file
12
qiming-mcp-proxy/mcp-proxy/src/tests/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#[cfg(test)]
|
||||
pub mod test_utils {
|
||||
// Tests utility module for common test setup
|
||||
}
|
||||
|
||||
// Coze MCP integration tests - Streamable HTTP to SSE conversion
|
||||
#[cfg(test)]
|
||||
pub mod coze_mcp_test;
|
||||
|
||||
// Protocol detection tests - SSE vs Streamable HTTP
|
||||
#[cfg(test)]
|
||||
pub mod protocol_detection_test;
|
||||
270
qiming-mcp-proxy/mcp-proxy/src/tests/protocol_detection_test.rs
Normal file
270
qiming-mcp-proxy/mcp-proxy/src/tests/protocol_detection_test.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! Protocol Detection Integration Tests
|
||||
//!
|
||||
//! Tests protocol detection (SSE vs Streamable HTTP) against real MCP services.
|
||||
//!
|
||||
//! # Running the tests
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Run the zimage streamable HTTP test (requires DASHSCOPE_API_KEY)
|
||||
//! DASHSCOPE_API_KEY=sk-xxx cargo test -p mcp-stdio-proxy test_zimage_protocol_detection -- --ignored --nocapture
|
||||
//!
|
||||
//! # Run the howtocook SSE test
|
||||
//! cargo test -p mcp-stdio-proxy test_howtocook_sse_protocol_detection -- --ignored --nocapture
|
||||
//!
|
||||
//! # Run all protocol detection network tests
|
||||
//! DASHSCOPE_API_KEY=sk-xxx cargo test -p mcp-stdio-proxy protocol_detection_test -- --ignored --nocapture
|
||||
//!
|
||||
//! # Run with debug logging
|
||||
//! DASHSCOPE_API_KEY=sk-xxx RUST_LOG=debug cargo test -p mcp-stdio-proxy protocol_detection_test -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use crate::model::{McpJsonServerParameters, McpProtocol, McpServerConfig};
|
||||
|
||||
/// The howtocook SSE MCP JSON config (SSE protocol, 测试环境密钥)
|
||||
const HOWTOCOOK_MCP_JSON: &str = r#"{
|
||||
"mcpServers": {
|
||||
"howtocook-跳跳糖": {
|
||||
"type": "sse",
|
||||
"url": "https://testagent.xspaceagi.com/api/mcp/sse?ak=ak-27b83516dfd4417a82f764fe3e859a6e"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// Build zimage MCP JSON config with Authorization token from environment variable
|
||||
///
|
||||
/// 环境变量: `DASHSCOPE_API_KEY`
|
||||
/// 运行网络测试前需设置: `export DASHSCOPE_API_KEY=sk-xxx`
|
||||
fn build_zimage_mcp_json(api_key: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"zimage": {{
|
||||
"type": "streamableHttp",
|
||||
"description": "Z-Image-Turbo 图像生成模型",
|
||||
"isActive": true,
|
||||
"name": "阿里云百炼_Z Image 图像生成",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/zimage/mcp",
|
||||
"headers": {{
|
||||
"Authorization": "Bearer {}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}"#,
|
||||
api_key
|
||||
)
|
||||
}
|
||||
|
||||
/// 用于本地解析测试的占位密钥(不需要真实密钥)
|
||||
const ZIMAGE_TEST_PLACEHOLDER_KEY: &str = "test-placeholder-key";
|
||||
|
||||
// ==================== 本地解析测试(无网络) ====================
|
||||
|
||||
#[test]
|
||||
fn test_zimage_config_type_parsed_as_stream() {
|
||||
let zimage_json = build_zimage_mcp_json(ZIMAGE_TEST_PLACEHOLDER_KEY);
|
||||
let params = McpJsonServerParameters::from(zimage_json);
|
||||
let config = params.try_get_first_mcp_server().unwrap();
|
||||
|
||||
match config {
|
||||
McpServerConfig::Url(url_config) => {
|
||||
// 1. 原始 type 字段值
|
||||
assert_eq!(url_config.r#type, Some("streamableHttp".to_string()));
|
||||
|
||||
// 2. get_protocol_type() 返回 Some,且 is_streamable
|
||||
let protocol_type = url_config.get_protocol_type();
|
||||
assert!(
|
||||
protocol_type.is_some(),
|
||||
"streamableHttp should be recognized"
|
||||
);
|
||||
assert!(protocol_type.as_ref().unwrap().is_streamable());
|
||||
|
||||
// 3. 转换为 McpProtocol::Stream
|
||||
assert_eq!(
|
||||
protocol_type.unwrap().to_mcp_protocol(),
|
||||
McpProtocol::Stream
|
||||
);
|
||||
|
||||
// 4. FromStr 解析也能识别 "streamableHttp"
|
||||
assert_eq!(
|
||||
"streamableHttp".parse::<McpProtocol>(),
|
||||
Ok(McpProtocol::Stream)
|
||||
);
|
||||
|
||||
// 5. URL 正确解析
|
||||
assert_eq!(
|
||||
url_config.get_url(),
|
||||
"https://dashscope.aliyuncs.com/api/v1/mcps/zimage/mcp"
|
||||
);
|
||||
|
||||
// 6. headers 包含 Authorization
|
||||
let headers = url_config.headers.as_ref().unwrap();
|
||||
assert!(headers.contains_key("Authorization"));
|
||||
assert_eq!(
|
||||
headers["Authorization"],
|
||||
format!("Bearer {}", ZIMAGE_TEST_PLACEHOLDER_KEY)
|
||||
);
|
||||
}
|
||||
McpServerConfig::Command(_) => panic!("Expected URL config"),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 网络探测测试(需要网络,默认 ignore) ====================
|
||||
|
||||
/// Test: SSE detector should return false for a Streamable HTTP service
|
||||
///
|
||||
/// 使用真实 zimage 服务验证:
|
||||
/// - is_sse_with_headers → false(不是 SSE)
|
||||
/// - is_streamable_http_with_headers → true(是 Streamable HTTP)
|
||||
/// - detect_mcp_protocol_with_headers → Stream
|
||||
///
|
||||
/// 需要设置环境变量: `DASHSCOPE_API_KEY`
|
||||
#[tokio::test]
|
||||
#[ignore] // 需要网络访问 + DASHSCOPE_API_KEY 环境变量
|
||||
async fn test_zimage_protocol_detection() {
|
||||
let api_key = std::env::var("DASHSCOPE_API_KEY")
|
||||
.expect("需要设置 DASHSCOPE_API_KEY 环境变量才能运行此测试");
|
||||
let zimage_json = build_zimage_mcp_json(&api_key);
|
||||
let params = McpJsonServerParameters::from(zimage_json);
|
||||
let config = params.try_get_first_mcp_server().unwrap();
|
||||
|
||||
let (url, headers) = match config {
|
||||
McpServerConfig::Url(url_config) => {
|
||||
let url = url_config.get_url().to_string();
|
||||
let headers = url_config.headers.clone().unwrap_or_default();
|
||||
(url, headers)
|
||||
}
|
||||
_ => panic!("Expected URL config"),
|
||||
};
|
||||
|
||||
println!("=== Protocol detection test: {} ===", url);
|
||||
|
||||
// 1. SSE 探测应返回 false
|
||||
println!("\\n--- SSE Probing ---");
|
||||
let is_sse = mcp_sse_proxy::is_sse_with_headers(&url, Some(&headers)).await;
|
||||
println!("is_sse_with_headers = {}", is_sse);
|
||||
assert!(!is_sse, "Streamable HTTP 服务不应被识别为 SSE");
|
||||
|
||||
// 2. Streamable HTTP 探测应返回 true
|
||||
println!("\\n--- Streamable HTTP probe ---");
|
||||
let is_stream =
|
||||
mcp_streamable_proxy::is_streamable_http_with_headers(&url, Some(&headers)).await;
|
||||
println!("is_streamable_http_with_headers = {}", is_stream);
|
||||
assert!(
|
||||
is_stream,
|
||||
"zimage 服务应被识别为 Streamable HTTP(需要传递 Authorization header)"
|
||||
);
|
||||
|
||||
// 3. 综合探测应返回 Stream
|
||||
println!("\\n--- Comprehensive protocol detection ---");
|
||||
let detected = crate::server::detect_mcp_protocol_with_headers(&url, Some(&headers))
|
||||
.await
|
||||
.unwrap();
|
||||
println!("detect_mcp_protocol_with_headers = {:?}", detected);
|
||||
assert_eq!(detected, McpProtocol::Stream, "综合探测应返回 Stream 协议");
|
||||
|
||||
println!("\\n=== All detection results are correct ===");
|
||||
}
|
||||
|
||||
// ==================== howtocook SSE 本地解析测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_howtocook_config_type_parsed_as_sse() {
|
||||
let params = McpJsonServerParameters::from(HOWTOCOOK_MCP_JSON.to_string());
|
||||
let config = params.try_get_first_mcp_server().unwrap();
|
||||
|
||||
match config {
|
||||
McpServerConfig::Url(url_config) => {
|
||||
// 1. 原始 type 字段值
|
||||
assert_eq!(url_config.r#type, Some("sse".to_string()));
|
||||
|
||||
// 2. get_protocol_type() 返回 Some,且不是 streamable
|
||||
let protocol_type = url_config.get_protocol_type();
|
||||
assert!(protocol_type.is_some(), "sse should be recognized");
|
||||
assert!(!protocol_type.as_ref().unwrap().is_streamable());
|
||||
|
||||
// 3. 转换为 McpProtocol::Sse
|
||||
assert_eq!(protocol_type.unwrap().to_mcp_protocol(), McpProtocol::Sse);
|
||||
|
||||
// 4. FromStr 解析也能识别 "sse"
|
||||
assert_eq!("sse".parse::<McpProtocol>(), Ok(McpProtocol::Sse));
|
||||
assert_eq!("SSE".parse::<McpProtocol>(), Ok(McpProtocol::Sse));
|
||||
|
||||
// 5. URL 正确解析(使用 url 字段而非 baseUrl)
|
||||
assert_eq!(
|
||||
url_config.get_url(),
|
||||
"https://testagent.xspaceagi.com/api/mcp/sse?ak=ak-27b83516dfd4417a82f764fe3e859a6e"
|
||||
);
|
||||
|
||||
// 6. 无 headers
|
||||
assert!(url_config.headers.is_none());
|
||||
}
|
||||
McpServerConfig::Command(_) => panic!("Expected URL config"),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== howtocook SSE 网络探测测试 ====================
|
||||
|
||||
/// Test: SSE detector should return true for a real SSE service
|
||||
///
|
||||
/// 使用真实 howtocook SSE 服务验证:
|
||||
/// - is_sse_with_headers → true(是 SSE,应探测到 event: endpoint)
|
||||
/// - detect_mcp_protocol_with_headers → Sse
|
||||
#[tokio::test]
|
||||
#[ignore] // 需要网络访问,使用 --ignored 运行
|
||||
async fn test_howtocook_sse_protocol_detection() {
|
||||
let params = McpJsonServerParameters::from(HOWTOCOOK_MCP_JSON.to_string());
|
||||
let config = params.try_get_first_mcp_server().unwrap();
|
||||
|
||||
let url = match config {
|
||||
McpServerConfig::Url(url_config) => url_config.get_url().to_string(),
|
||||
_ => panic!("Expected URL config"),
|
||||
};
|
||||
|
||||
println!("=== SSE protocol detection test: {} ===", url);
|
||||
|
||||
// 1. SSE 探测应返回 true(该服务是真实 MCP SSE,会发送 event: endpoint)
|
||||
println!("\\n--- SSE Probing ---");
|
||||
let is_sse = mcp_sse_proxy::is_sse(&url).await;
|
||||
println!("is_sse = {}", is_sse);
|
||||
assert!(
|
||||
is_sse,
|
||||
"howtocook SSE 服务应被识别为 SSE(发现 event: endpoint)"
|
||||
);
|
||||
|
||||
// 2. Streamable HTTP 探测应返回 false(SSE 服务不应被识别为 Streamable HTTP)
|
||||
println!("\\n--- Streamable HTTP probe ---");
|
||||
let is_stream = mcp_streamable_proxy::is_streamable_http(&url).await;
|
||||
println!("is_streamable_http = {}", is_stream);
|
||||
assert!(!is_stream, "SSE 服务不应被识别为 Streamable HTTP");
|
||||
|
||||
// 3. 综合探测应返回 Sse
|
||||
println!("\\n--- Comprehensive protocol detection ---");
|
||||
let detected = crate::server::detect_mcp_protocol(&url).await.unwrap();
|
||||
println!("detect_mcp_protocol = {:?}", detected);
|
||||
assert_eq!(detected, McpProtocol::Sse, "综合探测应返回 Sse 协议");
|
||||
|
||||
println!("\\n=== SSE detection result is correct ===");
|
||||
}
|
||||
|
||||
/// Test: protocol detection without headers should still default to Stream
|
||||
///
|
||||
/// 不传 Authorization header,探测可能失败,但应兜底为 Stream
|
||||
#[tokio::test]
|
||||
#[ignore] // 需要网络访问
|
||||
async fn test_zimage_detection_without_headers() {
|
||||
let url = "https://dashscope.aliyuncs.com/api/v1/mcps/zimage/mcp";
|
||||
|
||||
println!("=== No header detection test: {} ===", url);
|
||||
|
||||
// SSE 探测应返回 false
|
||||
let is_sse = mcp_sse_proxy::is_sse(url).await;
|
||||
println!("is_sse = {}", is_sse);
|
||||
assert!(!is_sse);
|
||||
|
||||
// 综合探测应兜底为 Stream
|
||||
let detected = crate::server::detect_mcp_protocol(url).await.unwrap();
|
||||
println!("detect_mcp_protocol = {:?}", detected);
|
||||
assert_eq!(detected, McpProtocol::Stream, "无 header 时应兜底为 Stream");
|
||||
|
||||
println!("=== No header, the detection result is correct ===");
|
||||
}
|
||||
Reference in New Issue
Block a user