diff --git a/README.md b/README.md index 9c4297b..e2ed190 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,11 @@ bun dist/index.js --http-stream ## Environment Variables ```env +# Required for all modes (optional for httpStream mode - can be provided via HTTP headers) GITLAB_API_URL=https://your-gitlab-instance.com + +# Required for stdio mode, optional for httpStream mode +# (can be provided via HTTP headers in httpStream mode) GITLAB_TOKEN=your_access_token # Optional: Provide a mapping from usernames to user IDs (JSON string) @@ -89,7 +93,77 @@ MCP_ENDPOINT=/mcp ## Usage Examples -See [USAGE.md](./USAGE.md) for detailed examples of each tool's parameters. +### Direct HTTP API Usage + +You can also interact with the MCP server directly via HTTP requests: + +```bash +# Example: Get user tasks using Bearer token +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-gitlab-token" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "Gitlab Get User Tasks Tool", + "arguments": { + "taskFilterType": "ASSIGNED_MRS", + "fields": ["id", "title", "source_branch", "target_branch"] + } + } + }' +``` + +```bash +# Example: Search projects using PRIVATE-TOKEN header +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "PRIVATE-TOKEN: your-gitlab-token" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "Gitlab Search Project Details Tool", + "arguments": { + "projectName": "my-project", + "fields": ["id", "name", "description", "web_url"] + } + } + }' +``` + +```bash +# Example: Use dynamic GitLab instance URL with Bearer token +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-gitlab-token" \ + -H "x-gitlab-url: https://gitlab.company.com" \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "Gitlab Get User Tasks Tool", + "arguments": { + "taskFilterType": "ASSIGNED_MRS", + "fields": ["id", "title", "source_branch", "target_branch"] + } + } + }' +``` + +### Tool Examples + +For detailed examples of each tool's parameters, see [USAGE.md](./USAGE.md). + +Key benefits of HTTP Stream mode with dynamic authentication: +- **Multi-tenant support**: Single server instance can serve multiple users +- **Security**: Each request uses its own authentication token and GitLab instance URL +- **Flexibility**: Tokens and GitLab URLs can be configured per client without server restart +- **Multi-instance support**: Connect to different GitLab instances from the same server ## Transport Modes @@ -105,6 +179,7 @@ This server supports two transport modes: - Uses HTTP POST requests with streaming responses - Allows multiple clients to connect to the same server instance - Ideal for production deployments +- **Supports dynamic token authentication via HTTP headers** When using HTTP Stream mode, clients can connect to: ``` @@ -112,6 +187,73 @@ POST http://localhost:3000/mcp Content-Type: application/json ``` +#### Authentication Methods + +HTTP Stream mode supports multiple ways to provide GitLab tokens and instance URLs: + +**Token Authentication:** + +**1. Bearer Token (Recommended):** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +Authorization: Bearer your-gitlab-access-token +``` + +**2. Private Token Header:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +PRIVATE-TOKEN: your-gitlab-access-token +``` + +**3. Alternative Private Token Header:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +private-token: your-gitlab-access-token +``` + +**4. Custom GitLab Token Header:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +x-gitlab-token: your-gitlab-access-token +``` + +**GitLab Instance URL Configuration:** + +**1. GitLab URL Header (Recommended):** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +x-gitlab-url: https://gitlab.company.com +``` + +**2. Alternative GitLab URL Headers:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +gitlab-url: https://gitlab.company.com +``` + +```http +POST http://localhost:3000/mcp +Content-Type: application/json +gitlab-api-url: https://gitlab.company.com +``` + +**5. Fallback to Environment Variables:** +If no token or URL is provided in headers, the server will fall back to the `GITLAB_TOKEN` and `GITLAB_API_URL` environment variables. + +**Complete Example:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +Authorization: Bearer your-gitlab-access-token +x-gitlab-url: https://gitlab.company.com +``` + ## Project Structure ``` @@ -157,26 +299,77 @@ Add to your config: ``` #### HTTP Stream Mode (Server Deployment) -For remote server deployment, first start the server: + +**Server Setup:** +First start the server (note that both `GITLAB_TOKEN` and `GITLAB_API_URL` are optional when using HTTP headers): ```bash -# On your server -MCP_TRANSPORT_TYPE=httpStream MCP_PORT=3000 npx @zephyr-mcp/gitlab +# On your server - no token or URL required in env vars +MCP_TRANSPORT_TYPE=httpStream MCP_PORT=3000 MCP_HOST=0.0.0.0 npx @zephyr-mcp/gitlab + +# Or with Docker +docker run -d \ + -p 3000:3000 \ + -e MCP_TRANSPORT_TYPE=httpStream \ + -e MCP_HOST=0.0.0.0 \ + -e MCP_PORT=3000 \ + gitlab-mcp-server ``` -Then configure Claude Desktop with HTTP transport: +**Client Configuration:** +Option 1: **With Bearer Token** (Recommended) ```json { "mcpServers": { "@zephyr-mcp/gitlab": { "command": "npx", - "args": ["@modelcontextprotocol/client-cli", "http://your-server:3000/mcp"] + "args": [ + "@modelcontextprotocol/client-cli", + "http://your-server:3000/mcp", + "--header", "Authorization: Bearer your-gitlab-access-token" + ] } } } ``` +Option 2: **With Private Token Header** +```json +{ + "mcpServers": { + "@zephyr-mcp/gitlab": { + "command": "npx", + "args": [ + "@modelcontextprotocol/client-cli", + "http://your-server:3000/mcp", + "--header", "PRIVATE-TOKEN: your-gitlab-access-token" + ] + } + } +} +``` + +Option 3: **With Dynamic GitLab URL and Token** +```json +{ + "mcpServers": { + "@zephyr-mcp/gitlab": { + "command": "npx", + "args": [ + "@modelcontextprotocol/client-cli", + "http://your-server:3000/mcp", + "--header", "Authorization: Bearer your-gitlab-access-token", + "--header", "x-gitlab-url: https://gitlab.company.com" + ] + } + } +} +``` + +**Multi-tenant Usage:** +Each user can configure their own token and GitLab instance URL in their client configuration, allowing the same server instance to serve multiple users with different GitLab permissions and instances. + ### Smithery Use directly on Smithery platform: @@ -189,8 +382,8 @@ Or search "@zephyr-mcp/gitlab" in Smithery UI and add to your workspace. Environment variables: -- `GITLAB_API_URL`: Base URL of your GitLab API -- `GITLAB_TOKEN`: Access token for GitLab API authentication +- `GITLAB_API_URL`: Base URL of your GitLab API (required for stdio mode, optional for httpStream mode - can be provided via HTTP headers) +- `GITLAB_TOKEN`: Access token for GitLab API authentication (required for stdio mode, optional for httpStream mode - can be provided via HTTP headers) - `MCP_TRANSPORT_TYPE`: Transport type (stdio/httpStream) - `MCP_HOST`: Server binding address for HTTP stream mode - `MCP_PORT`: HTTP port for HTTP stream mode @@ -206,11 +399,9 @@ The repository includes a Dockerfile for easy deployment: # Build the Docker image docker build -t gitlab-mcp-server . -# Run with environment variables +# Run with environment variables (both token and URL can be provided via HTTP headers) docker run -d \ -p 3000:3000 \ - -e GITLAB_API_URL=https://your-gitlab-instance.com \ - -e GITLAB_TOKEN=your_access_token \ -e MCP_TRANSPORT_TYPE=httpStream \ -e MCP_HOST=0.0.0.0 \ -e MCP_PORT=3000 \ @@ -227,11 +418,12 @@ services: ports: - "3000:3000" environment: - - GITLAB_TOKEN=your_gitlab_token - - GITLAB_API_URL=your-gitlab-instance.com - MCP_TRANSPORT_TYPE=httpStream - MCP_HOST=0.0.0.0 - MCP_PORT=3000 + # Both GITLAB_API_URL and GITLAB_TOKEN are optional when using HTTP headers + # - GITLAB_API_URL=https://your-gitlab-instance.com + # - GITLAB_TOKEN=your_gitlab_token command: npx -y @zephyr-mcp/gitlab@latest ``` diff --git a/README_TOOLS.md b/README_TOOLS.md new file mode 100644 index 0000000..78a5924 --- /dev/null +++ b/README_TOOLS.md @@ -0,0 +1,337 @@ +# GitLab MCP Tools + +这个包提供了 GitLab API 的 MCP 工具集合,可以作为 npm 包在其他项目中使用,支持 `@modelcontextprotocol/sdk` 和 `fastmcp` 两种框架。 + +## 安装 + +```bash +npm install @zephyr-mcp/gitlab +# 或 +bun add @zephyr-mcp/gitlab +``` + +## 使用方式 + +### 1. 与 @modelcontextprotocol/sdk 配合使用 + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerGitLabToolsForMcpSDK } from '@zephyr-mcp/gitlab/tools'; + +const server = new McpServer({ + name: 'my-app', + version: '1.0.0', + capabilities: { tools: {} } +}); + +// 注册所有 GitLab 工具 +registerGitLabToolsForMcpSDK(server); + +// 或者只注册特定工具 +registerGitLabToolsForMcpSDK(server, { + toolFilter: { + allowList: ['Gitlab_Create_MR_Tool', 'Gitlab_Search_User_Projects_Tool'] + } +}); +``` + +### 2. 与 fastmcp 配合使用 + +```typescript +import { FastMCP } from 'fastmcp'; +import { registerGitLabToolsForFastMCP } from '@zephyr-mcp/gitlab/tools'; + +const server = new FastMCP({ + name: 'my-app', + version: '1.0.0' +}); + +// 注册所有 GitLab 工具 +registerGitLabToolsForFastMCP(server); + +// 或者只注册特定工具 +registerGitLabToolsForFastMCP(server, { + toolFilter: { + allowList: ['Gitlab_Create_MR_Tool', 'Gitlab_Search_User_Projects_Tool'] + } +}); +``` + +## 环境变量配置 + +在使用工具前,需要设置以下环境变量: + +```bash +# 必需配置 +GITLAB_API_URL=https://your-gitlab-instance.com +GITLAB_TOKEN=your_access_token + +# 可选映射配置 +GITLAB_USER_MAPPING={"username1": 123} +GITLAB_PROJECT_MAPPING={"project-name": 1001} + +# 日志配置 +ENABLE_LOGGER=true +``` + +## 可用工具 + +### 1. Gitlab_Search_User_Projects_Tool +搜索用户及其活跃项目。 + +```typescript +// 调用示例 +{ + "tool": "Gitlab_Search_User_Projects_Tool", + "arguments": { + "username": "john_doe", + "fields": ["id", "name", "web_url"] + } +} +``` + +### 2. Gitlab_Get_User_Tasks_Tool +获取当前用户的待办任务。 + +```typescript +// 调用示例 +{ + "tool": "Gitlab_Get_User_Tasks_Tool", + "arguments": { + "taskFilterType": "ASSIGNED_MRS", + "fields": ["id", "title", "web_url"] + } +} +``` + +### 3. Gitlab_Search_Project_Details_Tool +搜索项目详情。 + +```typescript +// 调用示例 +{ + "tool": "Gitlab_Search_Project_Details_Tool", + "arguments": { + "projectName": "my-project", + "fields": ["id", "name", "description", "web_url"] + } +} +``` + +### 4. Gitlab_Create_MR_Tool +创建新的 Merge Request。 + +```typescript +// 调用示例 +{ + "tool": "Gitlab_Create_MR_Tool", + "arguments": { + "projectId": "my-project", + "sourceBranch": "feature-branch", + "targetBranch": "main", + "title": "Add new feature", + "description": "Description of the feature", + "assigneeId": "john_doe", + "reviewerIds": ["jane_doe"], + "labels": ["feature", "enhancement"] + } +} +``` + +### 5. Gitlab_Update_MR_Tool +更新 Merge Request。 + +```typescript +// 调用示例 +{ + "tool": "Gitlab_Update_MR_Tool", + "arguments": { + "projectId": "my-project", + "mergeRequestId": 123, + "title": "Updated title", + "description": "Updated description" + } +} +``` + +### 6. Gitlab_Accept_MR_Tool +接受并合并 Merge Request。 + +```typescript +// 调用示例 +{ + "tool": "Gitlab_Accept_MR_Tool", + "arguments": { + "projectId": "my-project", + "mergeRequestId": 123, + "mergeOptions": { + "squash": true, + "shouldRemoveSourceBranch": true + } + } +} +``` + +### 7. Gitlab_Create_MR_Comment_Tool +为 Merge Request 添加评论。 + +```typescript +// 调用示例 +{ + "tool": "Gitlab_Create_MR_Comment_Tool", + "arguments": { + "projectId": "my-project", + "mergeRequestId": 123, + "comment": "Looks good to me!" + } +} +``` + +### 8. Gitlab_Raw_API_Tool +通用 GitLab API 调用工具。 + +```typescript +// 调用示例 +{ + "tool": "Gitlab_Raw_API_Tool", + "arguments": { + "endpoint": "/projects", + "method": "GET", + "params": { + "owned": true + } + } +} +``` + +## 配置选项 + +### GitLabToolsRegistryOptions + +```typescript +interface GitLabToolsRegistryOptions { + /** 过滤选项,控制注册哪些工具 */ + toolFilter?: { + /** 允许注册的工具列表 */ + allowList?: GitLabToolName[]; + /** 禁止注册的工具列表 */ + blockList?: GitLabToolName[]; + }; + /** 是否启用日志 */ + enableLogger?: boolean; +} +``` + +### 类型定义 + +```typescript +/** 可用的 GitLab 工具名称 */ +type GitLabToolName = + | "Gitlab_Search_User_Projects_Tool" + | "Gitlab_Get_User_Tasks_Tool" + | "Gitlab_Search_Project_Details_Tool" + | "Gitlab_Create_MR_Tool" + | "Gitlab_Update_MR_Tool" + | "Gitlab_Accept_MR_Tool" + | "Gitlab_Create_MR_Comment_Tool" + | "Gitlab_Raw_API_Tool"; +``` + +## 示例项目 + +### 完整的 MCP SDK 项目示例 + +```typescript +#!/usr/bin/env node + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerGitLabTools } from '@zephyr-mcp/gitlab/tools'; + +const server = new McpServer({ + name: 'my-gitlab-app', + version: '1.0.0', + capabilities: { + tools: {} + } +}); + +// 注册 GitLab 工具 +registerGitLabTools(server, { + enableLogger: true, + toolFilter: { + allowList: [ + 'Gitlab_Create_MR_Tool', + 'Gitlab_Search_User_Projects_Tool', + 'Gitlab_Get_User_Tasks_Tool' + ] + } +}); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch(console.error); +``` + +### 完整的 FastMCP 项目示例 + +```typescript +#!/usr/bin/env node + +import { FastMCP } from 'fastmcp'; +import { registerGitLabToolsForFastMCP } from '@zephyr-mcp/gitlab/tools'; + +const server = new FastMCP({ + name: 'my-gitlab-app', + version: '1.0.0' +}); + +// 注册 GitLab 工具 +registerGitLabToolsForFastMCP(server, { + enableLogger: true, + toolFilter: { + allowList: ["Gitlab_Create_MR_Tool", "Gitlab_Search_User_Projects_Tool"] + } +}); + +// 启动服务器 +server.start({ + transportType: 'stdio' +}); +``` + +## 工具特性 + +1. **自动映射解析**: 自动将用户名和项目名映射为对应的 ID +2. **字段过滤**: 支持过滤 API 响应字段,优化性能 +3. **错误处理**: 统一的错误处理和用户友好的错误消息 +4. **类型安全**: 完整的 TypeScript 类型定义 +5. **灵活配置**: 支持工具过滤和日志配置 +6. **双框架支持**: 同时支持 MCP SDK 和 fastmcp +7. **简单明确**: 开发者明确选择使用哪个框架的注册函数 + +## 故障排除 + +### 常见问题 + +1. **认证失败**: 确保 `GITLAB_TOKEN` 环境变量设置正确 +2. **API URL 错误**: 确保 `GITLAB_API_URL` 指向正确的 GitLab 实例 +3. **权限不足**: 确保访问令牌具有足够的权限 +4. **用户/项目映射失败**: 检查映射配置或使用 ID 而非名称 + +### 调试 + +启用日志以查看详细的 API 调用信息: + +```typescript +registerGitLabTools(server, { + enableLogger: true +}); +``` + +## 许可证 + +MIT License \ No newline at end of file diff --git a/README_zh-CN.md b/README_zh-CN.md index 06b900c..4783bd4 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -1,4 +1,4 @@ -[English Version](./README.en.md) +[English Version](./README.md) ![](https://badge.mcpx.dev?type=server&features=tools 'MCP server with tools') [![Build Status](https://github.com/ZephyrDeng/mcp-server-gitlab/actions/workflows/ci.yml/badge.svg)](https://github.com/ZephyrDeng/mcp-server-gitlab/actions) [![Node Version](https://img.shields.io/node/v/@zephyr-mcp/gitlab)](https://nodejs.org) [![License](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) @@ -9,7 +9,7 @@ -基于 Model Context Protocol (MCP) 框架构建的 GitLab 集成服务器,提供多种 GitLab RESTful API 工具,支持 Claude、Smithery 等平台集成。 +基于 fastmcp 框架构建的 GitLab 集成服务器,提供多种 GitLab RESTful API 工具,支持 Claude、Smithery 等平台集成。 ## 功能概览 @@ -24,6 +24,7 @@ ## 快速开始 +### Stdio 模式(默认) ```bash # 安装依赖 bun install @@ -31,14 +32,33 @@ bun install # 构建项目 bun run build -# 启动服务 +# 启动 stdio 传输模式的服务器(默认) bun run start ``` +### HTTP Stream 模式(服务器部署) +```bash +# 安装依赖 +bun install + +# 构建项目 +bun run build + +# 启动 HTTP stream 传输模式的服务器 +MCP_TRANSPORT_TYPE=httpStream MCP_PORT=3000 bun run start + +# 或使用命令行标志 +bun dist/index.js --http-stream +``` + ## 环境变量配置 ```env +# 所有模式必需(httpStream 模式下可通过 HTTP headers 提供) GITLAB_API_URL=https://your-gitlab-instance.com + +# stdio 模式必需,httpStream 模式可选 +# (可在 httpStream 模式下通过 HTTP headers 提供) GITLAB_TOKEN=your_access_token # 可选:提供用户名到用户 ID 的映射(JSON 字符串) @@ -51,11 +71,185 @@ GITLAB_USER_MAPPING={"username1": 123, "username2": 456} # 这可以减少 API 调用次数,并确保使用正确的项目 # 示例:'{"project-name-a": 1001, "group/project-b": "group/project-b"}' GITLAB_PROJECT_MAPPING={"project-name-a": 1001, "group/project-b": "group/project-b"} + +# MCP 传输配置(可选) +# 传输类型:stdio(默认)或 httpStream +MCP_TRANSPORT_TYPE=stdio + +# HTTP Stream 配置(仅在 MCP_TRANSPORT_TYPE=httpStream 时使用) +# 服务器绑定地址(httpStream 模式默认为 0.0.0.0,stdio 模式默认为 localhost) +# 对于 Docker 部署,请使用 0.0.0.0 以允许外部访问 +MCP_HOST=0.0.0.0 + +# 服务器端口(默认:3000) +MCP_PORT=3000 + +# API 端点路径(默认:/mcp) +MCP_ENDPOINT=/mcp ``` -## 工具示例 +## 使用示例 + +### 直接 HTTP API 使用 + +您也可以直接通过 HTTP 请求与 MCP 服务器交互: + +```bash +# 示例:使用 Bearer token 获取用户任务 +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-gitlab-token" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "Gitlab Get User Tasks Tool", + "arguments": { + "taskFilterType": "ASSIGNED_MRS", + "fields": ["id", "title", "source_branch", "target_branch"] + } + } + }' +``` + +```bash +# 示例:使用 PRIVATE-TOKEN header 搜索项目 +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "PRIVATE-TOKEN: your-gitlab-token" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "Gitlab Search Project Details Tool", + "arguments": { + "projectName": "my-project", + "fields": ["id", "name", "description", "web_url"] + } + } + }' +``` + +```bash +# 示例:使用 Bearer token 和动态 GitLab 实例 URL +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-gitlab-token" \ + -H "x-gitlab-url: https://gitlab.company.com" \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "Gitlab Get User Tasks Tool", + "arguments": { + "taskFilterType": "ASSIGNED_MRS", + "fields": ["id", "title", "source_branch", "target_branch"] + } + } + }' +``` + +### 工具示例 + +详细工具参数示例请参见 [USAGE.md](./USAGE.md)。 + +HTTP Stream 模式下动态认证的主要优势: +- **多租户支持**:单个服务器实例可以服务多个用户 +- **安全性**:每个请求使用自己的认证令牌和 GitLab 实例 URL +- **灵活性**:可以在不重启服务器的情况下轮换客户端令牌 +- **多实例支持**:从同一服务器连接到不同的 GitLab 实例 + +## 传输模式 -详见 [USAGE.md](./USAGE.md),包括每个工具的参数示例。 +本服务器支持两种传输模式: + +### 1. Stdio 传输(默认) +- 最适合本地开发与 MCP 客户端的直接集成 +- 使用 stdin/stdout 进行通信 +- 无需网络配置 + +### 2. HTTP Stream 传输 +- 支持服务器部署以实现远程访问 +- 使用 HTTP POST 请求和流式响应 +- 允许多个客户端连接到同一服务器实例 +- 适用于生产部署 +- **支持通过 HTTP headers 进行动态 token 认证** + +使用 HTTP Stream 模式时,客户端可以连接到: +``` +POST http://localhost:3000/mcp +Content-Type: application/json +``` + +#### 认证方式 + +HTTP Stream 模式支持多种提供 GitLab token 和实例 URL 的方式: + +**Token 认证:** + +**1. Bearer Token(推荐):** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +Authorization: Bearer your-gitlab-access-token +``` + +**2. Private Token Header:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +PRIVATE-TOKEN: your-gitlab-access-token +``` + +**3. 替代 Private Token Header:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +private-token: your-gitlab-access-token +``` + +**4. 自定义 GitLab Token Header:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +x-gitlab-token: your-gitlab-access-token +``` + +**GitLab 实例 URL 配置:** + +**1. GitLab URL Header(推荐):** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +x-gitlab-url: https://gitlab.company.com +``` + +**2. 替代 GitLab URL Headers:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +gitlab-url: https://gitlab.company.com +``` + +```http +POST http://localhost:3000/mcp +Content-Type: application/json +gitlab-api-url: https://gitlab.company.com +``` + +**5. 回退到环境变量:** +如果 headers 中没有提供 token 或 URL,服务器将回退到 `GITLAB_TOKEN` 和 `GITLAB_API_URL` 环境变量。 + +**完整示例:** +```http +POST http://localhost:3000/mcp +Content-Type: application/json +Authorization: Bearer your-gitlab-access-token +x-gitlab-url: https://gitlab.company.com +``` ## 项目结构 @@ -87,6 +281,8 @@ tsconfig.json ### Claude 桌面客户端 +#### Stdio 模式(默认) + 在配置文件中添加: ```json @@ -94,15 +290,188 @@ tsconfig.json "mcpServers": { "@zephyr-mcp/gitlab": { "command": "npx", - "args": ["-y", "@zephyr-mcp/gitlab@0.3.0"] + "args": ["-y", "@zephyr-mcp/gitlab"] + } + } +} +``` + +#### HTTP Stream 模式(服务器部署) + +**服务器设置:** +首先启动服务器(注意使用 HTTP headers 时 `GITLAB_TOKEN` 是可选的): + +```bash +# 在您的服务器上 - 环境变量中不需要 token +MCP_TRANSPORT_TYPE=httpStream MCP_PORT=3000 MCP_HOST=0.0.0.0 npx @zephyr-mcp/gitlab + +# 或使用 Docker +docker run -d \ + -p 3000:3000 \ + -e GITLAB_API_URL=https://your-gitlab-instance.com \ + -e MCP_TRANSPORT_TYPE=httpStream \ + -e MCP_HOST=0.0.0.0 \ + -e MCP_PORT=3000 \ + gitlab-mcp-server +``` + +**客户端配置:** + +选项 1:**使用 Bearer Token**(推荐) +```json +{ + "mcpServers": { + "@zephyr-mcp/gitlab": { + "command": "npx", + "args": [ + "@modelcontextprotocol/client-cli", + "http://your-server:3000/mcp", + "--header", "Authorization: Bearer your-gitlab-access-token" + ] } } } ``` -配置参数: -- `GITLAB_API_URL`: GitLab API 的基础 URL -- `GITLAB_TOKEN`: 用于验证 GitLab API 请求的访问令牌 +选项 2:**使用 Private Token Header** +```json +{ + "mcpServers": { + "@zephyr-mcp/gitlab": { + "command": "npx", + "args": [ + "@modelcontextprotocol/client-cli", + "http://your-server:3000/mcp", + "--header", "PRIVATE-TOKEN: your-gitlab-access-token" + ] + } + } +} +``` + +选项 3:**使用动态 GitLab URL 和 Token** +```json +{ + "mcpServers": { + "@zephyr-mcp/gitlab": { + "command": "npx", + "args": [ + "@modelcontextprotocol/client-cli", + "http://your-server:3000/mcp", + "--header", "Authorization: Bearer your-gitlab-access-token", + "--header", "x-gitlab-url: https://gitlab.company.com" + ] + } + } +} +``` + +**多租户使用:** +每个用户可以在自己的客户端配置中配置自己的 token 和 GitLab 实例 URL,允许同一个服务器实例服务具有不同 GitLab 权限和实例的多个用户。 + +### Smithery + +直接在 Smithery 平台上使用: + +```bash +smithery add @zephyr-mcp/gitlab +``` + +或在 Smithery UI 中搜索 "@zephyr-mcp/gitlab" 并添加到您的工作区。 + +环境变量: + +- `GITLAB_API_URL`: GitLab API 的基础 URL(stdio 模式必需,httpStream 模式可选 - 可通过 HTTP headers 提供) +- `GITLAB_TOKEN`: GitLab API 认证的访问令牌(stdio 模式必需,httpStream 模式可选 - 可通过 HTTP headers 提供) +- `MCP_TRANSPORT_TYPE`: 传输类型(stdio/httpStream) +- `MCP_HOST`: HTTP stream 模式的服务器绑定地址 +- `MCP_PORT`: HTTP stream 模式的 HTTP 端口 +- `MCP_ENDPOINT`: HTTP stream 模式的 HTTP 端点路径 + +## 部署 + +### Docker 部署 + +仓库包含用于轻松部署的 Dockerfile: + +```bash +# 构建 Docker 镜像 +docker build -t gitlab-mcp-server . + +# 使用环境变量运行(token 和 URL 都可通过 HTTP headers 提供) +docker run -d \ + -p 3000:3000 \ + -e MCP_TRANSPORT_TYPE=httpStream \ + -e MCP_HOST=0.0.0.0 \ + -e MCP_PORT=3000 \ + gitlab-mcp-server +``` + +#### Docker Compose 示例 + +```yaml +services: + gitlab-mcp: + image: node:22.14.0 + container_name: gitlab-mcp + ports: + - "3000:3000" + environment: + - MCP_TRANSPORT_TYPE=httpStream + - MCP_HOST=0.0.0.0 + - MCP_PORT=3000 + # 使用 HTTP headers 时 GITLAB_API_URL 和 GITLAB_TOKEN 都是可选的 + # - GITLAB_API_URL=https://your-gitlab-instance.com + # - GITLAB_TOKEN=your_gitlab_token + command: npx -y @zephyr-mcp/gitlab@latest +``` + +**Docker 重要提示**:在 Docker 容器中运行时,确保设置 `MCP_HOST=0.0.0.0` 以允许外部访问。httpStream 传输的默认值已经是 `0.0.0.0`,但明确设置它可确保兼容性。 + +### 手动部署 + +```bash +# 安装依赖并构建 +npm install +npm run build + +# 启动 HTTP stream 模式的服务器(使用 HTTP headers 时 GITLAB_API_URL 是可选的) +export MCP_TRANSPORT_TYPE=httpStream +export MCP_PORT=3000 +# 可选:设置默认的 GitLab 实例 URL(可在 HTTP headers 中覆盖) +# export GITLAB_API_URL=https://your-gitlab-instance.com + +# 运行服务器 +node dist/index.js +``` + +### 进程管理器 (PM2) + +```bash +# 安装 PM2 +npm install -g pm2 + +# 创建生态系统文件 +cat > ecosystem.config.js << EOF +module.exports = { + apps: [{ + name: 'gitlab-mcp-server', + script: 'dist/index.js', + env: { + MCP_TRANSPORT_TYPE: 'httpStream', + MCP_PORT: 3000 + // 使用 HTTP headers 时 GITLAB_API_URL 是可选的 + // GITLAB_API_URL: 'https://your-gitlab-instance.com', + } + }] +} +EOF + +# 使用 PM2 启动 +pm2 start ecosystem.config.js +pm2 save +pm2 startup +``` ## 相关链接 diff --git a/package.json b/package.json index e721703..0f2ec45 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,25 @@ "description": "基于 Model Context Protocol (MCP) 框架构建的 GitLab 集成服务器,提供与 GitLab 实例的强大集成能力", "type": "module", "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./tools": { + "types": "./dist/gitlab-tools-sdk.d.ts", + "import": "./dist/gitlab-tools-sdk.js", + "require": "./dist/gitlab-tools-sdk.js" + }, + "./tools.js": { + "types": "./dist/gitlab-tools-sdk.d.ts", + "import": "./dist/gitlab-tools-sdk.js", + "require": "./dist/gitlab-tools-sdk.js" + } + }, "bin": { "gitlab-mcp": "dist/index.js" }, diff --git a/src/config/GitlabConfig.ts b/src/config/GitlabConfig.ts index d227e30..41106c2 100644 --- a/src/config/GitlabConfig.ts +++ b/src/config/GitlabConfig.ts @@ -4,6 +4,7 @@ export interface GitlabConfigOptions { timeout?: number; userMapping?: { [username: string]: number }; projectMapping?: { [projectName: string]: string | number }; + allowEmptyBaseUrl?: boolean; // Allow empty baseUrl for HTTP stream mode } export class GitlabConfig { @@ -21,7 +22,7 @@ export class GitlabConfig { this.projectMapping = this.loadMapping(options.projectMapping, process.env.GITLAB_PROJECT_MAPPING); - this.validate(); + this.validate(options.allowEmptyBaseUrl); } private loadMapping(optionValue?: T, envValue?: string): T { @@ -32,18 +33,16 @@ export class GitlabConfig { try { return JSON.parse(envValue); } catch (error) { - console.warn(`无法解析环境变量中的映射配置: ${envValue}`, error); + console.warn(`Unable to parse mapping configuration from environment variable: ${envValue}`, error); } } return {} as T; // Return empty object if neither option nor env var is provided/valid } - private validate() { - if (!this.privateToken) { - throw new Error("GitLab configuration error: missing access token (privateToken), please set environment variable GITLAB_TOKEN or pass it as parameter"); - } - if (!this.baseUrl) { + private validate(allowEmptyBaseUrl = false) { + if (!this.baseUrl && !allowEmptyBaseUrl) { throw new Error("GitLab configuration error: missing API URL (baseUrl), please set environment variable GITLAB_API_URL or pass it as parameter"); } + // Remove privateToken validation as it can be provided dynamically via headers } } diff --git a/src/gitlab-tools-sdk.ts b/src/gitlab-tools-sdk.ts new file mode 100644 index 0000000..946e220 --- /dev/null +++ b/src/gitlab-tools-sdk.ts @@ -0,0 +1,268 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { FastMCP, Tool } from "fastmcp"; + + +import { gitlabApiClient } from "./utils/gitlabApiClientInstance"; +import { GitlabAcceptMRTool } from "./tools/GitlabAcceptMRTool"; +import { GitlabCreateMRCommentTool } from "./tools/GitlabCreateMRCommentTool"; +import { GitlabCreateMRTool } from "./tools/GitlabCreateMRTool"; +import { GitlabGetUserTasksTool } from "./tools/GitlabGetUserTasksTool"; +import { GitlabRawApiTool } from "./tools/GitlabRawApiTool"; +import { GitlabSearchProjectDetailsTool } from "./tools/GitlabSearchProjectDetailsTool"; +import { GitlabSearchUserProjectsTool } from "./tools/GitlabSearchUserProjectsTool"; +import { GitlabUpdateMRTool } from "./tools/GitlabUpdateMRTool"; + +/** + * Union type representing all available GitLab tool names. + * + * @public + */ +export type GitLabToolName = + | "Gitlab_Search_User_Projects_Tool" + | "Gitlab_Get_User_Tasks_Tool" + | "Gitlab_Search_Project_Details_Tool" + | "Gitlab_Create_MR_Tool" + | "Gitlab_Update_MR_Tool" + | "Gitlab_Accept_MR_Tool" + | "Gitlab_Create_MR_Comment_Tool" + | "Gitlab_Raw_API_Tool"; + +/** + * Configuration options for filtering which tools should be registered. + * + * @public + */ +export interface GitLabToolFilterOptions { + /** List of tools that are allowed to be registered. If provided, only these tools will be registered. */ + allowList?: GitLabToolName[]; + /** List of tools that should not be registered. These tools will be excluded from registration. */ + blockList?: GitLabToolName[]; +} + +/** + * Configuration options for the GitLab tools registry. + * + * @public + */ +export interface GitLabToolsRegistryOptions { + /** Filter options to control which tools are registered. */ + toolFilter?: GitLabToolFilterOptions; + /** Enable/disable logger. Defaults to process.env.ENABLE_LOGGER */ + enableLogger?: boolean; +} + +/** + * Mapping of fastmcp tools to their standardized names. + */ +const toolNameMapping = { + [GitlabSearchUserProjectsTool.name]: "Gitlab_Search_User_Projects_Tool", + [GitlabGetUserTasksTool.name]: "Gitlab_Get_User_Tasks_Tool", + [GitlabSearchProjectDetailsTool.name]: "Gitlab_Search_Project_Details_Tool", + [GitlabCreateMRTool.name]: "Gitlab_Create_MR_Tool", + [GitlabUpdateMRTool.name]: "Gitlab_Update_MR_Tool", + [GitlabAcceptMRTool.name]: "Gitlab_Accept_MR_Tool", + [GitlabCreateMRCommentTool.name]: "Gitlab_Create_MR_Comment_Tool", + [GitlabRawApiTool.name]: "Gitlab_Raw_API_Tool", +} as const; + +/** + * Available fastmcp tools. + */ +const fastmcpTools = [ + GitlabAcceptMRTool, + GitlabCreateMRCommentTool, + GitlabCreateMRTool, + GitlabGetUserTasksTool, + GitlabRawApiTool, + GitlabSearchProjectDetailsTool, + GitlabSearchUserProjectsTool, + GitlabUpdateMRTool, +]; + +/** + * Determines whether a tool should be registered based on the provided filter options. + * + * @param toolName - The name of the tool to check + * @param filter - Filter options containing allowList and/or blockList + * @returns `true` if the tool should be registered, `false` otherwise + * + * @internal + */ +function shouldRegisterTool(toolName: GitLabToolName, filter?: GitLabToolFilterOptions): boolean { + if (!filter) { + return true; + } + + if (filter.allowList && filter.allowList.length > 0) { + return filter.allowList.includes(toolName); + } + + if (filter.blockList && filter.blockList.length > 0) { + return !filter.blockList.includes(toolName); + } + + return true; +} + +/** + * Registers GitLab tools with a fastmcp server instance. + * + * @param server - The FastMCP server instance + * @param options - Configuration options for tool registration + * + * @example + * ```typescript + * import { FastMCP } from 'fastmcp'; + * import { registerGitLabToolsForFastMCP } from './gitlab-tools-sdk'; + * + * const server = new FastMCP({ name: 'my-app', version: '1.0.0' }); + * + * // Register all tools + * registerGitLabToolsForFastMCP(server); + * + * // Register only specific tools + * registerGitLabToolsForFastMCP(server, { + * toolFilter: { + * allowList: ['Gitlab_Create_MR_Tool', 'Gitlab_Search_User_Projects_Tool'] + * } + * }); + * ``` + * + * @public + */ +export function registerGitLabToolsForFastMCP(server: FastMCP, options: GitLabToolsRegistryOptions = {}) { + const { enableLogger = process.env.ENABLE_LOGGER === 'true' } = options; + + // Configure logger if enabled + if (enableLogger) { + gitlabApiClient.setLogger(console); + } + + // Register tools based on filter options + fastmcpTools.forEach(tool => { + const standardizedName = toolNameMapping[tool.name as keyof typeof toolNameMapping]; + if (shouldRegisterTool(standardizedName as GitLabToolName, options.toolFilter)) { + // GitLabTool is now fully compatible with FastMCP's base type, can be registered directly + server.addTool(tool); + } + }); +} + +/** + * Registers GitLab tools with an MCP SDK server instance. + * + * @param server - The MCP SDK server instance + * @param options - Configuration options for tool registration + * + * @example + * ```typescript + * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + * import { registerGitLabToolsForMcpSDK } from './gitlab-tools-sdk'; + * + * const server = new McpServer({ + * name: 'my-app', + * version: '1.0.0', + * capabilities: { tools: {} } + * }); + * + * // Register all tools + * registerGitLabToolsForMcpSDK(server); + * + * // Register only specific tools + * registerGitLabToolsForMcpSDK(server, { + * toolFilter: { + * allowList: ['Gitlab_Create_MR_Tool', 'Gitlab_Search_User_Projects_Tool'] + * } + * }); + * ``` + * + * @public + */ +export function registerGitLabToolsForMcpSDK(server: McpServer, options: GitLabToolsRegistryOptions = {}) { + const { enableLogger = process.env.ENABLE_LOGGER === 'true' } = options; + + // Configure logger if enabled + if (enableLogger) { + gitlabApiClient.setLogger(console); + } + + // Convert fastmcp tools to MCP SDK format + fastmcpTools.forEach(tool => { + const standardizedName = toolNameMapping[tool.name as keyof typeof toolNameMapping]; + if (shouldRegisterTool(standardizedName as GitLabToolName, options.toolFilter)) { + server.tool( + tool.name, + tool.description || '', + tool.parameters || {}, + async (params) => { + // fastmcp tools return ContentResult, convert to MCP SDK format + // Create an empty context as MCP SDK doesn't support session authentication + const context = { + log: { + debug: () => {}, + error: () => {}, + info: () => {}, + warn: () => {}, + }, + reportProgress: async () => {}, + session: undefined, + streamContent: async () => {}, + }; + const result = await tool.execute(params, context); + + // Convert fastmcp ContentResult to MCP SDK format + if (typeof result === 'object' && result !== null && 'isError' in result && result.isError) { + const errorResult = result as { isError: boolean; content: Array<{ type: string; text: string }> }; + throw new Error( + errorResult.content?.[0]?.type === 'text' + ? errorResult.content[0].text + : 'Unknown error' + ); + } + + // Return the result in MCP SDK format + if (typeof result === 'object' && result !== null && 'content' in result) { + const contentResult = result as { content: Array<{ type: string; text: string }> }; + if (contentResult.content?.[0]?.type === 'text') { + try { + return JSON.parse(contentResult.content[0].text); + } catch { + return contentResult.content[0].text; + } + } + } + + return result; + } + ); + } + }); +} + + +/** + * Get all available GitLab tool names. + * + * @returns Array of all available GitLab tool names + * + * @public + */ +export function getAvailableGitLabTools(): GitLabToolName[] { + return Object.values(toolNameMapping); +} + +/** + * Legacy function for backward compatibility. + * @deprecated Use registerGitLabToolsForFastMCP instead. + */ +export const registerTools = registerGitLabToolsForFastMCP; + +/** + * Legacy function for backward compatibility. + * @deprecated Use registerGitLabTools with enableLogger option instead. + */ +export const registerLogger = () => { + if (process.env.ENABLE_LOGGER) { + gitlabApiClient.setLogger(console); + } +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5ee6ca2..03f50ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { FastMCP } from "fastmcp"; // GitLab related tools import { registerLogger, registerTools } from "./tools"; +import { GitLabSessionWithExtras } from "./types/GitLabSession"; // Parse command line arguments and environment variables const args = process.argv.slice(2); @@ -19,9 +20,50 @@ const endpoint = process.env.MCP_ENDPOINT || '/mcp'; const host = process.env.MCP_HOST || (transportType === 'httpStream' ? '0.0.0.0' : 'localhost'); // Create FastMCP server instance -const server = new FastMCP({ +const server = new FastMCP({ name: "GitLab MCP Server", version: "1.0.0", + authenticate: async (request) => { + // Extract GitLab token from HTTP headers + const tokenHeader = request.headers["authorization"]?.replace('Bearer ', '') || + request.headers["private-token"] || + request.headers["PRIVATE-TOKEN"] || + request.headers["x-gitlab-token"]; + + // Extract GitLab base URL from HTTP headers + const baseUrlHeader = request.headers["x-gitlab-url"] || + request.headers["gitlab-url"] || + request.headers["gitlab-api-url"]; + + // Ensure token and base URL are string types + const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; + const baseUrl = Array.isArray(baseUrlHeader) ? baseUrlHeader[0] : baseUrlHeader; + + // Validate token format if provided + if (token && typeof token === 'string') { + // GitLab tokens are typically at least 20 characters + if (token.length < 20) { + throw new Error('Invalid GitLab token format: token must be at least 20 characters'); + } + } + + // Validate URL format if provided + if (baseUrl && typeof baseUrl === 'string') { + try { + new URL(baseUrl); + } catch { + throw new Error('Invalid GitLab API URL format: must be a valid URL'); + } + } + + // Return session data that will be available in context.session + return { + gitlabToken: token, + gitlabBaseUrl: baseUrl, + headers: request.headers, + authenticatedAt: new Date().toISOString(), + }; + }, }); // Register demo resources: all GitLab projects diff --git a/src/tools.ts b/src/tools.ts index f13c12f..b8e13a5 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,29 +1,5 @@ -import type { FastMCP } from 'fastmcp'; - -import { gitlabApiClient } from './utils/gitlabApiClientInstance'; - -import { GitlabAcceptMRTool } from './tools/GitlabAcceptMRTool'; -import { GitlabCreateMRCommentTool } from './tools/GitlabCreateMRCommentTool'; -import { GitlabCreateMRTool } from './tools/GitlabCreateMRTool'; -import { GitlabGetUserTasksTool } from './tools/GitlabGetUserTasksTool'; -import { GitlabRawApiTool } from './tools/GitlabRawApiTool'; -import { GitlabSearchProjectDetailsTool } from './tools/GitlabSearchProjectDetailsTool'; -import { GitlabSearchUserProjectsTool } from './tools/GitlabSearchUserProjectsTool'; -import { GitlabUpdateMRTool } from './tools/GitlabUpdateMRTool'; - -export const registerTools = (server: FastMCP) => { - server.addTool(GitlabAcceptMRTool); - server.addTool(GitlabCreateMRCommentTool); - server.addTool(GitlabCreateMRTool); - server.addTool(GitlabGetUserTasksTool); - server.addTool(GitlabRawApiTool); - server.addTool(GitlabSearchProjectDetailsTool); - server.addTool(GitlabSearchUserProjectsTool); - server.addTool(GitlabUpdateMRTool); -}; - -export const registerLogger = () => { - if (process.env.ENABLE_LOGGER) { - gitlabApiClient.setLogger(console); - } -}; \ No newline at end of file +/** + * @deprecated Import from './gitlab-tools-sdk' instead + * This file is kept for backward compatibility only. + */ +export { registerTools, registerLogger } from './gitlab-tools-sdk'; \ No newline at end of file diff --git a/src/tools/GitlabAcceptMRTool.ts b/src/tools/GitlabAcceptMRTool.ts index ab31b09..1c191c3 100644 --- a/src/tools/GitlabAcceptMRTool.ts +++ b/src/tools/GitlabAcceptMRTool.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { gitlabApiClient } from "../utils/gitlabApiClientInstance"; +import { createGitlabClientFromContext } from "../utils/gitlabClientFactory"; import { filterResponseFields } from "./gitlab/FieldFilterUtils"; import type { Tool, ContentResult, TextContent, Context } from 'fastmcp'; @@ -31,15 +31,16 @@ export const GitlabAcceptMRTool: Tool | undefined> = { const { projectId: projectIdOrName, mergeRequestId, mergeOptions, fields } = typedArgs; try { - const resolvedProjectId = await gitlabApiClient.resolveProjectId(projectIdOrName); + const client = createGitlabClientFromContext(context); + const resolvedProjectId = await client.resolveProjectId(projectIdOrName); if (!resolvedProjectId) { throw new Error(`无法解析项目 ID 或名称:${projectIdOrName}`); } const endpoint = `/projects/${encodeURIComponent(String(resolvedProjectId))}/merge_requests/${mergeRequestId}/merge`; - const response = await gitlabApiClient.apiRequest(endpoint, "PUT", undefined, mergeOptions); + const response = await client.apiRequest(endpoint, "PUT", undefined, mergeOptions); - if (!gitlabApiClient.isValidResponse(response)) { + if (!client.isValidResponse(response)) { throw new Error(`GitLab API error: ${response?.message || 'Unknown error'}`); } diff --git a/src/tools/GitlabCreateMRCommentTool.ts b/src/tools/GitlabCreateMRCommentTool.ts index 3e841f9..2a55bea 100644 --- a/src/tools/GitlabCreateMRCommentTool.ts +++ b/src/tools/GitlabCreateMRCommentTool.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { gitlabApiClient } from "../utils/gitlabApiClientInstance"; +import { createGitlabClientFromContext } from "../utils/gitlabClientFactory"; import { filterResponseFields } from "./gitlab/FieldFilterUtils"; import type { Tool, ContentResult, Context } from 'fastmcp'; @@ -23,15 +23,16 @@ export const GitlabCreateMRCommentTool: Tool | undefined const { projectId: projectIdOrName, mergeRequestId, comment, fields } = typedArgs; try { - const resolvedProjectId = await gitlabApiClient.resolveProjectId(projectIdOrName); + const client = createGitlabClientFromContext(context); + const resolvedProjectId = await client.resolveProjectId(projectIdOrName); if (!resolvedProjectId) { throw new Error(`无法解析项目 ID 或名称:${projectIdOrName}`); } const endpoint = `/projects/${encodeURIComponent(String(resolvedProjectId))}/merge_requests/${mergeRequestId}/notes`; - const response = await gitlabApiClient.apiRequest(endpoint, "POST", undefined, { body: comment }); + const response = await client.apiRequest(endpoint, "POST", undefined, { body: comment }); - if (!gitlabApiClient.isValidResponse(response)) { + if (!client.isValidResponse(response)) { throw new Error(`GitLab API error: ${response?.message || 'Unknown error'}`); } diff --git a/src/tools/GitlabCreateMRTool.ts b/src/tools/GitlabCreateMRTool.ts index 419ced2..411442c 100644 --- a/src/tools/GitlabCreateMRTool.ts +++ b/src/tools/GitlabCreateMRTool.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { gitlabApiClient } from "../utils/gitlabApiClientInstance"; +import { createGitlabClientFromContext } from "../utils/gitlabClientFactory"; import { filterResponseFields } from "./gitlab/FieldFilterUtils"; import type { Tool, ContentResult, Context } from "fastmcp"; @@ -33,8 +33,9 @@ export const GitlabCreateMRTool: Tool | undefined> = { const { projectId: projectIdOrName, sourceBranch, targetBranch, title, description, assigneeId: assigneeIdOrName, reviewerIds: reviewerIdsOrNames, labels, fields } = typedArgs; try { + const client = createGitlabClientFromContext(context); // 解析项目 ID - const resolvedProjectId = await gitlabApiClient.resolveProjectId(projectIdOrName); + const resolvedProjectId = await client.resolveProjectId(projectIdOrName); if (!resolvedProjectId) { throw new Error(`无法解析项目 ID 或名称:${projectIdOrName}`); } @@ -42,7 +43,7 @@ export const GitlabCreateMRTool: Tool | undefined> = { let resolvedAssigneeId: number | undefined = undefined; if (assigneeIdOrName !== undefined) { - const resolved = await gitlabApiClient.resolveUserId(assigneeIdOrName); + const resolved = await client.resolveUserId(assigneeIdOrName); if (resolved) { resolvedAssigneeId = resolved; } else { @@ -54,7 +55,7 @@ export const GitlabCreateMRTool: Tool | undefined> = { let resolvedReviewerIds: number[] = []; if (reviewerIdsOrNames && reviewerIdsOrNames.length > 0) { - const reviewerPromises = reviewerIdsOrNames.map(idOrName => gitlabApiClient.resolveUserId(idOrName)); + const reviewerPromises = reviewerIdsOrNames.map(idOrName => client.resolveUserId(idOrName)); const results = await Promise.all(reviewerPromises); resolvedReviewerIds = results.filter((id): id is number => { if (id === null) { @@ -78,9 +79,9 @@ export const GitlabCreateMRTool: Tool | undefined> = { if (labels !== undefined) createData.labels = labels.join(","); - const response = await gitlabApiClient.apiRequest(endpoint, "POST", undefined, createData); + const response = await client.apiRequest(endpoint, "POST", undefined, createData); - if (!gitlabApiClient.isValidResponse(response)) { + if (!client.isValidResponse(response)) { throw new Error(`GitLab API error: ${response?.message || 'Unknown error'}`); } diff --git a/src/tools/GitlabGetUserTasksTool.ts b/src/tools/GitlabGetUserTasksTool.ts index 8facbfc..73078b8 100644 --- a/src/tools/GitlabGetUserTasksTool.ts +++ b/src/tools/GitlabGetUserTasksTool.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { createFieldsSchema } from "../utils/zodSchemas"; -import { gitlabApiClient } from "../utils/gitlabApiClientInstance"; +import { createGitlabClientFromContext } from "../utils/gitlabClientFactory"; import { filterResponseFields } from "./gitlab/FieldFilterUtils"; import type { Tool, ContentResult, Context } from 'fastmcp'; @@ -60,7 +60,8 @@ export const GitlabGetUserTasksTool: Tool | undefined> = break; } - const response = await gitlabApiClient.apiRequest("/merge_requests", "GET", params); + const client = createGitlabClientFromContext(context); + const response = await client.apiRequest("/merge_requests", "GET", params); if (typedArgs.fields) { const filteredResponse = filterResponseFields(response, typedArgs.fields); return { diff --git a/src/tools/GitlabRawApiTool.ts b/src/tools/GitlabRawApiTool.ts index 777640a..c54c013 100644 --- a/src/tools/GitlabRawApiTool.ts +++ b/src/tools/GitlabRawApiTool.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { createFieldsSchema } from "../utils/zodSchemas"; -import { gitlabApiClient } from "../utils/gitlabApiClientInstance"; +import { createGitlabClientFromContext } from "../utils/gitlabClientFactory"; import { filterResponseFields } from "./gitlab/FieldFilterUtils"; import type { Tool, ContentResult, Context } from 'fastmcp'; @@ -22,22 +22,23 @@ export const GitlabRawApiTool: Tool | undefined> = { data?: Record; fields?: string[]; }; - + try { - const response = await gitlabApiClient.apiRequest( - typedArgs.endpoint, - typedArgs.method, - typedArgs.params, + const client = createGitlabClientFromContext(context); + const response = await client.apiRequest( + typedArgs.endpoint, + typedArgs.method, + typedArgs.params, typedArgs.data ); - + if (typedArgs.fields) { const filteredResponse = filterResponseFields(response, typedArgs.fields); return { content: [{ type: "text", text: JSON.stringify(filteredResponse) }] } as ContentResult; } - + return { content: [{ type: "text", text: JSON.stringify(response) }] } as ContentResult; diff --git a/src/tools/GitlabSearchProjectDetailsTool.ts b/src/tools/GitlabSearchProjectDetailsTool.ts index e55407b..786bf4a 100644 --- a/src/tools/GitlabSearchProjectDetailsTool.ts +++ b/src/tools/GitlabSearchProjectDetailsTool.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { createFieldsSchema } from "../utils/zodSchemas"; -import { gitlabApiClient } from "../utils/gitlabApiClientInstance"; +import { createGitlabClientFromContext } from "../utils/gitlabClientFactory"; import type { Tool, ContentResult, Context } from 'fastmcp'; import { filterResponseFields } from './gitlab/FieldFilterUtils'; @@ -18,7 +18,8 @@ export const GitlabSearchProjectDetailsTool: Tool | unde }; try { - const response = await gitlabApiClient.apiRequest('/projects', 'GET', { search: typedArgs.projectName }); + const client = createGitlabClientFromContext(context); + const response = await client.apiRequest('/projects', 'GET', { search: typedArgs.projectName }); let result = response; if (typedArgs.fields) { diff --git a/src/tools/GitlabSearchUserProjectsTool.ts b/src/tools/GitlabSearchUserProjectsTool.ts index 9876584..e081453 100644 --- a/src/tools/GitlabSearchUserProjectsTool.ts +++ b/src/tools/GitlabSearchUserProjectsTool.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { gitlabApiClient } from "../utils/gitlabApiClientInstance"; +import { createGitlabClientFromContext } from "../utils/gitlabClientFactory"; import { createFieldsSchema } from '../utils/zodSchemas' import type { Tool, ContentResult, Context } from 'fastmcp'; import { filterResponseFields } from '../tools/gitlab/FieldFilterUtils' @@ -18,7 +18,8 @@ export const GitlabSearchUserProjectsTool: Tool | undefi }; try { - const users = await gitlabApiClient.apiRequest("/users", "GET", { search: typedArgs.username }); + const client = createGitlabClientFromContext(context); + const users = await client.apiRequest("/users", "GET", { search: typedArgs.username }); if (!Array.isArray(users) || users.length === 0) { return { content: [ @@ -32,7 +33,7 @@ export const GitlabSearchUserProjectsTool: Tool | undefi } const user = users[0]; - const projects = await gitlabApiClient.apiRequest(`/users/${user.id}/projects`, "GET", {}); + const projects = await client.apiRequest(`/users/${user.id}/projects`, "GET", {}); const result = { user, projects }; if (typedArgs.fields) { diff --git a/src/tools/GitlabUpdateMRTool.ts b/src/tools/GitlabUpdateMRTool.ts index 774d362..296da40 100644 --- a/src/tools/GitlabUpdateMRTool.ts +++ b/src/tools/GitlabUpdateMRTool.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { gitlabApiClient } from "../utils/gitlabApiClientInstance"; +import { createGitlabClientFromContext } from "../utils/gitlabClientFactory"; import { filterResponseFields } from "./gitlab/FieldFilterUtils"; import type { Tool, ContentResult, Context } from "fastmcp"; @@ -31,7 +31,8 @@ export const GitlabUpdateMRTool: Tool | undefined> = { const { projectId: projectIdOrName, mergeRequestId, assigneeId: assigneeIdOrName, reviewerIds: reviewerIdsOrNames, title, description, labels, fields } = typedArgs; try { - const resolvedProjectId = await gitlabApiClient.resolveProjectId(projectIdOrName); + const client = createGitlabClientFromContext(context); + const resolvedProjectId = await client.resolveProjectId(projectIdOrName); if (!resolvedProjectId) { throw new Error(`无法解析项目 ID 或名称:${projectIdOrName}`); } @@ -42,7 +43,7 @@ export const GitlabUpdateMRTool: Tool | undefined> = { if (assigneeIdOrName === 0 || assigneeIdOrName === "") { resolvedAssigneeId = null; } else { - const resolved = await gitlabApiClient.resolveUserId(assigneeIdOrName); + const resolved = await client.resolveUserId(assigneeIdOrName); if (resolved) { resolvedAssigneeId = resolved; } else { @@ -58,7 +59,7 @@ export const GitlabUpdateMRTool: Tool | undefined> = { if (reviewerIdsOrNames.length === 0) { resolvedReviewerIds = []; } else { - const reviewerPromises = reviewerIdsOrNames.map(idOrName => gitlabApiClient.resolveUserId(idOrName)); + const reviewerPromises = reviewerIdsOrNames.map(idOrName => client.resolveUserId(idOrName)); const results = await Promise.all(reviewerPromises); resolvedReviewerIds = results.filter((id): id is number => { if (id === null) { @@ -89,9 +90,9 @@ export const GitlabUpdateMRTool: Tool | undefined> = { } - const response = await gitlabApiClient.apiRequest(endpoint, "PUT", undefined, updateData); + const response = await client.apiRequest(endpoint, "PUT", undefined, updateData); - if (!gitlabApiClient.isValidResponse(response)) { + if (!client.isValidResponse(response)) { throw new Error(`GitLab API error: ${response?.message || 'Unknown error'}`); } diff --git a/src/types/GitLabSession.ts b/src/types/GitLabSession.ts new file mode 100644 index 0000000..f231cd4 --- /dev/null +++ b/src/types/GitLabSession.ts @@ -0,0 +1,27 @@ +/** + * GitLab Session type definition + * Used for FastMCP session authentication data + */ + +export interface GitLabSession { + /** GitLab access token */ + gitlabToken?: string; + /** GitLab API base URL */ + gitlabBaseUrl?: string; + /** Original request headers */ + headers?: Record; + /** Authentication timestamp */ + authenticatedAt?: string; + /** User role */ + role?: string; + /** User ID */ + userId?: string; + /** Username */ + username?: string; +} + +/** + * GitLab Session type with extra dynamic properties + * Uses index signature to satisfy FastMCPSessionAuth constraint while maintaining type safety + */ +export type GitLabSessionWithExtras = GitLabSession & Record; \ No newline at end of file diff --git a/src/types/GitLabTool.ts b/src/types/GitLabTool.ts new file mode 100644 index 0000000..070507d --- /dev/null +++ b/src/types/GitLabTool.ts @@ -0,0 +1,10 @@ +import { Tool } from "fastmcp"; +import { GitLabSession } from "./GitLabSession"; + +/** + * Base type definition for GitLab tools + * + * Uses Record | undefined to maintain full compatibility with FastMCPSessionAuth + * In tool implementations, we ensure type safety through type guards + */ +export type GitLabTool = Tool | undefined>; \ No newline at end of file diff --git a/src/utils/gitlabApiClientInstance.ts b/src/utils/gitlabApiClientInstance.ts index b167464..41b86ce 100644 --- a/src/utils/gitlabApiClientInstance.ts +++ b/src/utils/gitlabApiClientInstance.ts @@ -1,9 +1,2 @@ -import { GitlabApiClient } from "../tools/gitlab/GitlabApiClient"; -import { GitlabConfig } from "../config/GitlabConfig"; - -const config = new GitlabConfig({ - baseUrl: process.env.GITLAB_API_URL, - privateToken: process.env.GITLAB_TOKEN, -}); - -export const gitlabApiClient = new GitlabApiClient(config); \ No newline at end of file +// Re-export new factory functions for backward compatibility +export { createGitlabApiClient, gitlabApiClient } from "./gitlabClientFactory"; \ No newline at end of file diff --git a/src/utils/gitlabClientFactory.ts b/src/utils/gitlabClientFactory.ts new file mode 100644 index 0000000..aa33d3c --- /dev/null +++ b/src/utils/gitlabClientFactory.ts @@ -0,0 +1,67 @@ +import { GitlabApiClient } from "../tools/gitlab/GitlabApiClient"; +import { GitlabConfig } from "../config/GitlabConfig"; +import { Context } from "fastmcp"; +import { extractGitlabToken, extractGitlabBaseUrl } from "./typeGuards"; + +const config = new GitlabConfig({ + baseUrl: process.env.GITLAB_API_URL, + privateToken: process.env.GITLAB_TOKEN, + allowEmptyBaseUrl: process.env.MCP_TRANSPORT_TYPE === 'httpStream', +}); + +/** + * Extract GitLab token and base URL from context and create client + * @param context MCP request context + * @returns GitLab API client instance + */ +export function createGitlabClientFromContext(context?: Context | undefined>): GitlabApiClient { + // Use type-safe functions to extract token and base URL + const token = extractGitlabToken(context?.session); + const baseUrl = extractGitlabBaseUrl(context?.session); + + // Only validate when neither context nor config has values + // This allows environment variables to be used as fallback + const finalToken = token || config.privateToken; + const finalBaseUrl = baseUrl || config.baseUrl; + + if (!finalToken) { + throw new Error( + 'GitLab token is required. Please provide it via HTTP headers (Authorization/PRIVATE-TOKEN/x-gitlab-token) or set GITLAB_TOKEN environment variable' + ); + } + + if (!finalBaseUrl && process.env.MCP_TRANSPORT_TYPE !== 'httpStream') { + throw new Error( + 'GitLab API URL is required. Please provide it via HTTP headers (x-gitlab-url/gitlab-url/gitlab-api-url) or set GITLAB_API_URL environment variable' + ); + } + + // Create config with dynamic values if provided + const configWithDynamicValues = new GitlabConfig({ + baseUrl: finalBaseUrl, + privateToken: finalToken, + allowEmptyBaseUrl: process.env.MCP_TRANSPORT_TYPE === 'httpStream', + }); + + return new GitlabApiClient(configWithDynamicValues); +} + +/** + * Create GitLab API client instance (maintains backward compatibility) + * @param token Optional dynamic token, uses configured token if not provided + * @param baseUrl Optional dynamic base URL, uses configured URL if not provided + * @returns GitLab API client instance + */ +export function createGitlabApiClient(token?: string, baseUrl?: string): GitlabApiClient { + // Create config with dynamic values if provided + const configWithDynamicValues = new GitlabConfig({ + baseUrl: baseUrl || config.baseUrl, + privateToken: token || config.privateToken, + allowEmptyBaseUrl: process.env.MCP_TRANSPORT_TYPE === 'httpStream', + }); + + return new GitlabApiClient(configWithDynamicValues); +} + +// Default instance for backward compatibility +export const gitlabApiClient = createGitlabApiClient(); \ No newline at end of file diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts new file mode 100644 index 0000000..9e6d5dc --- /dev/null +++ b/src/utils/typeGuards.ts @@ -0,0 +1,40 @@ +import { GitLabSession } from "../types/GitLabSession"; + +/** + * Type guard: Check if session is GitLabSession + */ +export function isGitLabSession(session: unknown): session is GitLabSession { + return session !== null && + typeof session === 'object' && + 'gitlabToken' in session; +} + +/** + * Safely extract GitLab token from context + */ +export function extractGitlabToken(session: unknown): string | undefined { + if (isGitLabSession(session)) { + return session.gitlabToken; + } + return undefined; +} + +/** + * Safely extract GitLab base URL from context + */ +export function extractGitlabBaseUrl(session: unknown): string | undefined { + if (isGitLabSession(session)) { + return session.gitlabBaseUrl; + } + return undefined; +} + +/** + * Safely extract headers from context + */ +export function extractHeaders(session: unknown): Record | undefined { + if (isGitLabSession(session)) { + return session.headers; + } + return undefined; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1476df0..7f940de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,12 +8,18 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "types": ["node", "jest"] + "types": ["node", "jest"], + "declaration": true, + "declarationMap": true, + "sourceMap": true }, "include": [ "src/**/*" ], "exclude": [ - "node_modules" + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts" ] } \ No newline at end of file