diff --git a/docs/claude-desktop-config-simple.json b/docs/claude-desktop-config-simple.json new file mode 100644 index 0000000..e6705b7 --- /dev/null +++ b/docs/claude-desktop-config-simple.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "promptx": { + "command": "node", + "args": [ + "/Users/YOUR_USERNAME/WorkSpaces/DeepracticeProjects/PromptX/src/bin/promptx.js", + "mcp-server" + ] + } + } +} \ No newline at end of file diff --git a/docs/claude-desktop-config-windows.json b/docs/claude-desktop-config-windows.json new file mode 100644 index 0000000..baa298d --- /dev/null +++ b/docs/claude-desktop-config-windows.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "promptx": { + "command": "node", + "args": [ + "C:\\Users\\YOUR_USERNAME\\WorkSpaces\\DeepracticeProjects\\PromptX\\src\\bin\\promptx.js", + "mcp-server" + ] + } + } +} \ No newline at end of file diff --git a/docs/mcp-http-sse-implementation-plan.md b/docs/mcp-http-sse-implementation-plan.md new file mode 100644 index 0000000..ccb7d1d --- /dev/null +++ b/docs/mcp-http-sse-implementation-plan.md @@ -0,0 +1,291 @@ +# MCP Streamable HTTP 传输实现规划 + +## 概述 + +本文档规划在 PromptX 项目中实现 MCP (Model Context Protocol) Streamable HTTP 传输的技术方案,同时提供 SSE 向后兼容支持。 + +## 背景分析 + +### 当前状态 +- PromptX 目前仅支持 stdio 传输方式 (`MCPServerCommand.js`) +- 使用 `@modelcontextprotocol/sdk@1.12.1`,已包含 SSE 传输支持 +- 启动方式:`pnpm start mcp-server` (默认 stdio) + +### 需求驱动 +- 需要支持基于 HTTP 的 MCP 服务器实例 +- 为 Web 客户端和远程访问提供现代化支持 +- 采用最新 MCP 协议标准,确保长期兼容性 +- 提供更灵活的部署选项 + +## 技术方案 + +### 传输协议选择 + +#### Streamable HTTP 传输(主要方案) +- **状态**: MCP 协议当前推荐的标准传输方式 +- **特点**: + - 统一 HTTP POST 端点 + - 无状态连接,支持 SSE 可选升级 + - 支持会话管理和连接恢复 +- **优势**: + - 现代化架构,更好的可扩展性 + - 简化客户端实现 + - 更好的负载均衡支持 + - 符合 REST 架构原则 + +#### SSE 传输(兼容方案) +- **状态**: 在协议版本 2024-11-05 中被标记为弃用 +- **特点**: 双端点架构(GET 建立 SSE 流,POST 接收消息) +- **适用**: 向后兼容现有客户端,过渡期使用 + +### 实现架构 + +#### 方案一:扩展现有 MCPServerCommand + +**优势**: +- 保持代码统一性 +- 复用现有逻辑 +- 最小化改动 + +**实现路径**: +```javascript +// MCPServerCommand.js 修改 +async execute(options = {}) { + const { transport = 'stdio', port = 3000 } = options; + + switch (transport) { + case 'stdio': + return this.startStdioServer(); + case 'http': + return this.startStreamableHttpServer(port); + case 'sse': + return this.startSSEServer(port); // 兼容支持 + default: + throw new Error(`Unsupported transport: ${transport}`); + } +} +``` + +#### 方案二:创建专用 HTTP 服务器命令 + +**优势**: +- 职责分离,代码清晰 +- 便于独立测试和维护 +- 避免原有功能的副作用 + +**实现路径**: +``` +src/lib/commands/ +├── MCPServerCommand.js # stdio 传输 +├── MCPStreamableHttpCommand.js # Streamable HTTP 传输(主要) +└── index.js # 命令导出 +``` + +### 详细设计 + +#### Streamable HTTP 服务器实现 + +```javascript +// 基础架构 +class MCPStreamableHttpCommand { + constructor() { + this.name = 'promptx-mcp-streamable-http-server'; + this.version = '1.0.0'; + } + + async execute(options = {}) { + const { + transport = 'http', // 'http' | 'sse' + port = 3000, + host = 'localhost' + } = options; + + if (transport === 'http') { + return this.startStreamableHttpServer(port, host); + } else if (transport === 'sse') { + return this.startSSEServer(port, host); // 兼容支持 + } + } + + async startStreamableHttpServer(port, host) { + // 使用 StreamableHttpServerTransport + // 实现现代化统一端点架构 + } + + async startSSEServer(port, host) { + // 使用 Express + SSEServerTransport + // 向后兼容双端点架构 + } +} +``` + +#### 端点设计 + +**Streamable HTTP 端点**(主要): +- `POST /mcp` - 统一入口端点 + - 接收所有 JSON-RPC 消息 + - 支持可选 SSE 流式响应 + - 支持会话管理(sessionId) + - 无状态设计,便于负载均衡 + +**SSE 传输端点**(兼容): +- `GET /mcp` - 建立 SSE 连接 +- `POST /messages` - 接收客户端消息 + +#### 配置选项 + +```javascript +// 命令行参数 +{ + transport: 'stdio' | 'http' | 'sse', // 'http' 为推荐默认值 + port: number, // HTTP 端口 (默认: 3000) + host: string, // 绑定地址 (默认: localhost) + cors: boolean, // CORS 支持 (默认: false) + auth: boolean, // 认证开关 (默认: false) + streaming: boolean, // SSE 流式响应 (默认: true) + maxConnections: number // 最大连接数 (默认: 100) +} +``` + +## 实现计划 + +### 阶段 1: Streamable HTTP 传输支持(主要目标) + +**目标**: 实现 MCP 推荐的 Streamable HTTP 传输 + +**任务**: +1. 创建 `MCPStreamableHttpCommand.js` +2. 实现 StreamableHttpServerTransport 集成 +3. 支持统一端点架构和可选 SSE 升级 +4. 集成现有 MCP 工具处理逻辑 +5. 添加命令行参数支持 +6. 编写单元测试 + +**预期成果**: +```bash +# 启动 Streamable HTTP 服务器 +pnpm start mcp-server --transport http --port 3000 +``` + +### 阶段 2: SSE 传输兼容支持 + +**目标**: 实现 SSE 传输的向后兼容 + +**任务**: +1. 在同一命令中添加 SSE 传输支持 +2. 实现 SSE 双端点架构 +3. 添加传输类型切换逻辑 +4. 性能优化和错误处理 +5. 兼容性测试 + +**预期成果**: +```bash +# 启动 SSE 服务器(兼容模式) +pnpm start mcp-server --transport sse --port 3000 +``` + +### 阶段 3: 生产化增强 + +**目标**: 完善生产环境特性 + +**任务**: +1. CORS 跨域支持 +2. 认证机制集成 +3. 连接池和限流 +4. 监控和日志增强 +5. Docker 部署支持 + +**预期成果**: +- 生产就绪的 Streamable HTTP MCP 服务器 +- 完整的部署文档 +- 性能基准测试报告 + +## 配置管理 + +### 环境变量支持 +```bash +MCP_TRANSPORT=http # 传输类型(推荐默认值) +MCP_PORT=3000 # 服务端口 +MCP_HOST=localhost # 绑定地址 +MCP_CORS_ENABLED=false # CORS 开关 +MCP_STREAMING=true # SSE 流式响应 +MCP_MAX_CONNECTIONS=100 # 最大连接数 +``` + + +## 测试策略 + +### 单元测试 +- 传输类型选择逻辑 +- HTTP 端点处理 +- 错误处理机制 +- 参数验证 + +### 集成测试 +- 完整 MCP 会话流程 +- 多客户端并发连接 +- 传输协议兼容性 +- 工具调用端到端测试 + + +## 部署考虑 + +### 开发环境 +- 本地调试支持 +- 热重载机制 +- 详细日志输出 + +### 生产环境 +- 进程管理 (PM2) +- 反向代理 (Nginx) +- HTTPS 支持 +- 监控告警 + +## 兼容性 + +### MCP 客户端兼容性 +- Claude Desktop +- MCP Inspector +- 自定义 MCP 客户端 + +### 协议版本兼容性 +- 支持当前协议版本 +- 向后兼容弃用特性 +- 平滑迁移路径 + +## 风险评估 + +### 技术风险 +- SSE 传输弃用风险 → 优先实现 Streamable HTTP +- 并发性能瓶颈 → 连接池和限流机制 +- 内存泄漏风险 → 完善资源清理 + +### 维护风险 +- 代码复杂度增加 → 清晰的架构分层 +- 测试覆盖率下降 → 完善的测试策略 + +## 成功指标 + +### 功能指标 +- [ ] 支持 Streamable HTTP 传输启动 +- [ ] 支持 SSE 兼容传输 +- [ ] 多传输类型无缝切换 +- [ ] 完整的工具调用功能 + +### 性能指标 +- 支持 > 50 并发连接 +- 消息延迟 < 100ms +- 内存使用 < 500MB + +### 质量指标 +- 测试覆盖率 > 80% +- 零安全漏洞 +- 完整的文档覆盖 + +## 参考资料 + +- [MCP 官方文档 - Transports](https://modelcontextprotocol.io/docs/concepts/transports) +- [MCP SDK 示例 - Streamable HTTP Server](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/simpleStreamableHttp.js) +- [MCP SDK 示例 - SSE Server](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/simpleSseServer.js) +- [Streamable HTTP 实现指南](https://blog.christianposta.com/ai/understanding-mcp-recent-change-around-http-sse/) +- [MCP 协议变更说明](https://blog.christianposta.com/ai/understanding-mcp-recent-change-around-http-sse/) \ No newline at end of file diff --git a/docs/mcp-streamable-http-implementation-plan.md b/docs/mcp-streamable-http-implementation-plan.md new file mode 100644 index 0000000..9142476 --- /dev/null +++ b/docs/mcp-streamable-http-implementation-plan.md @@ -0,0 +1,864 @@ +# MCP Streamable HTTP 传输实现规划 + +## 概述 + +本文档规划在 PromptX 项目中实现 MCP (Model Context Protocol) Streamable HTTP 传输的技术方案,同时提供 SSE 向后兼容支持。 + +## 背景分析 + +### 当前状态 +- PromptX 目前仅支持 stdio 传输方式 (`MCPServerCommand.js`) +- 使用 `@modelcontextprotocol/sdk@1.12.1`,已包含 SSE 传输支持 +- 启动方式:`pnpm start mcp-server` (默认 stdio) + +### 需求驱动 +- 需要支持基于 HTTP 的 MCP 服务器实例 +- 为 Web 客户端和远程访问提供现代化支持 +- 采用最新 MCP 协议标准,确保长期兼容性 +- 提供更灵活的部署选项 + +## 技术方案 + +### 依赖管理 + +基于官方示例和稳定性考虑,本实现使用 Express.js 框架: + +```bash +# MCP SDK(已安装) +@modelcontextprotocol/sdk@1.12.1 + +# Express 框架(新增) +express@^5.1.0 +``` + +**选择 Express.js 的原因:** +1. **官方示例一致性** - MCP SDK 官方示例均使用 Express.js +2. **测试稳定性** - Express 提供更完善的中间件和错误处理机制 +3. **开发效率** - 简化 CORS、JSON 解析等常见 HTTP 处理需求 +4. **社区支持** - 成熟的生态系统和丰富的文档资源 + +### 传输协议选择 + +#### Streamable HTTP 传输(主要方案) +- **状态**: MCP 协议当前推荐的标准传输方式 +- **特点**: + - 统一 HTTP POST 端点 + - 无状态连接,支持 SSE 可选升级 + - 支持会话管理和连接恢复 +- **优势**: + - 现代化架构,更好的可扩展性 + - 简化客户端实现 + - 更好的负载均衡支持 + - 符合 REST 架构原则 + +#### SSE 传输(兼容方案) +- **状态**: 在协议版本 2024-11-05 中被标记为弃用 +- **特点**: 双端点架构(GET 建立 SSE 流,POST 接收消息) +- **适用**: 向后兼容现有客户端,过渡期使用 + +### 实现架构 + +#### 方案一:扩展现有 MCPServerCommand + +**优势**: +- 保持代码统一性 +- 复用现有逻辑 +- 最小化改动 + +**实现路径**: +```javascript +// MCPServerCommand.js 修改 +async execute(options = {}) { + const { transport = 'stdio', port = 3000 } = options; + + switch (transport) { + case 'stdio': + return this.startStdioServer(); + case 'http': + return this.startStreamableHttpServer(port); + case 'sse': + return this.startSSEServer(port); // 兼容支持 + default: + throw new Error(`Unsupported transport: ${transport}`); + } +} +``` + +#### 方案二:创建专用 HTTP 服务器命令 + +**优势**: +- 职责分离,代码清晰 +- 便于独立测试和维护 +- 避免原有功能的副作用 + +**实现路径**: +``` +src/lib/commands/ +├── MCPServerCommand.js # stdio 传输 +├── MCPStreamableHttpCommand.js # Streamable HTTP 传输(主要) +└── index.js # 命令导出 +``` + +### 详细设计 + +#### Streamable HTTP 服务器实现 + +```javascript +// 基础架构 +class MCPStreamableHttpCommand { + constructor() { + this.name = 'promptx-mcp-streamable-http-server'; + this.version = '1.0.0'; + } + + async execute(options = {}) { + const { + transport = 'http', // 'http' | 'sse' + port = 3000, + host = 'localhost' + } = options; + + if (transport === 'http') { + return this.startStreamableHttpServer(port, host); + } else if (transport === 'sse') { + return this.startSSEServer(port, host); // 兼容支持 + } + } + + async startStreamableHttpServer(port, host) { + // 使用 Express + StreamableHttpServerTransport + // 实现现代化统一端点架构 + const app = express(); + app.use(express.json()); + app.use(corsMiddleware); + app.post('/mcp', handleMCPPostRequest); + // 健康检查和其他端点 + } + + async startSSEServer(port, host) { + // 使用 Express + SSEServerTransport + // 向后兼容双端点架构 + const app = express(); + app.get('/mcp', handleSSEConnection); + app.post('/messages', handleSSEMessage); + } +} +``` + +#### 端点设计 + +**Streamable HTTP 端点**(主要): +- `POST /mcp` - 统一入口端点 + - 接收所有 JSON-RPC 消息 + - 支持可选 SSE 流式响应 + - 支持会话管理(sessionId) + - 无状态设计,便于负载均衡 + +**SSE 传输端点**(兼容): +- `GET /mcp` - 建立 SSE 连接 +- `POST /messages` - 接收客户端消息 + +#### 配置选项 + +```javascript +// 命令行参数 +{ + transport: 'stdio' | 'http' | 'sse', // 'http' 为推荐默认值 + port: number, // HTTP 端口 (默认: 3000) + host: string, // 绑定地址 (默认: localhost) + cors: boolean, // CORS 支持 (默认: false) + auth: boolean, // 认证开关 (默认: false) + streaming: boolean, // SSE 流式响应 (默认: true) + maxConnections: number // 最大连接数 (默认: 100) +} +``` + +## 实现计划 + +### 阶段 1: Streamable HTTP 传输支持(主要目标) + +**目标**: 实现 MCP 推荐的 Streamable HTTP 传输 + +**任务**: +1. 创建 `MCPStreamableHttpCommand.js` +2. 实现 StreamableHttpServerTransport 集成 +3. 支持统一端点架构和可选 SSE 升级 +4. 集成现有 MCP 工具处理逻辑 +5. 添加命令行参数支持 +6. 编写单元测试 + +**预期成果**: +```bash +# 启动 Streamable HTTP 服务器 +pnpm start mcp-server --transport http --port 3000 +``` + +### 阶段 2: SSE 传输兼容支持 + +**目标**: 实现 SSE 传输的向后兼容 + +**任务**: +1. 在同一命令中添加 SSE 传输支持 +2. 实现 SSE 双端点架构 +3. 添加传输类型切换逻辑 +4. 性能优化和错误处理 +5. 兼容性测试 + +**预期成果**: +```bash +# 启动 SSE 服务器(兼容模式) +pnpm start mcp-server --transport sse --port 3000 +``` + +### 阶段 3: 生产化增强 + +**目标**: 完善生产环境特性 + +**任务**: +1. CORS 跨域支持 +2. 认证机制集成 +3. 连接池和限流 +4. 监控和日志增强 +5. Docker 部署支持 + +**预期成果**: +- 生产就绪的 Streamable HTTP MCP 服务器 +- 完整的部署文档 +- 性能基准测试报告 + +## 配置管理 + +### 环境变量支持 +```bash +MCP_TRANSPORT=http # 传输类型(推荐默认值) +MCP_PORT=3000 # 服务端口 +MCP_HOST=localhost # 绑定地址 +MCP_CORS_ENABLED=false # CORS 开关 +MCP_STREAMING=true # SSE 流式响应 +MCP_MAX_CONNECTIONS=100 # 最大连接数 +``` + +### 配置文件支持 +```json +// package.json scripts 扩展 +{ + "scripts": { + "mcp:stdio": "node src/bin/promptx.js mcp-server", + "mcp:http": "node src/bin/promptx.js mcp-server --transport http", + "mcp:sse": "node src/bin/promptx.js mcp-server --transport sse", + "mcp:dev": "MCP_DEBUG=true node src/bin/promptx.js mcp-server --transport http --port 3001" + } +} +``` + +## 测试策略 + +### 单元测试 +- 传输类型选择逻辑 +- HTTP 端点处理 +- 错误处理机制 +- 参数验证 + +### 集成测试 +- 完整 MCP 会话流程 +- 多客户端并发连接 +- 传输协议兼容性 +- 工具调用端到端测试 + +### 性能测试 +- 并发连接压力测试 +- 消息吞吐量测试 +- 内存和 CPU 使用率监控 + +## 部署考虑 + +### 开发环境 +- 本地调试支持 +- 热重载机制 +- 详细日志输出 + +### 生产环境 +- 进程管理 (PM2) +- 反向代理 (Nginx) +- HTTPS 支持 +- 监控告警 + +## 客户端配置指南 + +### Claude Desktop 配置 + +#### 推荐配置(官方标准方式) + +**配置文件路径**: +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +##### 方式一:Stdio 传输(推荐,最简单) + +```json +{ + "mcpServers": { + "promptx": { + "command": "node", + "args": [ + "/absolute/path/to/PromptX/src/bin/promptx.js", + "mcp-server" + ] + } + } +} +``` + +**Windows 示例**: +```json +{ + "mcpServers": { + "promptx": { + "command": "node", + "args": [ + "C:\\Users\\你的用户名\\WorkSpaces\\DeepracticeProjects\\PromptX\\src\\bin\\promptx.js", + "mcp-server" + ] + } + } +} +``` + +##### 方式二:使用 npx 运行(如果发布到 npm) + +```json +{ + "mcpServers": { + "promptx": { + "command": "npx", + "args": [ + "-y", + "dpml-prompt", + "mcp-server" + ] + } + } +} +``` + +#### HTTP 传输配置(高级用法) + +⚠️ **注意**: HTTP 传输配置比较复杂,仅在有特殊需求时使用。 + +##### 跨平台 HTTP 配置 + +**macOS/Linux** (有 curl): +```json +{ + "mcpServers": { + "promptx-http": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", "Accept: application/json, text/event-stream", + "--data-binary", "@-", + "http://localhost:3000/mcp" + ] + } + } +} +``` + +**Windows** (使用 Node.js 脚本): +```json +{ + "mcpServers": { + "promptx-http": { + "command": "node", + "args": [ + "C:\\path\\to\\PromptX\\scripts\\mcp-http-client.js" + ] + } + } +} +``` + +#### 生产环境配置 + +对于生产环境,建议使用以下配置: + +```json +{ + "mcpServers": { + "promptx-prod": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", "Accept: application/json, text/event-stream", + "-H", "User-Agent: Claude-Desktop/1.0", + "--timeout", "30", + "--retry", "3", + "--data-binary", "@-", + "https://your-domain.com/mcp" + ], + "env": { + "MCP_DEBUG": "false", + "HTTP_TIMEOUT": "30000" + } + } + } +} +``` + +#### SSE 传输配置(兼容模式) + +```json +{ + "mcpServers": { + "promptx-sse": { + "command": "curl", + "args": [ + "-X", "GET", + "-H", "Accept: text/event-stream", + "-H", "Cache-Control: no-cache", + "http://localhost:3000/mcp" + ], + "env": { + "MCP_DEBUG": "true" + } + } + } +} +``` + +### 配置文件管理 + +#### 配置文件创建步骤 + +1. **查找配置文件位置** + ```bash + # macOS + ls -la ~/Library/Application\ Support/Claude/ + + # Windows (PowerShell) + ls $env:APPDATA\Claude\ + ``` + +2. **创建配置文件**(如果不存在) + ```bash + # macOS + mkdir -p ~/Library/Application\ Support/Claude/ + touch ~/Library/Application\ Support/Claude/claude_desktop_config.json + + # Windows (PowerShell) + New-Item -ItemType Directory -Force -Path $env:APPDATA\Claude\ + New-Item -ItemType File -Force -Path $env:APPDATA\Claude\claude_desktop_config.json + ``` + +3. **验证配置** + ```bash + # 测试配置文件语法 + cat ~/Library/Application\ Support/Claude/claude_desktop_config.json | jq . + ``` + +#### 配置文件模板 + +我们提供了一个完整的配置文件模板:`docs/claude-desktop-config-example.json` + +你可以直接复制这个文件到你的 Claude Desktop 配置目录: + +```bash +# macOS +cp docs/claude-desktop-config-example.json ~/Library/Application\ Support/Claude/claude_desktop_config.json + +# Windows (PowerShell) +Copy-Item docs/claude-desktop-config-example.json $env:APPDATA\Claude\claude_desktop_config.json +``` + +**重要**: 记得将配置文件中的 `/Users/YOUR_USERNAME/` 替换为你的实际用户路径。 + +#### 快速配置脚本 + +```bash +#!/bin/bash +# 文件名: setup-claude-config.sh + +# 获取当前项目路径 +PROJECT_PATH=$(pwd) + +# 获取用户名 +USERNAME=$(whoami) + +# Claude Desktop 配置路径 +CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude" +CLAUDE_CONFIG_FILE="$CLAUDE_CONFIG_DIR/claude_desktop_config.json" + +# 创建配置目录 +mkdir -p "$CLAUDE_CONFIG_DIR" + +# 生成配置文件 +cat > "$CLAUDE_CONFIG_FILE" << EOF +{ + "mcpServers": { + "promptx-http": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", "Accept: application/json, text/event-stream", + "--data-binary", "@-", + "http://localhost:3000/mcp" + ], + "env": { + "MCP_DEBUG": "false" + } + }, + "promptx-stdio": { + "command": "node", + "args": [ + "$PROJECT_PATH/src/bin/promptx.js", + "mcp-server" + ], + "env": { + "MCP_DEBUG": "false" + } + } + }, + "globalShortcut": "Cmd+Shift+.", + "theme": "auto" +} +EOF + +echo "✅ Claude Desktop 配置已生成: $CLAUDE_CONFIG_FILE" +echo "🔄 请重启 Claude Desktop 以加载新配置" +``` + +使用方法: +```bash +chmod +x setup-claude-config.sh +./setup-claude-config.sh +``` + +#### 多环境配置 + +```json +{ + "mcpServers": { + "promptx-dev": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", "Accept: application/json, text/event-stream", + "--data-binary", "@-", + "http://localhost:3000/mcp" + ], + "env": { + "MCP_DEBUG": "true", + "NODE_ENV": "development" + } + }, + "promptx-staging": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", "Accept: application/json, text/event-stream", + "--data-binary", "@-", + "https://staging.your-domain.com/mcp" + ], + "env": { + "MCP_DEBUG": "false", + "NODE_ENV": "staging" + } + }, + "promptx-prod": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", "Accept: application/json, text/event-stream", + "-H", "Authorization: Bearer YOUR_API_TOKEN", + "--timeout", "30", + "--retry", "3", + "--data-binary", "@-", + "https://api.your-domain.com/mcp" + ], + "env": { + "MCP_DEBUG": "false", + "NODE_ENV": "production" + } + } + } +} +``` + +### 自定义客户端实现 + +#### JavaScript/TypeScript 客户端 + +```javascript +import { McpClient } from '@modelcontextprotocol/sdk/client/mcp.js'; +import { HttpTransport } from '@modelcontextprotocol/sdk/client/http.js'; + +// Streamable HTTP 客户端 +const transport = new HttpTransport({ + baseUrl: 'http://localhost:3000/mcp', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } +}); + +const client = new McpClient({ + name: 'promptx-client', + version: '1.0.0' +}, { + capabilities: { + tools: {} + } +}); + +await client.connect(transport); + +// 调用工具示例 +const result = await client.callTool('promptx_hello', {}); +console.log(result); +``` + +#### Python 客户端 + +```python +import asyncio +import aiohttp +import json + +class PromptXClient: + def __init__(self, base_url="http://localhost:3000"): + self.base_url = base_url + self.session_id = None + + async def initialize(self): + """初始化 MCP 连接""" + async with aiohttp.ClientSession() as session: + payload = { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "clientInfo": { + "name": "promptx-python-client", + "version": "1.0.0" + } + }, + "id": 1 + } + + async with session.post( + f"{self.base_url}/mcp", + json=payload, + headers={"Content-Type": "application/json"} + ) as response: + result = await response.json() + self.session_id = response.headers.get('mcp-session-id') + return result + + async def call_tool(self, tool_name, arguments=None): + """调用 PromptX 工具""" + if not self.session_id: + await self.initialize() + + async with aiohttp.ClientSession() as session: + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments or {} + }, + "id": 2 + } + + headers = { + "Content-Type": "application/json", + "mcp-session-id": self.session_id + } + + async with session.post( + f"{self.base_url}/mcp", + json=payload, + headers=headers + ) as response: + return await response.json() + +# 使用示例 +async def main(): + client = PromptXClient() + + # 调用角色发现工具 + result = await client.call_tool('promptx_hello') + print(result) + + # 激活产品经理角色 + result = await client.call_tool('promptx_action', {'role': 'product-manager'}) + print(result) + +asyncio.run(main()) +``` + +### MCP Inspector 配置 + +使用 MCP Inspector 进行调试和测试: + +```bash +# 安装 MCP Inspector +npm install -g @modelcontextprotocol/inspector + +# 连接到 PromptX HTTP 服务器 +mcp-inspector http://localhost:3000/mcp +``` + +### 服务器启动命令 + +在配置客户端之前,确保 PromptX 服务器已启动: + +```bash +# 启动 Streamable HTTP 服务器(推荐) +pnpm start mcp-server --transport http --port 3000 + +# 启动 SSE 服务器(兼容模式) +pnpm start mcp-server --transport sse --port 3000 + +# 启动时启用调试日志 +MCP_DEBUG=true pnpm start mcp-server --transport http --port 3000 +``` + +### 连接测试 + +#### 健康检查 + +```bash +# 测试服务器是否运行 +curl http://localhost:3000/health + +# 预期响应 +{ + "status": "ok", + "name": "promptx-mcp-streamable-http-server", + "version": "1.0.0", + "transport": "http" +} +``` + +#### 工具列表获取 + +```bash +# 获取可用工具列表(无需会话ID) +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/list", + "id": 1 + }' +``` + +**注意**: 必须包含 `Accept: application/json, text/event-stream` 头,否则会收到406错误。 + +#### 工具调用测试 + +```bash +# 调用角色发现工具 +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "mcp-session-id: YOUR_SESSION_ID" \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "promptx_hello", + "arguments": {} + }, + "id": 2 + }' +``` + +### 故障排除 + +#### 常见问题 + +1. **连接被拒绝** + ```bash + # 检查服务器是否运行 + curl http://localhost:3000/health + # 检查端口是否被占用 + lsof -i :3000 + ``` + +2. **CORS 错误** + ```bash + # 启动时启用 CORS(如果需要) + pnpm start mcp-server --transport http --port 3000 --cors + ``` + +3. **会话 ID 错误** + - 确保在工具调用时包含正确的 `mcp-session-id` 头 + - 对于新连接,先发送 `initialize` 请求 + +4. **工具调用失败** + ```bash + # 启用调试模式查看详细日志 + MCP_DEBUG=true pnpm start mcp-server --transport http --port 3000 + ``` + +## 兼容性 + +### MCP 客户端兼容性 +- Claude Desktop (通过 HTTP 配置) +- MCP Inspector +- 自定义 JavaScript/TypeScript 客户端 +- 自定义 Python 客户端 +- 任何支持 HTTP JSON-RPC 的客户端 + +### 协议版本兼容性 +- 支持当前协议版本 (2024-11-05) +- 向后兼容弃用特性 (SSE 传输) +- 平滑迁移路径 + +## 风险评估 + +### 技术风险 +- SSE 传输弃用风险 → 优先实现 Streamable HTTP +- 并发性能瓶颈 → 连接池和限流机制 +- 内存泄漏风险 → 完善资源清理 + +### 维护风险 +- 代码复杂度增加 → 清晰的架构分层 +- 测试覆盖率下降 → 完善的测试策略 + +## 成功指标 + +### 功能指标 +- [ ] 支持 Streamable HTTP 传输启动 +- [ ] 支持 SSE 兼容传输 +- [ ] 多传输类型无缝切换 +- [ ] 完整的工具调用功能 + +### 性能指标 +- 支持 > 50 并发连接 +- 消息延迟 < 100ms +- 内存使用 < 500MB + +### 质量指标 +- 测试覆盖率 > 80% +- 零安全漏洞 +- 完整的文档覆盖 + +## 参考资料 + +- [MCP 官方文档 - Transports](https://modelcontextprotocol.io/docs/concepts/transports) +- [MCP SDK 示例 - Streamable HTTP Server](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/simpleStreamableHttp.js) +- [MCP SDK 示例 - SSE Server](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/simpleSseServer.js) +- [Streamable HTTP 实现指南](https://blog.christianposta.com/ai/understanding-mcp-recent-change-around-http-sse/) +- [MCP 协议变更说明](https://blog.christianposta.com/ai/understanding-mcp-recent-change-around-http-sse/) \ No newline at end of file diff --git a/docs/usage/mcp-quick-start.md b/docs/usage/mcp-quick-start.md new file mode 100644 index 0000000..ac00491 --- /dev/null +++ b/docs/usage/mcp-quick-start.md @@ -0,0 +1,140 @@ +# PromptX MCP 快速上手 + +## 启动服务器 + +### 本地模式(推荐) +```bash +npx -f -y dpml-prompt@snapshot mcp-server +``` + +### HTTP 模式(远程访问) +```bash +npx -f -y dpml-prompt@snapshot mcp-server --transport http --port 3000 +``` + +检查服务器状态: +```bash +curl http://localhost:3000/health +``` + +## 客户端配置 + +### Claude Desktop(仅本地模式) + +**配置文件位置:** +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "promptx": { + "command": "npx", + "args": ["-f", "-y", "dpml-prompt@snapshot", "mcp-server"] + } + } +} +``` + +### VS Code + +创建 `.vscode/mcp.json`: + +**本地模式:** +```json +{ + "servers": { + "promptx": { + "command": "npx", + "args": ["-f", "-y", "dpml-prompt@snapshot", "mcp-server"] + } + } +} +``` + +**HTTP 模式:** +```json +{ + "servers": { + "promptx": { + "type": "http", + "url": "http://localhost:3000/mcp" + } + } +} +``` + +### Cursor + +**本地模式:** +```json +{ + "mcpServers": { + "promptx": { + "command": "npx", + "args": ["-f", "-y", "dpml-prompt@snapshot", "mcp-server"] + } + } +} +``` + +**HTTP 模式:** +```json +{ + "mcpServers": { + "promptx": { + "url": "http://localhost:3000/mcp" + } + } +} +``` + +### LibreChat + +编辑 `librechat.yaml`: + +**本地模式:** +```yaml +mcpServers: + promptx: + command: npx + args: + - -f + - -y + - dpml-prompt@snapshot + - mcp-server +``` + +**HTTP 模式:** +```yaml +mcpServers: + promptx: + type: streamable-http + url: http://localhost:3000/mcp +``` + +## 测试工具 + +重启客户端后,尝试使用以下工具: + +- `promptx_hello` - 查看可用角色 +- `promptx_action` - 激活角色(需要参数:role) +- `promptx_learn` - 学习资源(需要参数:resource) +- `promptx_recall` - 查看记忆 +- `promptx_remember` - 保存记忆(需要参数:content) + +## 故障排除 + +**服务器启动失败:** +- 检查 Node.js 版本:`node --version`(需要 >= 14) +- 确认网络连接正常(npx 需要下载包) + +**客户端连接失败:** +- 检查配置文件 JSON/YAML 语法 +- 重启客户端应用 +- 确认 npx 可以运行:`npx -f -y dpml-prompt@snapshot --help` + +**HTTP 模式报错:** +- 确认服务器正在运行 +- 检查防火墙设置 +- 使用 `curl` 测试连接 \ No newline at end of file diff --git a/package.json b/package.json index d3827bb..20f04be 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "chalk": "^4.1.2", "commander": "^11.0.0", "env-paths": "2.2.1", + "express": "^5.1.0", "find-monorepo-root": "^1.0.3", "find-pkg-dir": "^2.0.0", "find-up": "^7.0.0", @@ -63,7 +64,8 @@ "resolve": "^1.22.10", "resolve-package": "^1.0.1", "semver": "^7.5.0", - "yaml": "^2.3.0" + "yaml": "^2.3.0", + "zod": "^3.25.62" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7df72c..ee98d22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: env-paths: specifier: 2.2.1 version: 2.2.1 + express: + specifier: ^5.1.0 + version: 5.1.0 find-monorepo-root: specifier: ^1.0.3 version: 1.0.3 @@ -65,6 +68,9 @@ importers: yaml: specifier: ^2.3.0 version: 2.8.0 + zod: + specifier: ^3.25.62 + version: 3.25.62 devDependencies: '@changesets/changelog-github': specifier: ^0.5.1 @@ -2950,8 +2956,8 @@ packages: peerDependencies: zod: ^3.24.1 - zod@3.25.53: - resolution: {integrity: sha512-BKOKoY3XcGUVkqaalCtFK15LhwR0G0i65AClFpWSXLN2gJNBGlTktukHgwexCTa/dAacPPp9ReryXPWyeZF4LQ==} + zod@3.25.62: + resolution: {integrity: sha512-YCxsr4DmhPcrKPC9R1oBHQNlQzlJEyPAId//qTau/vBee9uO8K6prmRq4eMkOyxvBfH4wDPIPdLx9HVMWIY3xA==} snapshots: @@ -3566,8 +3572,8 @@ snapshots: express-rate-limit: 7.5.0(express@5.1.0) pkce-challenge: 5.0.0 raw-body: 3.0.0 - zod: 3.25.53 - zod-to-json-schema: 3.24.5(zod@3.25.53) + zod: 3.25.62 + zod-to-json-schema: 3.24.5(zod@3.25.62) transitivePeerDependencies: - supports-color @@ -6454,8 +6460,8 @@ snapshots: yocto-queue@1.2.1: {} - zod-to-json-schema@3.24.5(zod@3.25.53): + zod-to-json-schema@3.24.5(zod@3.25.62): dependencies: - zod: 3.25.53 + zod: 3.25.62 - zod@3.25.53: {} + zod@3.25.62: {} diff --git a/scripts/setup-claude-config.sh b/scripts/setup-claude-config.sh new file mode 100644 index 0000000..5f9616e --- /dev/null +++ b/scripts/setup-claude-config.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# PromptX Claude Desktop 配置生成脚本 +# 使用方法: ./scripts/setup-claude-config.sh + +set -e + +echo "🔧 设置 Claude Desktop 配置..." + +# 获取当前项目路径 +PROJECT_PATH=$(pwd) + +# 获取用户名 +USERNAME=$(whoami) + +# Claude Desktop 配置路径 +CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude" +CLAUDE_CONFIG_FILE="$CLAUDE_CONFIG_DIR/claude_desktop_config.json" + +# 检查操作系统 +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude" +elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then + # Windows + CLAUDE_CONFIG_DIR="$APPDATA/Claude" +else + echo "❌ 不支持的操作系统: $OSTYPE" + exit 1 +fi + +CLAUDE_CONFIG_FILE="$CLAUDE_CONFIG_DIR/claude_desktop_config.json" + +echo "📁 配置目录: $CLAUDE_CONFIG_DIR" +echo "📄 配置文件: $CLAUDE_CONFIG_FILE" + +# 创建配置目录 +mkdir -p "$CLAUDE_CONFIG_DIR" + +# 备份现有配置 +if [ -f "$CLAUDE_CONFIG_FILE" ]; then + BACKUP_FILE="${CLAUDE_CONFIG_FILE}.backup.$(date +%Y%m%d_%H%M%S)" + cp "$CLAUDE_CONFIG_FILE" "$BACKUP_FILE" + echo "💾 已备份现有配置到: $BACKUP_FILE" +fi + +# 生成配置文件 +cat > "$CLAUDE_CONFIG_FILE" << EOF +{ + "mcpServers": { + "promptx-http": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", "Accept: application/json, text/event-stream", + "--data-binary", "@-", + "http://localhost:3000/mcp" + ], + "env": { + "MCP_DEBUG": "false" + } + }, + "promptx-stdio": { + "command": "node", + "args": [ + "$PROJECT_PATH/src/bin/promptx.js", + "mcp-server" + ], + "env": { + "MCP_DEBUG": "false" + } + } + }, + "globalShortcut": "Cmd+Shift+.", + "theme": "auto" +} +EOF + +echo "✅ Claude Desktop 配置已生成" +echo "📝 配置文件内容:" +echo "----------------------------------------" +cat "$CLAUDE_CONFIG_FILE" +echo "----------------------------------------" + +# 验证JSON格式 +if command -v jq &> /dev/null; then + if jq . "$CLAUDE_CONFIG_FILE" > /dev/null 2>&1; then + echo "✅ JSON 格式验证通过" + else + echo "❌ JSON 格式验证失败" + exit 1 + fi +else + echo "⚠️ 建议安装 jq 来验证 JSON 格式: brew install jq" +fi + +echo "" +echo "🚀 下一步操作:" +echo "1. 启动 PromptX HTTP 服务器:" +echo " pnpm start mcp-server --transport http --port 3000" +echo "" +echo "2. 重启 Claude Desktop 应用" +echo "" +echo "3. 在 Claude Desktop 中应该能看到 PromptX 工具" + +# 检查服务器是否运行 +if curl -s http://localhost:3000/health > /dev/null 2>&1; then + echo "✅ PromptX HTTP 服务器正在运行" +else + echo "⚠️ PromptX HTTP 服务器未运行,请先启动服务器" +fi \ No newline at end of file diff --git a/scripts/setup-claude-simple.sh b/scripts/setup-claude-simple.sh new file mode 100644 index 0000000..ee95d8f --- /dev/null +++ b/scripts/setup-claude-simple.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# PromptX Claude Desktop 简单配置脚本(官方标准方式) + +set -e + +echo "🔧 设置 Claude Desktop 配置(官方标准方式)..." + +# 获取当前项目路径 +PROJECT_PATH=$(pwd) + +# Claude Desktop 配置路径 +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude" +elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then + # Windows + CLAUDE_CONFIG_DIR="$APPDATA/Claude" +else + echo "❌ 不支持的操作系统: $OSTYPE" + exit 1 +fi + +CLAUDE_CONFIG_FILE="$CLAUDE_CONFIG_DIR/claude_desktop_config.json" + +echo "📁 配置目录: $CLAUDE_CONFIG_DIR" +echo "📄 配置文件: $CLAUDE_CONFIG_FILE" + +# 创建配置目录 +mkdir -p "$CLAUDE_CONFIG_DIR" + +# 备份现有配置 +if [ -f "$CLAUDE_CONFIG_FILE" ]; then + BACKUP_FILE="${CLAUDE_CONFIG_FILE}.backup.$(date +%Y%m%d_%H%M%S)" + cp "$CLAUDE_CONFIG_FILE" "$BACKUP_FILE" + echo "💾 已备份现有配置到: $BACKUP_FILE" +fi + +# 生成简单配置文件(stdio传输) +cat > "$CLAUDE_CONFIG_FILE" << EOF +{ + "mcpServers": { + "promptx": { + "command": "node", + "args": [ + "$PROJECT_PATH/src/bin/promptx.js", + "mcp-server" + ] + } + } +} +EOF + +echo "✅ Claude Desktop 配置已生成(stdio 传输方式)" +echo "📝 配置文件内容:" +echo "----------------------------------------" +cat "$CLAUDE_CONFIG_FILE" +echo "----------------------------------------" + +# 验证JSON格式 +if command -v jq &> /dev/null; then + if jq . "$CLAUDE_CONFIG_FILE" > /dev/null 2>&1; then + echo "✅ JSON 格式验证通过" + else + echo "❌ JSON 格式验证失败" + exit 1 + fi +fi + +echo "" +echo "🚀 下一步操作:" +echo "1. 重启 Claude Desktop 应用" +echo "2. 在 Claude Desktop 中应该能看到 PromptX 工具" +echo "" +echo "💡 提示:" +echo "- 使用 stdio 传输,无需单独启动 HTTP 服务器" +echo "- 这是官方推荐的最简单配置方式" +echo "- 如需 HTTP 传输,请参考文档中的高级配置" \ No newline at end of file diff --git a/src/bin/promptx.js b/src/bin/promptx.js index a5e41d5..6efdc95 100755 --- a/src/bin/promptx.js +++ b/src/bin/promptx.js @@ -8,6 +8,7 @@ const packageJson = require('../../package.json') const { cli } = require('../lib/core/pouch') // 导入MCP Server命令 const { MCPServerCommand } = require('../lib/commands/MCPServerCommand') +const { MCPStreamableHttpCommand } = require('../lib/commands/MCPStreamableHttpCommand') // 创建主程序 const program = new Command() @@ -66,10 +67,36 @@ program program .command('mcp-server') .description('🔌 启动MCP Server,支持Claude Desktop等AI应用接入') + .option('-t, --transport ', '传输类型 (stdio|http|sse)', 'stdio') + .option('-p, --port ', 'HTTP端口号 (仅http/sse传输)', '3000') + .option('--host
', '绑定地址 (仅http/sse传输)', 'localhost') + .option('--cors', '启用CORS (仅http/sse传输)', false) + .option('--debug', '启用调试模式', false) .action(async (options) => { try { - const mcpServer = new MCPServerCommand(); - await mcpServer.execute(); + // 设置调试模式 + if (options.debug) { + process.env.MCP_DEBUG = 'true'; + } + + // 根据传输类型选择命令 + if (options.transport === 'stdio') { + const mcpServer = new MCPServerCommand(); + await mcpServer.execute(); + } else if (options.transport === 'http' || options.transport === 'sse') { + const mcpHttpServer = new MCPStreamableHttpCommand(); + const serverOptions = { + transport: options.transport, + port: parseInt(options.port), + host: options.host, + cors: options.cors + }; + + console.error(chalk.green(`🚀 启动 ${options.transport.toUpperCase()} MCP Server 在 ${options.host}:${options.port}...`)); + await mcpHttpServer.execute(serverOptions); + } else { + throw new Error(`不支持的传输类型: ${options.transport}。支持的类型: stdio, http, sse`); + } } catch (error) { // 输出到stderr,不污染MCP的stdout通信 console.error(chalk.red(`❌ MCP Server 启动失败: ${error.message}`)); @@ -121,7 +148,9 @@ ${chalk.cyan('示例:')} promptx remember "测试→预发布→生产" ${chalk.gray('# 7️⃣ 启动MCP服务')} - promptx mcp-server + promptx mcp-server # stdio传输(默认) + promptx mcp-server -t http -p 3000 # HTTP传输 + promptx mcp-server -t sse -p 3001 # SSE传输 ${chalk.cyan('🔄 PATEOAS状态机:')} 每个锦囊输出都包含 PATEOAS 导航,引导 AI 发现下一步操作 diff --git a/src/lib/commands/MCPStreamableHttpCommand.js b/src/lib/commands/MCPStreamableHttpCommand.js new file mode 100644 index 0000000..d9ad3b4 --- /dev/null +++ b/src/lib/commands/MCPStreamableHttpCommand.js @@ -0,0 +1,619 @@ +const express = require('express'); +const { randomUUID } = require('node:crypto'); +const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); +const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js'); +const { SSEServerTransport } = require('@modelcontextprotocol/sdk/server/sse.js'); +const { isInitializeRequest } = require('@modelcontextprotocol/sdk/types.js'); +const { cli } = require('../core/pouch'); +const { MCPOutputAdapter } = require('../adapters/MCPOutputAdapter'); + +/** + * MCP Streamable HTTP Server Command + * 实现基于 Streamable HTTP 传输的 MCP 服务器 + * 同时提供 SSE 向后兼容支持 + */ +class MCPStreamableHttpCommand { + constructor() { + this.name = 'promptx-mcp-streamable-http-server'; + this.version = '1.0.0'; + this.transport = 'http'; + this.port = 3000; + this.host = 'localhost'; + this.transports = {}; // 存储会话传输 + this.outputAdapter = new MCPOutputAdapter(); + this.debug = process.env.MCP_DEBUG === 'true'; + } + + /** + * 执行命令 + */ + async execute(options = {}) { + const { + transport = 'http', + port = 3000, + host = 'localhost' + } = options; + + // 验证传输类型 + if (!['http', 'sse'].includes(transport)) { + throw new Error(`Unsupported transport: ${transport}`); + } + + // 验证配置 + this.validatePort(port); + this.validateHost(host); + + if (transport === 'http') { + return this.startStreamableHttpServer(port, host); + } else if (transport === 'sse') { + return this.startSSEServer(port, host); + } + } + + /** + * 启动 Streamable HTTP 服务器 + */ + async startStreamableHttpServer(port, host) { + this.log(`🚀 启动 Streamable HTTP MCP Server...`); + + const app = express(); + + // 中间件设置 + app.use(express.json()); + app.use(this.corsMiddleware.bind(this)); + + // 健康检查端点 + app.get('/health', (req, res) => { + res.json({ + status: 'ok', + name: this.name, + version: this.version, + transport: 'http' + }); + }); + + // MCP 端点 + app.post('/mcp', this.handleMCPPostRequest.bind(this)); + app.get('/mcp', this.handleMCPGetRequest.bind(this)); + app.delete('/mcp', this.handleMCPDeleteRequest.bind(this)); + + // 错误处理中间件 + app.use(this.errorHandler.bind(this)); + + return new Promise((resolve, reject) => { + const server = app.listen(port, host, () => { + this.log(`✅ Streamable HTTP MCP Server 运行在 http://${host}:${port}`); + this.server = server; + resolve(server); + }); + + server.on('error', reject); + }); + } + + /** + * CORS 中间件 + */ + corsMiddleware(req, res, next) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, mcp-session-id'); + + if (req.method === 'OPTIONS') { + res.sendStatus(200); + return; + } + + next(); + } + + /** + * 错误处理中间件 + */ + errorHandler(error, req, res, next) { + this.log('Express 错误处理:', error); + + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } + + /** + * 启动 SSE 服务器 + */ + async startSSEServer(port, host) { + const app = express(); + app.use(express.json()); + + this.log(`🚀 启动 SSE MCP Server...`); + + // 健康检查端点 + app.get('/health', (req, res) => { + res.json({ status: 'ok', name: this.name, version: this.version, transport: 'sse' }); + }); + + // SSE 端点 - 建立事件流 + app.get('/mcp', async (req, res) => { + await this.handleSSEConnection(req, res); + }); + + // 消息端点 - 接收客户端 JSON-RPC 消息 + app.post('/messages', async (req, res) => { + await this.handleSSEMessage(req, res); + }); + + return new Promise((resolve, reject) => { + const server = app.listen(port, host, () => { + this.log(`✅ SSE MCP Server 运行在 http://${host}:${port}`); + resolve(server); + }); + + server.on('error', reject); + this.server = server; + }); + } + + /** + * 处理 SSE 连接建立 + */ + async handleSSEConnection(req, res) { + this.log('建立 SSE 连接'); + + try { + // 创建 SSE 传输 + const transport = new SSEServerTransport('/messages', res); + const sessionId = transport.sessionId; + + // 存储传输 + this.transports[sessionId] = transport; + + // 设置关闭处理程序 + transport.onclose = () => { + this.log(`SSE 传输关闭: ${sessionId}`); + delete this.transports[sessionId]; + }; + + // 连接到 MCP 服务器 + const server = this.setupMCPServer(); + await server.connect(transport); + + this.log(`SSE 流已建立,会话ID: ${sessionId}`); + } catch (error) { + this.log('建立 SSE 连接错误:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE connection'); + } + } + } + + /** + * 处理 SSE 消息 + */ + async handleSSEMessage(req, res) { + this.log('收到 SSE 消息:', req.body); + + try { + // 从查询参数获取会话ID + const sessionId = req.query.sessionId; + + if (!sessionId) { + res.status(400).send('Missing sessionId parameter'); + return; + } + + const transport = this.transports[sessionId]; + if (!transport) { + res.status(404).send('Session not found'); + return; + } + + // 处理消息 + await transport.handlePostMessage(req, res, req.body); + } catch (error) { + this.log('处理 SSE 消息错误:', error); + if (!res.headersSent) { + res.status(500).send('Error handling request'); + } + } + } + + /** + * 设置 MCP 服务器 + */ + setupMCPServer() { + const server = new McpServer({ + name: this.name, + version: this.version + }, { + capabilities: { + tools: {}, + logging: {} + } + }); + + // 注册所有 PromptX 工具 + this.setupMCPTools(server); + + return server; + } + + /** + * 设置 MCP 工具 + */ + setupMCPTools(server) { + const { z } = require('zod'); + + // 注册 promptx_init 工具 + server.tool('promptx_init', '🏗️ [环境初始化锦囊] 初始化PromptX工作环境,创建配置目录,准备专业能力增强系统', {}, async (args, extra) => { + this.log('🔧 调用工具: promptx_init'); + return await this.callTool('promptx_init', {}); + }); + + // 注册 promptx_hello 工具 + server.tool('promptx_hello', '👋 [角色发现锦囊] 让AI浏览专业角色库(产品经理、Java开发者、设计师等),当需要专业能力时使用,引导角色激活', {}, async (args, extra) => { + this.log('🔧 调用工具: promptx_hello'); + return await this.callTool('promptx_hello', {}); + }); + + // 注册 promptx_action 工具 + server.tool('promptx_action', '⚡ [专家变身锦囊] 让AI获得指定专业角色的思维模式和核心能力,即时变身领域专家,开始提供专业服务', { + role: z.string().describe('要激活的角色ID,如:copywriter, product-manager, java-backend-developer') + }, async (args, extra) => { + this.log(`🔧 调用工具: promptx_action 参数: ${JSON.stringify(args)}`); + return await this.callTool('promptx_action', args); + }); + + // 注册 promptx_learn 工具 + server.tool('promptx_learn', '📚 [专业深化锦囊] 让AI学习特定领域的思维模式和执行模式(如敏捷开发、产品设计),强化当前专家角色能力', { + resource: z.string().describe('资源URL,支持格式:thought://creativity, execution://best-practice, knowledge://scrum') + }, async (args, extra) => { + this.log(`🔧 调用工具: promptx_learn 参数: ${JSON.stringify(args)}`); + return await this.callTool('promptx_learn', args); + }); + + // 注册 promptx_recall 工具 + server.tool('promptx_recall', '🔍 [经验检索锦囊] 让AI从专业记忆库中检索相关经验和最佳实践,当需要基于历史经验工作时使用', { + query: z.string().optional().describe('检索关键词或描述,可选参数,不提供则返回所有记忆') + }, async (args, extra) => { + this.log(`🔧 调用工具: promptx_recall 参数: ${JSON.stringify(args)}`); + return await this.callTool('promptx_recall', args); + }); + + // 注册 promptx_remember 工具 + server.tool('promptx_remember', '💾 [知识积累锦囊] 让AI将重要经验和专业知识保存到记忆库,构建可复用的专业知识体系,供未来检索应用', { + content: z.string().describe('要保存的重要信息或经验'), + tags: z.string().optional().describe('自定义标签,用空格分隔,可选') + }, async (args, extra) => { + this.log(`🔧 调用工具: promptx_remember 参数: ${JSON.stringify(args)}`); + return await this.callTool('promptx_remember', args); + }); + } + + /** + * 获取工具定义 + */ + getToolDefinitions() { + return [ + { + name: 'promptx_init', + description: '🏗️ [环境初始化锦囊] 初始化PromptX工作环境,创建配置目录,准备专业能力增强系统', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'promptx_hello', + description: '👋 [角色发现锦囊] 让AI浏览专业角色库(产品经理、Java开发者、设计师等),当需要专业能力时使用,引导角色激活', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'promptx_action', + description: '⚡ [专家变身锦囊] 让AI获得指定专业角色的思维模式和核心能力,即时变身领域专家,开始提供专业服务', + inputSchema: { + type: 'object', + properties: { + role: { + type: 'string', + description: '要激活的角色ID,如:copywriter, product-manager, java-backend-developer' + } + }, + required: ['role'] + } + }, + { + name: 'promptx_learn', + description: '📚 [专业深化锦囊] 让AI学习特定领域的思维模式和执行模式(如敏捷开发、产品设计),强化当前专家角色能力', + inputSchema: { + type: 'object', + properties: { + resource: { + type: 'string', + description: '资源URL,支持格式:thought://creativity, execution://best-practice, knowledge://scrum' + } + }, + required: ['resource'] + } + }, + { + name: 'promptx_recall', + description: '🔍 [经验检索锦囊] 让AI从专业记忆库中检索相关经验和最佳实践,当需要基于历史经验工作时使用', + inputSchema: { + type: 'object', + properties: { + random_string: { + type: 'string', + description: 'Dummy parameter for no-parameter tools' + }, + query: { + type: 'string', + description: '检索关键词或描述,可选参数,不提供则返回所有记忆' + } + }, + required: ['random_string'] + } + }, + { + name: 'promptx_remember', + description: '💾 [知识积累锦囊] 让AI将重要经验和专业知识保存到记忆库,构建可复用的专业知识体系,供未来检索应用', + inputSchema: { + type: 'object', + properties: { + content: { + type: 'string', + description: '要保存的重要信息或经验' + }, + tags: { + type: 'string', + description: '自定义标签,用空格分隔,可选' + } + }, + required: ['content'] + } + } + ]; + } + + /** + * 处理 MCP POST 请求 + */ + async handleMCPPostRequest(req, res) { + this.log('收到 MCP 请求:', req.body); + + try { + // 检查现有会话 ID + const sessionId = req.headers['mcp-session-id']; + let transport; + + if (sessionId && this.transports[sessionId]) { + // 复用现有传输 + transport = this.transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // 新的初始化请求 + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + this.log(`会话初始化: ${sessionId}`); + this.transports[sessionId] = transport; + } + }); + + // 设置关闭处理程序 + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && this.transports[sid]) { + this.log(`传输关闭: ${sid}`); + delete this.transports[sid]; + } + }; + + // 连接到 MCP 服务器 + const server = this.setupMCPServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else if (!sessionId && this.isStatelessRequest(req.body)) { + // 无状态请求(如 tools/list, prompts/list 等) + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined // 无状态模式 + }); + + // 连接到 MCP 服务器 + const server = this.setupMCPServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + // 无效请求 + return res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + id: null + }); + } + + // 处理现有传输的请求 + await transport.handleRequest(req, res, req.body); + } catch (error) { + this.log('处理 MCP 请求错误:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } + } + + /** + * 处理 MCP GET 请求(SSE) + */ + async handleMCPGetRequest(req, res) { + const sessionId = req.headers['mcp-session-id']; + if (!sessionId || !this.transports[sessionId]) { + return res.status(400).json({ + error: 'Invalid or missing session ID' + }); + } + + this.log(`建立 SSE 流: ${sessionId}`); + const transport = this.transports[sessionId]; + await transport.handleRequest(req, res); + } + + /** + * 处理 MCP DELETE 请求(会话终止) + */ + async handleMCPDeleteRequest(req, res) { + const sessionId = req.headers['mcp-session-id']; + if (!sessionId || !this.transports[sessionId]) { + return res.status(400).json({ + error: 'Invalid or missing session ID' + }); + } + + this.log(`终止会话: ${sessionId}`); + try { + const transport = this.transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + this.log('处理会话终止错误:', error); + if (!res.headersSent) { + res.status(500).json({ + error: 'Error processing session termination' + }); + } + } + } + + /** + * 调用工具 + */ + async callTool(toolName, args) { + try { + // 将 MCP 参数转换为 CLI 函数调用参数 + const cliArgs = this.convertMCPToCliParams(toolName, args); + this.log(`🎯 CLI调用: ${toolName} -> ${JSON.stringify(cliArgs)}`); + + // 直接调用 PromptX CLI 函数 + const result = await cli.execute(toolName.replace('promptx_', ''), cliArgs, true); + this.log(`✅ CLI执行完成: ${toolName}`); + + // 返回新 MCP SDK 格式的响应 + return { + content: [ + { + type: 'text', + text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) + } + ] + }; + + } catch (error) { + this.log(`❌ 工具调用失败: ${toolName} - ${error.message}`); + throw error; // 让 MCP SDK 处理错误 + } + } + + /** + * 转换 MCP 参数为 CLI 函数调用参数 + */ + convertMCPToCliParams(toolName, mcpArgs) { + const paramMapping = { + 'promptx_init': () => [], + 'promptx_hello': () => [], + 'promptx_action': (args) => args && args.role ? [args.role] : [], + 'promptx_learn': (args) => args && args.resource ? [args.resource] : [], + 'promptx_recall': (args) => { + if (!args || !args.query || typeof args.query !== 'string' || args.query.trim() === '') { + return []; + } + return [args.query]; + }, + 'promptx_remember': (args) => { + if (!args || !args.content) { + throw new Error('content 参数是必需的'); + } + const result = [args.content]; + if (args.tags) { + result.push('--tags', args.tags); + } + return result; + } + }; + + const mapper = paramMapping[toolName]; + if (!mapper) { + throw new Error(`未知工具: ${toolName}`); + } + + return mapper(mcpArgs || {}); + } + + /** + * 调试日志 + */ + log(message, ...args) { + if (this.debug) { + console.error(`[MCP DEBUG] ${message}`, ...args); + } + } + + /** + * 验证端口号 + */ + validatePort(port) { + if (typeof port !== 'number') { + throw new Error('Port must be a number'); + } + if (port < 1 || port > 65535) { + throw new Error('Port must be between 1 and 65535'); + } + } + + /** + * 验证主机地址 + */ + validateHost(host) { + if (!host || typeof host !== 'string' || host.trim() === '') { + throw new Error('Host cannot be empty'); + } + } + + /** + * 判断是否为无状态请求(不需要会话ID) + */ + isStatelessRequest(requestBody) { + if (!requestBody || !requestBody.method) { + return false; + } + + // 这些方法可以无状态处理 + const statelessMethods = [ + 'tools/list', + 'prompts/list', + 'resources/list' + ]; + + return statelessMethods.includes(requestBody.method); + } +} + +module.exports = { MCPStreamableHttpCommand }; \ No newline at end of file diff --git a/src/lib/commands/index.js b/src/lib/commands/index.js new file mode 100644 index 0000000..c526822 --- /dev/null +++ b/src/lib/commands/index.js @@ -0,0 +1,7 @@ +const { MCPServerCommand } = require('./MCPServerCommand'); +const { MCPStreamableHttpCommand } = require('./MCPStreamableHttpCommand'); + +module.exports = { + MCPServerCommand, + MCPStreamableHttpCommand +}; \ No newline at end of file diff --git a/src/tests/commands/MCPSSEServer.integration.test.js b/src/tests/commands/MCPSSEServer.integration.test.js new file mode 100644 index 0000000..35b53b3 --- /dev/null +++ b/src/tests/commands/MCPSSEServer.integration.test.js @@ -0,0 +1,181 @@ +const { MCPStreamableHttpCommand } = require('../../lib/commands/MCPStreamableHttpCommand'); +const http = require('http'); + +describe('MCP SSE Server Integration Tests', () => { + let command; + let port; + + beforeEach(() => { + command = new MCPStreamableHttpCommand(); + port = 3001 + Math.floor(Math.random() * 1000); + }); + + afterEach(async () => { + if (command.server && command.server.close) { + await new Promise((resolve) => { + command.server.close(resolve); + }); + } + }); + + describe('SSE Transport', () => { + it('should start SSE server and handle dual endpoints', async () => { + // 启动 SSE 服务器 + await command.execute({ + transport: 'sse', + port, + host: 'localhost' + }); + + // 等待服务器启动 + await new Promise(resolve => setTimeout(resolve, 200)); + + // 测试健康检查端点 + const healthResponse = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/health', + method: 'GET' + }); + + expect(healthResponse.statusCode).toBe(200); + const healthData = JSON.parse(healthResponse.data); + expect(healthData.status).toBe('ok'); + }, 10000); + + it('should establish SSE stream on GET /mcp', async () => { + await command.execute({ transport: 'sse', port, host: 'localhost' }); + await new Promise(resolve => setTimeout(resolve, 200)); + + // 尝试建立 SSE 连接 + const sseResponse = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/mcp', + method: 'GET', + headers: { + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache' + } + }); + + expect(sseResponse.statusCode).toBe(200); + expect(sseResponse.headers['content-type']).toContain('text/event-stream'); + }, 10000); + + it('should handle POST messages to /messages endpoint', async () => { + await command.execute({ transport: 'sse', port, host: 'localhost' }); + await new Promise(resolve => setTimeout(resolve, 200)); + + // 先建立 SSE 连接获取会话ID + const sseResponse = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/mcp', + method: 'GET', + headers: { 'Accept': 'text/event-stream' } + }); + + // 解析 SSE 响应获取会话ID + const sseData = sseResponse.data; + const endpointMatch = sseData.match(/event: endpoint\ndata: (.+)/); + let sessionId = 'test-session'; + + if (endpointMatch) { + const endpointData = JSON.parse(endpointMatch[1]); + const urlObj = new URL(endpointData.uri); + sessionId = urlObj.searchParams.get('sessionId'); + } + + // 发送初始化请求到 /messages 端点 + const initRequest = { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + }, + id: 1 + }; + + const response = await makeHttpRequest({ + hostname: 'localhost', + port, + path: `/messages?sessionId=${sessionId}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }, JSON.stringify(initRequest)); + + expect(response.statusCode).toBe(200); + }, 10000); + }); + + describe('Transport Type Selection', () => { + it('should start different transports based on parameter', async () => { + // 测试默认 HTTP 传输 + const httpCommand = new MCPStreamableHttpCommand(); + const httpPort = port + 100; + await httpCommand.execute({ transport: 'http', port: httpPort }); + + const httpHealth = await makeHttpRequest({ + hostname: 'localhost', + port: httpPort, + path: '/health', + method: 'GET' + }); + expect(httpHealth.statusCode).toBe(200); + + // 清理 + if (httpCommand.server) { + await new Promise(resolve => httpCommand.server.close(resolve)); + } + + // 测试 SSE 传输 + const sseCommand = new MCPStreamableHttpCommand(); + const ssePort = port + 200; + await sseCommand.execute({ transport: 'sse', port: ssePort }); + + const sseHealth = await makeHttpRequest({ + hostname: 'localhost', + port: ssePort, + path: '/health', + method: 'GET' + }); + expect(sseHealth.statusCode).toBe(200); + + // 清理 + if (sseCommand.server) { + await new Promise(resolve => sseCommand.server.close(resolve)); + } + }, 15000); + }); +}); + +// Helper function to make HTTP requests +function makeHttpRequest(options, data = null) { + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let responseData = ''; + res.on('data', (chunk) => { + responseData += chunk; + }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + data: responseData + }); + }); + }); + + req.on('error', reject); + + if (data) { + req.write(data); + } + req.end(); + }); +} \ No newline at end of file diff --git a/src/tests/commands/MCPStreamableHttpCommand.integration.test.js b/src/tests/commands/MCPStreamableHttpCommand.integration.test.js new file mode 100644 index 0000000..c4bbcf6 --- /dev/null +++ b/src/tests/commands/MCPStreamableHttpCommand.integration.test.js @@ -0,0 +1,261 @@ +const { MCPStreamableHttpCommand } = require('../../lib/commands/MCPStreamableHttpCommand'); +const http = require('http'); + +describe('MCPStreamableHttpCommand Integration Tests', () => { + let command; + let server; + let port; + + beforeEach(() => { + command = new MCPStreamableHttpCommand(); + port = 3001 + Math.floor(Math.random() * 1000); // 随机端口避免冲突 + }); + + afterEach(async () => { + if (server && server.close) { + await new Promise((resolve) => { + server.close(resolve); + }); + } + }); + + describe('Streamable HTTP Server', () => { + it('should start server and respond to health check', async () => { + // 启动服务器 + const serverPromise = command.execute({ + transport: 'http', + port, + host: 'localhost' + }); + + // 等待服务器启动 + await new Promise(resolve => setTimeout(resolve, 100)); + + // 发送健康检查请求 + const response = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/health', + method: 'GET' + }); + + expect(response.statusCode).toBe(200); + }, 5000); + + it('should handle MCP initialize request', async () => { + // 启动服务器 + await command.execute({ + transport: 'http', + port, + host: 'localhost' + }); + + // 等待服务器启动 + await new Promise(resolve => setTimeout(resolve, 100)); + + // 发送初始化请求 + const initRequest = { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'test-client', + version: '1.0.0' + } + }, + id: 1 + }; + + const response = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream' + } + }, JSON.stringify(initRequest)); + + expect(response.statusCode).toBe(200); + const responseData = JSON.parse(response.data); + expect(responseData.jsonrpc).toBe('2.0'); + expect(responseData.id).toBe(1); + }, 5000); + + it('should handle tools/list request', async () => { + // 启动服务器 + await command.execute({ + transport: 'http', + port, + host: 'localhost' + }); + + // 等待服务器启动 + await new Promise(resolve => setTimeout(resolve, 100)); + + // 先初始化 + const initRequest = { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + }, + id: 1 + }; + + const initResponse = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream' + } + }, JSON.stringify(initRequest)); + + const sessionId = JSON.parse(initResponse.data).result?.sessionId; + + // 发送工具列表请求 + const toolsRequest = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 2 + }; + + const response = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'mcp-session-id': sessionId || 'test-session' + } + }, JSON.stringify(toolsRequest)); + + expect(response.statusCode).toBe(200); + const responseData = JSON.parse(response.data); + expect(responseData.result.tools).toBeDefined(); + expect(Array.isArray(responseData.result.tools)).toBe(true); + expect(responseData.result.tools.length).toBe(6); + }, 5000); + + it('should handle tool call request', async () => { + // 启动服务器 + await command.execute({ + transport: 'http', + port, + host: 'localhost' + }); + + // 等待服务器启动 + await new Promise(resolve => setTimeout(resolve, 100)); + + // 发送工具调用请求 + const toolCallRequest = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'promptx_hello', + arguments: {} + }, + id: 3 + }; + + const response = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'mcp-session-id': 'test-session' + } + }, JSON.stringify(toolCallRequest)); + + expect(response.statusCode).toBe(200); + const responseData = JSON.parse(response.data); + expect(responseData.result).toBeDefined(); + }, 5000); + }); + + describe('Error Handling', () => { + it('should handle invalid JSON requests', async () => { + await command.execute({ transport: 'http', port, host: 'localhost' }); + await new Promise(resolve => setTimeout(resolve, 100)); + + const response = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream' + } + }, 'invalid json'); + + expect(response.statusCode).toBe(400); + }, 5000); + + it('should handle missing session ID for non-initialize requests', async () => { + await command.execute({ transport: 'http', port, host: 'localhost' }); + await new Promise(resolve => setTimeout(resolve, 100)); + + const request = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 1 + }; + + const response = await makeHttpRequest({ + hostname: 'localhost', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream' + } + }, JSON.stringify(request)); + + expect(response.statusCode).toBe(400); + }, 5000); + }); +}); + +// Helper function to make HTTP requests +function makeHttpRequest(options, data = null) { + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let responseData = ''; + res.on('data', (chunk) => { + responseData += chunk; + }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + data: responseData + }); + }); + }); + + req.on('error', reject); + + if (data) { + req.write(data); + } + req.end(); + }); +} \ No newline at end of file diff --git a/src/tests/commands/MCPStreamableHttpCommand.unit.test.js b/src/tests/commands/MCPStreamableHttpCommand.unit.test.js new file mode 100644 index 0000000..6b532eb --- /dev/null +++ b/src/tests/commands/MCPStreamableHttpCommand.unit.test.js @@ -0,0 +1,178 @@ +const { MCPStreamableHttpCommand } = require('../../lib/commands/MCPStreamableHttpCommand'); + +describe('MCPStreamableHttpCommand', () => { + let command; + + beforeEach(() => { + command = new MCPStreamableHttpCommand(); + }); + + describe('constructor', () => { + it('should initialize with correct name and version', () => { + expect(command.name).toBe('promptx-mcp-streamable-http-server'); + expect(command.version).toBe('1.0.0'); + }); + + it('should have default configuration', () => { + expect(command.transport).toBe('http'); + expect(command.port).toBe(3000); + expect(command.host).toBe('localhost'); + }); + }); + + describe('execute', () => { + it('should throw error when transport type is unsupported', async () => { + await expect(command.execute({ transport: 'unsupported' })) + .rejects + .toThrow('Unsupported transport: unsupported'); + }); + + it('should start Streamable HTTP server with default options', async () => { + const mockStartStreamableHttpServer = jest.fn().mockResolvedValue(); + command.startStreamableHttpServer = mockStartStreamableHttpServer; + + await command.execute(); + + expect(mockStartStreamableHttpServer).toHaveBeenCalledWith(3000, 'localhost'); + }); + + it('should start Streamable HTTP server with custom options', async () => { + const mockStartStreamableHttpServer = jest.fn().mockResolvedValue(); + command.startStreamableHttpServer = mockStartStreamableHttpServer; + + await command.execute({ transport: 'http', port: 4000, host: '0.0.0.0' }); + + expect(mockStartStreamableHttpServer).toHaveBeenCalledWith(4000, '0.0.0.0'); + }); + + it('should start SSE server when transport is sse', async () => { + const mockStartSSEServer = jest.fn().mockResolvedValue(); + command.startSSEServer = mockStartSSEServer; + + await command.execute({ transport: 'sse', port: 3001 }); + + expect(mockStartSSEServer).toHaveBeenCalledWith(3001, 'localhost'); + }); + }); + + describe('startStreamableHttpServer', () => { + it('should create Express app and listen on specified port', async () => { + // Mock Express + const mockApp = { + use: jest.fn(), + post: jest.fn(), + get: jest.fn(), + delete: jest.fn(), + listen: jest.fn((port, callback) => callback()) + }; + const mockExpress = jest.fn(() => mockApp); + mockExpress.json = jest.fn(); + + // Mock the method to avoid actual server startup + const originalMethod = command.startStreamableHttpServer; + command.startStreamableHttpServer = jest.fn().mockImplementation(async (port, host) => { + expect(port).toBe(3000); + expect(host).toBe('localhost'); + return Promise.resolve(); + }); + + await command.startStreamableHttpServer(3000, 'localhost'); + + expect(command.startStreamableHttpServer).toHaveBeenCalledWith(3000, 'localhost'); + }); + }); + + describe('startSSEServer', () => { + it('should create Express app with dual endpoints', async () => { + // Mock the method to avoid actual server startup + command.startSSEServer = jest.fn().mockImplementation(async (port, host) => { + expect(port).toBe(3000); + expect(host).toBe('localhost'); + return Promise.resolve(); + }); + + await command.startSSEServer(3000, 'localhost'); + + expect(command.startSSEServer).toHaveBeenCalledWith(3000, 'localhost'); + }); + }); + + describe('setupMCPServer', () => { + it('should create MCP server with correct configuration', () => { + const server = command.setupMCPServer(); + + expect(server).toBeDefined(); + // We'll verify the server has the correct tools in integration tests + }); + }); + + describe('getToolDefinitions', () => { + it('should return all PromptX tools', () => { + const tools = command.getToolDefinitions(); + + expect(Array.isArray(tools)).toBe(true); + expect(tools.length).toBe(6); // All PromptX tools + + const toolNames = tools.map(tool => tool.name); + expect(toolNames).toContain('promptx_init'); + expect(toolNames).toContain('promptx_hello'); + expect(toolNames).toContain('promptx_action'); + expect(toolNames).toContain('promptx_learn'); + expect(toolNames).toContain('promptx_recall'); + expect(toolNames).toContain('promptx_remember'); + }); + }); + + describe('handleMCPRequest', () => { + it('should handle tool calls correctly', async () => { + const mockReq = { + body: { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'promptx_hello', + arguments: {} + }, + id: 1 + }, + headers: {} + }; + + const mockRes = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + headersSent: false + }; + + // Mock CLI execution + const mockCli = { + execute: jest.fn().mockResolvedValue('Hello response') + }; + + command.cli = mockCli; + command.handleMCPRequest = jest.fn().mockImplementation(async (req, res) => { + expect(req.body.method).toBe('tools/call'); + res.json({ result: 'success' }); + }); + + await command.handleMCPRequest(mockReq, mockRes); + + expect(command.handleMCPRequest).toHaveBeenCalledWith(mockReq, mockRes); + }); + }); + + describe('configuration validation', () => { + it('should validate port number', () => { + expect(() => command.validatePort(3000)).not.toThrow(); + expect(() => command.validatePort('invalid')).toThrow('Port must be a number'); + expect(() => command.validatePort(70000)).toThrow('Port must be between 1 and 65535'); + }); + + it('should validate host address', () => { + expect(() => command.validateHost('localhost')).not.toThrow(); + expect(() => command.validateHost('0.0.0.0')).not.toThrow(); + expect(() => command.validateHost('192.168.1.1')).not.toThrow(); + expect(() => command.validateHost('')).toThrow('Host cannot be empty'); + }); + }); +}); \ No newline at end of file