@zereight/mcp-gitlab 2.1.26 → 2.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +28 -22
- package/README.md +39 -29
- package/README.zh-CN.md +24 -18
- package/build/index.js +92 -20
- package/build/test/dynamic-api-url-allowlist.test.js +104 -0
- package/build/test/sse-auth-guard.test.js +96 -0
- package/build/utils/schema.js +42 -11
- package/package.json +5 -4
package/README.ko.md
CHANGED
|
@@ -20,7 +20,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
|
|
|
20
20
|
- 클라이언트 친화적 설정: Claude Code, Codex, Antigravity, OpenCode, Copilot, Cline, Roo Code, Cursor, Kilo Code, Amp Code 예시 제공
|
|
21
21
|
- 셀프 호스팅 대응: 커스텀 GitLab 인스턴스, 프록시 설정, 동적 API URL 라우팅 지원
|
|
22
22
|
|
|
23
|
-
빠른 시작: 아래에서 Personal Access Token 또는 OAuth2 설정 중 하나를 선택하고 MCP 클라이언트 설정에서
|
|
23
|
+
빠른 시작: 아래에서 Personal Access Token 또는 OAuth2 설정 중 하나를 선택하고 `@zereight/mcp-gitlab`을 설치한 뒤 MCP 클라이언트 설정에서 `zereight-mcp-gitlab`을 사용하세요.
|
|
24
24
|
|
|
25
25
|
### 클라이언트 설정 가이드
|
|
26
26
|
|
|
@@ -63,6 +63,16 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
|
|
|
63
63
|
|
|
64
64
|
가장 단순한 로컬 설정은 Personal Access Token으로 시작하세요. 브라우저 기반 로컬 인증은 OAuth2를 사용하세요. 원격 또는 멀티 유저 배포는 아래 MCP OAuth 및 원격 인증 섹션을 참고하세요.
|
|
65
65
|
|
|
66
|
+
서버를 한 번 전역 설치하세요.
|
|
67
|
+
|
|
68
|
+
```shell
|
|
69
|
+
npm install -g @zereight/mcp-gitlab
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
73
|
+
|
|
74
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.27`처럼 버전을 고정하세요.
|
|
75
|
+
|
|
66
76
|
#### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
|
|
67
77
|
|
|
68
78
|
일부 MCP 클라이언트(예: GitHub Copilot CLI)는 환경 변수 처리에 문제가 있을 수 있습니다. 이 경우 CLI 인자를 사용하세요.
|
|
@@ -71,13 +81,8 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
|
|
|
71
81
|
{
|
|
72
82
|
"mcpServers": {
|
|
73
83
|
"gitlab": {
|
|
74
|
-
"command": "
|
|
75
|
-
"args": [
|
|
76
|
-
"-y",
|
|
77
|
-
"@zereight/mcp-gitlab",
|
|
78
|
-
"--token=YOUR_GITLAB_TOKEN",
|
|
79
|
-
"--api-url=https://gitlab.com/api/v4"
|
|
80
|
-
],
|
|
84
|
+
"command": "zereight-mcp-gitlab",
|
|
85
|
+
"args": ["--token=YOUR_GITLAB_TOKEN", "--api-url=https://gitlab.com/api/v4"],
|
|
81
86
|
"tools": ["*"]
|
|
82
87
|
}
|
|
83
88
|
}
|
|
@@ -178,15 +183,15 @@ MCP 서버가 직접 로컬 브라우저 callback을 받을 때만 `GITLAB_OAUTH
|
|
|
178
183
|
2. `api` 또는 `read_api` scope가 있는 사전 등록 GitLab OAuth 애플리케이션
|
|
179
184
|
— `Admin area` → `Applications`에서 Redirect URI를 `{MCP_SERVER_URL}/callback`으로 설정하세요.
|
|
180
185
|
|
|
181
|
-
| 환경 변수 | 필수 | 설명
|
|
182
|
-
| ----------------------------- | ---- |
|
|
183
|
-
| `GITLAB_MCP_OAUTH` | 예 | 활성화하려면 `true`
|
|
184
|
-
| `GITLAB_API_URL` | 예 | GitLab API base URL
|
|
185
|
-
| `GITLAB_OAUTH_APP_ID` | 예 | GitLab OAuth Application ID
|
|
186
|
-
| `MCP_SERVER_URL` | 예 | 이 MCP 서버의 공개 HTTPS URL
|
|
187
|
-
| `STREAMABLE_HTTP` | 예 | 반드시 `true`
|
|
188
|
-
| `GITLAB_OAUTH_CALLBACK_PROXY` | 선택 | MCP 서버의 고정 `/callback` URL을 사용하려면 `true`
|
|
189
|
-
| `GITLAB_OAUTH_SCOPES` | 선택 | 쉼표로 구분된 scope 목록(기본값: `api,read_api,read_user`)
|
|
186
|
+
| 환경 변수 | 필수 | 설명 |
|
|
187
|
+
| ----------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
188
|
+
| `GITLAB_MCP_OAUTH` | 예 | 활성화하려면 `true` |
|
|
189
|
+
| `GITLAB_API_URL` | 예 | GitLab API base URL |
|
|
190
|
+
| `GITLAB_OAUTH_APP_ID` | 예 | GitLab OAuth Application ID |
|
|
191
|
+
| `MCP_SERVER_URL` | 예 | 이 MCP 서버의 공개 HTTPS URL |
|
|
192
|
+
| `STREAMABLE_HTTP` | 예 | 반드시 `true` |
|
|
193
|
+
| `GITLAB_OAUTH_CALLBACK_PROXY` | 선택 | MCP 서버의 고정 `/callback` URL을 사용하려면 `true` |
|
|
194
|
+
| `GITLAB_OAUTH_SCOPES` | 선택 | 쉼표로 구분된 scope 목록(기본값: `api,read_api,read_user`) |
|
|
190
195
|
| `GITLAB_OAUTH_ALLOWED_GROUPS` | 선택 | 쉼표로 구분된 GitLab 그룹 전체 경로 — 해당 그룹 및 하위 그룹 멤버만 토큰을 발급받을 수 있음 (기존 `GITLAB_ALLOWED_GROUPS` 대체) |
|
|
191
196
|
|
|
192
197
|
> **`Unregistered redirect_uri` 문제 해결**
|
|
@@ -233,11 +238,12 @@ MCP 클라이언트 설정:
|
|
|
233
238
|
|
|
234
239
|
**헤더 우선순위**: `Private-Token` > `JOB-TOKEN` > `Authorization: Bearer`
|
|
235
240
|
|
|
236
|
-
| 환경 변수 | 필수 | 설명
|
|
237
|
-
| ------------------------ | ---- |
|
|
238
|
-
| `REMOTE_AUTHORIZATION` | 예 | 활성화하려면 `true`
|
|
239
|
-
| `STREAMABLE_HTTP` | 예 | 반드시 `true`
|
|
240
|
-
| `ENABLE_DYNAMIC_API_URL` | 선택 | 요청별 `X-GitLab-API-URL` 헤더 허용
|
|
241
|
+
| 환경 변수 | 필수 | 설명 |
|
|
242
|
+
| ------------------------ | ---- | ------------------------------------------------------------------- |
|
|
243
|
+
| `REMOTE_AUTHORIZATION` | 예 | 활성화하려면 `true` |
|
|
244
|
+
| `STREAMABLE_HTTP` | 예 | 반드시 `true` |
|
|
245
|
+
| `ENABLE_DYNAMIC_API_URL` | 선택 | 요청별 `X-GitLab-API-URL` 헤더 허용 |
|
|
246
|
+
| `GITLAB_ALLOWED_HOSTS` | 선택 | 허용할 호스트의 쉼표 구분 목록; `GITLAB_API_URL` 호스트는 항상 허용 |
|
|
241
247
|
|
|
242
248
|
**예시 요청 헤더:**
|
|
243
249
|
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization
|
|
|
22
22
|
- Client-friendly setup — examples for Claude Code, Codex, Antigravity, OpenCode, Copilot, Cline, Roo Code, Cursor, Kilo Code, and Amp Code
|
|
23
23
|
- Self-hosted ready — works with custom GitLab instances, proxy settings, and dynamic API URL routing
|
|
24
24
|
|
|
25
|
-
Quick start: choose either Personal Access Token or OAuth2 setup below and use
|
|
25
|
+
Quick start: choose either Personal Access Token or OAuth2 setup below, install `@zereight/mcp-gitlab`, and use `zereight-mcp-gitlab` in your MCP client configuration.
|
|
26
26
|
|
|
27
27
|
### Client Setup Guides
|
|
28
28
|
|
|
@@ -67,6 +67,16 @@ The server supports four authentication methods:
|
|
|
67
67
|
|
|
68
68
|
For the simplest local setup, start with a Personal Access Token. For browser-based local auth, use OAuth2. For remote or multi-user deployments, continue to the MCP OAuth and Remote Authorization sections later in this README.
|
|
69
69
|
|
|
70
|
+
Install the server globally once:
|
|
71
|
+
|
|
72
|
+
```shell
|
|
73
|
+
npm install -g @zereight/mcp-gitlab
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
|
|
77
|
+
|
|
78
|
+
No global install? Pin `npx` to a known version, for example `npx -y @zereight/mcp-gitlab@2.1.27`.
|
|
79
|
+
|
|
70
80
|
#### Using CLI Arguments (for clients with env var issues)
|
|
71
81
|
|
|
72
82
|
Some MCP clients (like GitHub Copilot CLI) have issues with environment variables. Use CLI arguments instead:
|
|
@@ -75,13 +85,8 @@ Some MCP clients (like GitHub Copilot CLI) have issues with environment variable
|
|
|
75
85
|
{
|
|
76
86
|
"mcpServers": {
|
|
77
87
|
"gitlab": {
|
|
78
|
-
"command": "
|
|
79
|
-
"args": [
|
|
80
|
-
"-y",
|
|
81
|
-
"@zereight/mcp-gitlab",
|
|
82
|
-
"--token=YOUR_GITLAB_TOKEN",
|
|
83
|
-
"--api-url=https://gitlab.com/api/v4"
|
|
84
|
-
],
|
|
88
|
+
"command": "zereight-mcp-gitlab",
|
|
89
|
+
"args": ["--token=YOUR_GITLAB_TOKEN", "--api-url=https://gitlab.com/api/v4"],
|
|
85
90
|
"tools": ["*"]
|
|
86
91
|
}
|
|
87
92
|
}
|
|
@@ -111,6 +116,7 @@ docker run -i --rm \
|
|
|
111
116
|
-e USE_MILESTONE=true \
|
|
112
117
|
-e USE_PIPELINE=true \
|
|
113
118
|
-e SSE=true \
|
|
119
|
+
-e SSE_AUTH_TOKEN=your_mcp_sse_token \
|
|
114
120
|
-p 3333:3002 \
|
|
115
121
|
zereight050/gitlab-mcp
|
|
116
122
|
```
|
|
@@ -120,7 +126,10 @@ docker run -i --rm \
|
|
|
120
126
|
"mcpServers": {
|
|
121
127
|
"gitlab": {
|
|
122
128
|
"type": "sse",
|
|
123
|
-
"url": "http://localhost:3333/sse"
|
|
129
|
+
"url": "http://localhost:3333/sse",
|
|
130
|
+
"headers": {
|
|
131
|
+
"Authorization": "Bearer your_mcp_sse_token"
|
|
132
|
+
}
|
|
124
133
|
}
|
|
125
134
|
}
|
|
126
135
|
}
|
|
@@ -182,10 +191,10 @@ Remote MCP OAuth is different. In `GITLAB_MCP_OAUTH=true` mode, the MCP client
|
|
|
182
191
|
provides its own callback URL during `/authorize`. `GITLAB_OAUTH_REDIRECT_URI`
|
|
183
192
|
does not replace that client-provided URL.
|
|
184
193
|
|
|
185
|
-
| Mode
|
|
186
|
-
|
|
|
187
|
-
| Local OAuth
|
|
188
|
-
| Remote MCP OAuth | `GITLAB_MCP_OAUTH=true` | `GITLAB_OAUTH_CALLBACK_PROXY=true` | `{MCP_SERVER_URL}/callback`
|
|
194
|
+
| Mode | Enable with | Callback variable | GitLab redirect URI |
|
|
195
|
+
| ---------------- | ----------------------- | ---------------------------------- | ------------------------------------------------------- |
|
|
196
|
+
| Local OAuth | `GITLAB_USE_OAUTH=true` | `GITLAB_OAUTH_REDIRECT_URI` | `http://127.0.0.1:8888/callback` or your local callback |
|
|
197
|
+
| Remote MCP OAuth | `GITLAB_MCP_OAUTH=true` | `GITLAB_OAUTH_CALLBACK_PROXY=true` | `{MCP_SERVER_URL}/callback` |
|
|
189
198
|
|
|
190
199
|
Use `GITLAB_OAUTH_REDIRECT_URI` only when the MCP server itself owns the local
|
|
191
200
|
browser callback. Use `GITLAB_OAUTH_CALLBACK_PROXY=true` when a remote MCP client
|
|
@@ -201,15 +210,15 @@ exchanging credentials with GitLab on behalf of the client.
|
|
|
201
210
|
2. A pre-registered GitLab OAuth application with `api` (or `read_api`) scopes
|
|
202
211
|
— Go to `Admin area` → `Applications`, set Redirect URI to `{MCP_SERVER_URL}/callback`
|
|
203
212
|
|
|
204
|
-
| Environment Variable
|
|
205
|
-
|
|
|
206
|
-
| `GITLAB_MCP_OAUTH`
|
|
207
|
-
| `GITLAB_API_URL`
|
|
208
|
-
| `GITLAB_OAUTH_APP_ID`
|
|
209
|
-
| `MCP_SERVER_URL`
|
|
210
|
-
| `STREAMABLE_HTTP`
|
|
211
|
-
| `GITLAB_OAUTH_CALLBACK_PROXY` | optional | Set to `true` to use the MCP server's fixed `/callback` URL
|
|
212
|
-
| `GITLAB_OAUTH_SCOPES`
|
|
213
|
+
| Environment Variable | Required | Description |
|
|
214
|
+
| ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
215
|
+
| `GITLAB_MCP_OAUTH` | ✅ | Set to `true` to enable |
|
|
216
|
+
| `GITLAB_API_URL` | ✅ | GitLab API base URL |
|
|
217
|
+
| `GITLAB_OAUTH_APP_ID` | ✅ | GitLab OAuth Application ID |
|
|
218
|
+
| `MCP_SERVER_URL` | ✅ | Public HTTPS URL of this MCP server |
|
|
219
|
+
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
220
|
+
| `GITLAB_OAUTH_CALLBACK_PROXY` | optional | Set to `true` to use the MCP server's fixed `/callback` URL |
|
|
221
|
+
| `GITLAB_OAUTH_SCOPES` | optional | Comma-separated scopes (default: `api,read_api,read_user`) |
|
|
213
222
|
| `GITLAB_OAUTH_ALLOWED_GROUPS` | optional | Comma-separated group full paths — only members (and subgroup members) may obtain a token (replaces deprecated `GITLAB_ALLOWED_GROUPS`) |
|
|
214
223
|
|
|
215
224
|
When `STREAMABLE_HTTP=true`, server-side `GITLAB_PERSONAL_ACCESS_TOKEN` or `GITLAB_JOB_TOKEN` require `REMOTE_AUTHORIZATION=true` or `GITLAB_MCP_OAUTH=true`.
|
|
@@ -262,13 +271,14 @@ the token to GitLab on behalf of the caller.
|
|
|
262
271
|
|
|
263
272
|
**Header priority**: `Private-Token` > `JOB-TOKEN` > `Authorization: Bearer`
|
|
264
273
|
|
|
265
|
-
| Environment Variable
|
|
266
|
-
|
|
|
267
|
-
| `REMOTE_AUTHORIZATION`
|
|
268
|
-
| `STREAMABLE_HTTP`
|
|
269
|
-
| `ENABLE_DYNAMIC_API_URL`
|
|
270
|
-
| `
|
|
271
|
-
| `
|
|
274
|
+
| Environment Variable | Required | Description |
|
|
275
|
+
| --------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
276
|
+
| `REMOTE_AUTHORIZATION` | ✅ | Set to `true` to enable |
|
|
277
|
+
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
278
|
+
| `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
|
|
279
|
+
| `GITLAB_ALLOWED_HOSTS` | optional | Comma-separated allowed `X-GitLab-API-URL` hosts; `GITLAB_API_URL` hosts are always allowed |
|
|
280
|
+
| `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | optional | Allow unauthenticated `initialize`, `notifications/initialized`, and `tools/list` only (tool calls still require auth) |
|
|
281
|
+
| `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers behind a reverse proxy (download URLs, Express `req.ip`, OAuth rate limits) |
|
|
272
282
|
|
|
273
283
|
`GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY=true` is intended for MCP gateways
|
|
274
284
|
or admin UIs that need to inspect tool metadata before a user provides a GitLab
|
package/README.zh-CN.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
- 客户端设置友好:提供 Claude Code、Codex、Antigravity、OpenCode、Copilot、Cline、Roo Code、Cursor、Kilo Code 和 Amp Code 示例
|
|
21
21
|
- 适合自托管:支持自定义 GitLab 实例、代理设置和动态 API URL 路由
|
|
22
22
|
|
|
23
|
-
快速开始:在下面选择 Personal Access Token 或 OAuth2
|
|
23
|
+
快速开始:在下面选择 Personal Access Token 或 OAuth2 设置,安装 `@zereight/mcp-gitlab`,并在 MCP 客户端配置中使用 `zereight-mcp-gitlab`。
|
|
24
24
|
|
|
25
25
|
### 客户端设置指南
|
|
26
26
|
|
|
@@ -63,6 +63,16 @@
|
|
|
63
63
|
|
|
64
64
|
最简单的本地设置可以从 Personal Access Token 开始。基于浏览器的本地认证使用 OAuth2。远程或多用户部署请继续查看下面的 MCP OAuth 和远程授权部分。
|
|
65
65
|
|
|
66
|
+
先全局安装一次服务器:
|
|
67
|
+
|
|
68
|
+
```shell
|
|
69
|
+
npm install -g @zereight/mcp-gitlab
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
73
|
+
|
|
74
|
+
如果不想全局安装,请固定 `npx` 版本,例如 `npx -y @zereight/mcp-gitlab@2.1.27`。
|
|
75
|
+
|
|
66
76
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
67
77
|
|
|
68
78
|
部分 MCP 客户端(例如 GitHub Copilot CLI)可能难以处理环境变量。可以改用 CLI 参数。
|
|
@@ -71,13 +81,8 @@
|
|
|
71
81
|
{
|
|
72
82
|
"mcpServers": {
|
|
73
83
|
"gitlab": {
|
|
74
|
-
"command": "
|
|
75
|
-
"args": [
|
|
76
|
-
"-y",
|
|
77
|
-
"@zereight/mcp-gitlab",
|
|
78
|
-
"--token=YOUR_GITLAB_TOKEN",
|
|
79
|
-
"--api-url=https://gitlab.com/api/v4"
|
|
80
|
-
],
|
|
84
|
+
"command": "zereight-mcp-gitlab",
|
|
85
|
+
"args": ["--token=YOUR_GITLAB_TOKEN", "--api-url=https://gitlab.com/api/v4"],
|
|
81
86
|
"tools": ["*"]
|
|
82
87
|
}
|
|
83
88
|
}
|
|
@@ -178,16 +183,16 @@ OpenCode、MCPJam、Claude.ai 等远程 MCP 客户端可能会在授权时发送
|
|
|
178
183
|
2. 预先注册的 GitLab OAuth 应用,包含 `api` 或 `read_api` scopes
|
|
179
184
|
— 前往 `Admin area` → `Applications`,将 Redirect URI 设置为 `{MCP_SERVER_URL}/callback`
|
|
180
185
|
|
|
181
|
-
| 环境变量 | 必需 | 说明
|
|
182
|
-
| ----------------------------- | ---- |
|
|
183
|
-
| `GITLAB_MCP_OAUTH` | 是 | 设置为 `true` 以启用
|
|
184
|
-
| `GITLAB_API_URL` | 是 | GitLab API base URL
|
|
185
|
-
| `GITLAB_OAUTH_APP_ID` | 是 | GitLab OAuth Application ID
|
|
186
|
-
| `MCP_SERVER_URL` | 是 | 此 MCP 服务器的公开 HTTPS URL
|
|
187
|
-
| `STREAMABLE_HTTP` | 是 | 必须为 `true`
|
|
188
|
-
| `GITLAB_OAUTH_CALLBACK_PROXY` | 可选 | 设置为 `true` 时使用 MCP 服务器固定的 `/callback` URL
|
|
189
|
-
| `GITLAB_OAUTH_SCOPES` | 可选 | 逗号分隔的 scope(默认:`api,read_api,read_user`)
|
|
190
|
-
| `GITLAB_OAUTH_ALLOWED_GROUPS` | 可选 | 逗号分隔的 GitLab 群组完整路径 — 仅该群组及其子群组的成员可获取令牌(替代已废弃的 `GITLAB_ALLOWED_GROUPS
|
|
186
|
+
| 环境变量 | 必需 | 说明 |
|
|
187
|
+
| ----------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- |
|
|
188
|
+
| `GITLAB_MCP_OAUTH` | 是 | 设置为 `true` 以启用 |
|
|
189
|
+
| `GITLAB_API_URL` | 是 | GitLab API base URL |
|
|
190
|
+
| `GITLAB_OAUTH_APP_ID` | 是 | GitLab OAuth Application ID |
|
|
191
|
+
| `MCP_SERVER_URL` | 是 | 此 MCP 服务器的公开 HTTPS URL |
|
|
192
|
+
| `STREAMABLE_HTTP` | 是 | 必须为 `true` |
|
|
193
|
+
| `GITLAB_OAUTH_CALLBACK_PROXY` | 可选 | 设置为 `true` 时使用 MCP 服务器固定的 `/callback` URL |
|
|
194
|
+
| `GITLAB_OAUTH_SCOPES` | 可选 | 逗号分隔的 scope(默认:`api,read_api,read_user`) |
|
|
195
|
+
| `GITLAB_OAUTH_ALLOWED_GROUPS` | 可选 | 逗号分隔的 GitLab 群组完整路径 — 仅该群组及其子群组的成员可获取令牌(替代已废弃的 `GITLAB_ALLOWED_GROUPS`) |
|
|
191
196
|
|
|
192
197
|
> **排查 `Unregistered redirect_uri`**
|
|
193
198
|
>
|
|
@@ -238,6 +243,7 @@ MCP 客户端配置:
|
|
|
238
243
|
| `REMOTE_AUTHORIZATION` | 是 | 设置为 `true` 以启用 |
|
|
239
244
|
| `STREAMABLE_HTTP` | 是 | 必须为 `true` |
|
|
240
245
|
| `ENABLE_DYNAMIC_API_URL` | 可选 | 允许按请求通过 `X-GitLab-API-URL` 请求头指定 GitLab URL |
|
|
246
|
+
| `GITLAB_ALLOWED_HOSTS` | 可选 | 逗号分隔的允许主机;`GITLAB_API_URL` 中的主机始终允许 |
|
|
241
247
|
|
|
242
248
|
**示例请求头:**
|
|
243
249
|
|
package/build/index.js
CHANGED
|
@@ -467,6 +467,14 @@ function createServer() {
|
|
|
467
467
|
/**
|
|
468
468
|
* Validate configuration at startup
|
|
469
469
|
*/
|
|
470
|
+
function isLoopbackBindHost(host) {
|
|
471
|
+
const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
472
|
+
const isIpv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized);
|
|
473
|
+
return (normalized === "localhost" ||
|
|
474
|
+
isIpv4Loopback ||
|
|
475
|
+
normalized === "::1" ||
|
|
476
|
+
normalized === "0:0:0:0:0:0:0:1");
|
|
477
|
+
}
|
|
470
478
|
function validateConfiguration() {
|
|
471
479
|
const errors = [];
|
|
472
480
|
// Validate SESSION_TIMEOUT_SECONDS
|
|
@@ -517,6 +525,12 @@ function validateConfiguration() {
|
|
|
517
525
|
}
|
|
518
526
|
}
|
|
519
527
|
}
|
|
528
|
+
const allowedHosts = getConfig("allowed-hosts", "GITLAB_ALLOWED_HOSTS")?.split(",") || [];
|
|
529
|
+
for (const host of allowedHosts) {
|
|
530
|
+
if (host.trim() && !toAllowedGitLabApiUrl(host)) {
|
|
531
|
+
errors.push(`GITLAB_ALLOWED_HOSTS contains an invalid host or URL: ${host.trim()}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
520
534
|
// Validate auth configuration
|
|
521
535
|
const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
522
536
|
const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
|
|
@@ -526,12 +540,19 @@ function validateConfiguration() {
|
|
|
526
540
|
const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
|
|
527
541
|
const mcpServerUrl = getConfig("mcp-server-url", "MCP_SERVER_URL");
|
|
528
542
|
const streamableHttp = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
|
|
543
|
+
const sse = getConfig("sse", "SSE") === "true";
|
|
544
|
+
const bindHost = getConfig("host", "HOST") || "127.0.0.1";
|
|
545
|
+
const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
|
|
546
|
+
const allowUnauthenticatedRemoteSse = getConfig("sse-dangerously-allow-unauthenticated-remote", "SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE") === "true";
|
|
529
547
|
if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie && !mcpOAuth) {
|
|
530
548
|
errors.push("Either --token, --job-token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables)");
|
|
531
549
|
}
|
|
532
550
|
if (streamableHttp && (hasToken || hasJobToken) && !remoteAuth && !mcpOAuth) {
|
|
533
551
|
errors.push("STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token or GITLAB_JOB_TOKEN/--job-token requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true");
|
|
534
552
|
}
|
|
553
|
+
if (sse && !isLoopbackBindHost(bindHost) && !sseAuthToken && !allowUnauthenticatedRemoteSse) {
|
|
554
|
+
errors.push("SSE=true on a non-loopback HOST requires SSE_AUTH_TOKEN (or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)");
|
|
555
|
+
}
|
|
535
556
|
if (mcpOAuth) {
|
|
536
557
|
if (!mcpServerUrl) {
|
|
537
558
|
errors.push("MCP_SERVER_URL is required when GITLAB_MCP_OAUTH=true (e.g. https://mcp.example.com)");
|
|
@@ -986,11 +1007,57 @@ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
|
|
|
986
1007
|
"Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
|
|
987
1008
|
}
|
|
988
1009
|
const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10;
|
|
1010
|
+
function toAllowedGitLabApiUrl(value) {
|
|
1011
|
+
const trimmed = value.trim();
|
|
1012
|
+
if (!trimmed)
|
|
1013
|
+
return null;
|
|
1014
|
+
try {
|
|
1015
|
+
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
1016
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
1017
|
+
return null;
|
|
1018
|
+
return { host: url.host, apiUrl: normalizeGitLabApiUrl(url.toString()) };
|
|
1019
|
+
}
|
|
1020
|
+
catch {
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
function parseAllowedGitLabApiUrls(value) {
|
|
1025
|
+
return value
|
|
1026
|
+
.split(",")
|
|
1027
|
+
.map(toAllowedGitLabApiUrl)
|
|
1028
|
+
.filter((entry) => Boolean(entry));
|
|
1029
|
+
}
|
|
1030
|
+
function encodeGitLabPathSegment(value) {
|
|
1031
|
+
return encodeURIComponent(decodeURIComponent(value));
|
|
1032
|
+
}
|
|
1033
|
+
function encodeGitLabPath(value) {
|
|
1034
|
+
return value.split("/").map(encodeGitLabPathSegment).join("/");
|
|
1035
|
+
}
|
|
1036
|
+
function resolveTrustedGitLabApiUrl(value) {
|
|
1037
|
+
const parsed = new URL(normalizeGitLabApiUrl(value));
|
|
1038
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1039
|
+
throw new Error("GitLab API URL must use HTTP or HTTPS");
|
|
1040
|
+
}
|
|
1041
|
+
const allowedApiUrl = GITLAB_ALLOWED_API_URLS_BY_HOST.get(parsed.host);
|
|
1042
|
+
if (!allowedApiUrl) {
|
|
1043
|
+
throw new Error(`GitLab API URL host is not allowed: ${parsed.host}`);
|
|
1044
|
+
}
|
|
1045
|
+
return allowedApiUrl;
|
|
1046
|
+
}
|
|
989
1047
|
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
990
1048
|
const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
|
|
991
1049
|
.split(",")
|
|
992
1050
|
.map(normalizeGitLabApiUrl);
|
|
993
1051
|
const GITLAB_API_URL = GITLAB_API_URLS[0];
|
|
1052
|
+
const GITLAB_ALLOWED_API_URLS_BY_HOST = new Map();
|
|
1053
|
+
for (const { host, apiUrl } of [
|
|
1054
|
+
...GITLAB_API_URLS.map(toAllowedGitLabApiUrl).filter((entry) => Boolean(entry)),
|
|
1055
|
+
...parseAllowedGitLabApiUrls(getConfig("allowed-hosts", "GITLAB_ALLOWED_HOSTS") || ""),
|
|
1056
|
+
]) {
|
|
1057
|
+
if (!GITLAB_ALLOWED_API_URLS_BY_HOST.has(host)) {
|
|
1058
|
+
GITLAB_ALLOWED_API_URLS_BY_HOST.set(host, apiUrl);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
994
1061
|
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
|
|
995
1062
|
const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(",")
|
|
996
1063
|
.map(id => id.trim())
|
|
@@ -8844,18 +8911,15 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8844
8911
|
return;
|
|
8845
8912
|
}
|
|
8846
8913
|
// API URL: prefer token-embedded URL, then X-GitLab-API-URL header, then default
|
|
8847
|
-
let apiUrl =
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
|
|
8853
|
-
|
|
8854
|
-
}
|
|
8855
|
-
|
|
8856
|
-
res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
|
|
8857
|
-
return;
|
|
8858
|
-
}
|
|
8914
|
+
let apiUrl = GITLAB_API_URL;
|
|
8915
|
+
const requestedApiUrl = tokenApiUrl || req.headers["x-gitlab-api-url"]?.trim();
|
|
8916
|
+
if (ENABLE_DYNAMIC_API_URL && requestedApiUrl) {
|
|
8917
|
+
try {
|
|
8918
|
+
apiUrl = resolveTrustedGitLabApiUrl(requestedApiUrl);
|
|
8919
|
+
}
|
|
8920
|
+
catch {
|
|
8921
|
+
res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
|
|
8922
|
+
return;
|
|
8859
8923
|
}
|
|
8860
8924
|
}
|
|
8861
8925
|
const { type } = req.params;
|
|
@@ -8869,7 +8933,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8869
8933
|
return;
|
|
8870
8934
|
}
|
|
8871
8935
|
const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
|
|
8872
|
-
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${job_id}/artifacts`;
|
|
8936
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(job_id)}/artifacts`;
|
|
8873
8937
|
break;
|
|
8874
8938
|
}
|
|
8875
8939
|
case "attachment": {
|
|
@@ -8879,7 +8943,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8879
8943
|
return;
|
|
8880
8944
|
}
|
|
8881
8945
|
const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
|
|
8882
|
-
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`;
|
|
8946
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${encodeGitLabPathSegment(secret)}/${encodeGitLabPath(filename)}`;
|
|
8883
8947
|
break;
|
|
8884
8948
|
}
|
|
8885
8949
|
case "release-asset": {
|
|
@@ -8891,7 +8955,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8891
8955
|
return;
|
|
8892
8956
|
}
|
|
8893
8957
|
const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
|
|
8894
|
-
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${direct_asset_path}`;
|
|
8958
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${encodeGitLabPath(direct_asset_path)}`;
|
|
8895
8959
|
break;
|
|
8896
8960
|
}
|
|
8897
8961
|
default:
|
|
@@ -8943,12 +9007,21 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8943
9007
|
*/
|
|
8944
9008
|
async function startSSEServer() {
|
|
8945
9009
|
const app = express();
|
|
9010
|
+
const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
|
|
8946
9011
|
if (MCP_TRUST_PROXY) {
|
|
8947
9012
|
app.set("trust proxy", 1);
|
|
8948
9013
|
}
|
|
9014
|
+
const requireSseAuth = (req, res, next) => {
|
|
9015
|
+
if (!sseAuthToken)
|
|
9016
|
+
return next();
|
|
9017
|
+
const match = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization || "");
|
|
9018
|
+
if (match?.[1] === sseAuthToken)
|
|
9019
|
+
return next();
|
|
9020
|
+
res.status(401).json({ error: "SSE authentication required" });
|
|
9021
|
+
};
|
|
8949
9022
|
const transports = {};
|
|
8950
9023
|
let shuttingDown = false;
|
|
8951
|
-
app.get("/sse", async (_, res) => {
|
|
9024
|
+
app.get("/sse", requireSseAuth, async (_, res) => {
|
|
8952
9025
|
const serverInstance = createServer();
|
|
8953
9026
|
const transport = new SSEServerTransport("/messages", res);
|
|
8954
9027
|
transports[transport.sessionId] = transport;
|
|
@@ -8957,7 +9030,7 @@ async function startSSEServer() {
|
|
|
8957
9030
|
});
|
|
8958
9031
|
await serverInstance.connect(transport);
|
|
8959
9032
|
});
|
|
8960
|
-
app.post("/messages", async (req, res) => {
|
|
9033
|
+
app.post("/messages", requireSseAuth, async (req, res) => {
|
|
8961
9034
|
const sessionId = req.query.sessionId;
|
|
8962
9035
|
const transport = transports[sessionId];
|
|
8963
9036
|
if (transport) {
|
|
@@ -9081,12 +9154,11 @@ async function startStreamableHTTPServer() {
|
|
|
9081
9154
|
// Only process dynamic URL if the feature is enabled
|
|
9082
9155
|
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
|
|
9083
9156
|
try {
|
|
9084
|
-
|
|
9085
|
-
apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
|
|
9157
|
+
apiUrl = resolveTrustedGitLabApiUrl(dynamicApiUrl);
|
|
9086
9158
|
}
|
|
9087
9159
|
catch {
|
|
9088
9160
|
logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. Auth will fail.`);
|
|
9089
|
-
return null; // Reject if URL is malformed
|
|
9161
|
+
return null; // Reject if URL is malformed or not allowed
|
|
9090
9162
|
}
|
|
9091
9163
|
}
|
|
9092
9164
|
// Extract token — priority: Private-Token > JOB-TOKEN > Authorization Bearer
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { after, before, describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
|
|
5
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
6
|
+
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
7
|
+
const MOCK_TOKEN = "glpat-dynamic-url-token";
|
|
8
|
+
async function startAttackerServer(port) {
|
|
9
|
+
let hits = 0;
|
|
10
|
+
const server = createServer((_req, res) => {
|
|
11
|
+
hits++;
|
|
12
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
13
|
+
res.end("[]");
|
|
14
|
+
});
|
|
15
|
+
await new Promise((resolve, reject) => {
|
|
16
|
+
server.once("error", reject);
|
|
17
|
+
server.listen(port, HOST, () => {
|
|
18
|
+
server.off("error", reject);
|
|
19
|
+
resolve();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
return { server, getHits: () => hits };
|
|
23
|
+
}
|
|
24
|
+
describe("Dynamic API URL allowlist", () => {
|
|
25
|
+
let primaryGitLab;
|
|
26
|
+
let secondaryGitLab;
|
|
27
|
+
let attackerServer;
|
|
28
|
+
let mcpServer;
|
|
29
|
+
let mcpUrl;
|
|
30
|
+
let secondaryHit = false;
|
|
31
|
+
let getAttackerHits = () => 0;
|
|
32
|
+
before(async () => {
|
|
33
|
+
const primaryPort = await findMockServerPort(9100);
|
|
34
|
+
primaryGitLab = new MockGitLabServer({ port: primaryPort, validTokens: [MOCK_TOKEN] });
|
|
35
|
+
await primaryGitLab.start();
|
|
36
|
+
const secondaryPort = await findMockServerPort(9200);
|
|
37
|
+
secondaryGitLab = new MockGitLabServer({ port: secondaryPort, validTokens: [MOCK_TOKEN] });
|
|
38
|
+
secondaryGitLab.addMockHandler("get", "/projects/1/issues", (_req, res) => {
|
|
39
|
+
secondaryHit = true;
|
|
40
|
+
res.json([]);
|
|
41
|
+
});
|
|
42
|
+
await secondaryGitLab.start();
|
|
43
|
+
const attackerPort = await findAvailablePort(9300);
|
|
44
|
+
const attacker = await startAttackerServer(attackerPort);
|
|
45
|
+
attackerServer = attacker.server;
|
|
46
|
+
getAttackerHits = attacker.getHits;
|
|
47
|
+
const mcpPort = await findAvailablePort(3100);
|
|
48
|
+
mcpServer = await launchServer({
|
|
49
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
50
|
+
port: mcpPort,
|
|
51
|
+
timeout: 5000,
|
|
52
|
+
env: {
|
|
53
|
+
STREAMABLE_HTTP: "true",
|
|
54
|
+
REMOTE_AUTHORIZATION: "true",
|
|
55
|
+
ENABLE_DYNAMIC_API_URL: "true",
|
|
56
|
+
GITLAB_API_URL: `${primaryGitLab.getUrl()}/api/v4`,
|
|
57
|
+
GITLAB_ALLOWED_HOSTS: `${secondaryGitLab.getUrl()}/api/v4`,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
61
|
+
});
|
|
62
|
+
after(async () => {
|
|
63
|
+
if (mcpServer)
|
|
64
|
+
cleanupServers([mcpServer]);
|
|
65
|
+
await primaryGitLab?.stop();
|
|
66
|
+
await secondaryGitLab?.stop();
|
|
67
|
+
const server = attackerServer;
|
|
68
|
+
if (server)
|
|
69
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
70
|
+
});
|
|
71
|
+
test("allows dynamic API URLs on configured hosts", async () => {
|
|
72
|
+
const client = new CustomHeaderClient({
|
|
73
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
74
|
+
"x-gitlab-api-url": `${secondaryGitLab.getUrl()}/api/v4`,
|
|
75
|
+
});
|
|
76
|
+
await client.connect(mcpUrl);
|
|
77
|
+
await client.callTool("list_issues", { project_id: "1" });
|
|
78
|
+
await client.disconnect();
|
|
79
|
+
assert.strictEqual(secondaryHit, true, "allowed dynamic host should receive GitLab calls");
|
|
80
|
+
});
|
|
81
|
+
test("rejects dynamic API URLs on unconfigured hosts before forwarding tokens", async () => {
|
|
82
|
+
const server = attackerServer;
|
|
83
|
+
assert.ok(server, "attacker server should be running");
|
|
84
|
+
const attackerUrl = `http://${HOST}:${server.address().port}/api/v4`;
|
|
85
|
+
const client = new CustomHeaderClient({
|
|
86
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
87
|
+
"x-gitlab-api-url": attackerUrl,
|
|
88
|
+
});
|
|
89
|
+
let connected = false;
|
|
90
|
+
try {
|
|
91
|
+
await client.connect(mcpUrl);
|
|
92
|
+
connected = true;
|
|
93
|
+
await client.callTool("list_issues", { project_id: "1" });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Expected: the session is rejected before any GitLab API request is made.
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await client.disconnect();
|
|
100
|
+
}
|
|
101
|
+
assert.strictEqual(connected, false, "untrusted dynamic host should not initialize a session");
|
|
102
|
+
assert.strictEqual(getAttackerHits(), 0, "token-bearing requests must not reach untrusted hosts");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { afterEach, describe, test } from "node:test";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { findAvailablePort } from "./utils/server-launcher.js";
|
|
7
|
+
const ERROR_MESSAGE = "SSE=true on a non-loopback HOST requires SSE_AUTH_TOKEN (or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)";
|
|
8
|
+
const LOOPBACK = "127.0.0.1";
|
|
9
|
+
const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
|
|
10
|
+
const running = new Set();
|
|
11
|
+
function startSseServer(env, port) {
|
|
12
|
+
const child = spawn("node", [SERVER_PATH], {
|
|
13
|
+
env: {
|
|
14
|
+
...process.env,
|
|
15
|
+
GITLAB_API_URL: "https://gitlab.example.com/api/v4",
|
|
16
|
+
HOST: "0.0.0.0",
|
|
17
|
+
PORT: String(port),
|
|
18
|
+
SSE: "true",
|
|
19
|
+
STREAMABLE_HTTP: "false",
|
|
20
|
+
REMOTE_AUTHORIZATION: "false",
|
|
21
|
+
GITLAB_MCP_OAUTH: "false",
|
|
22
|
+
GITLAB_USE_OAUTH: "false",
|
|
23
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
|
|
24
|
+
GITLAB_JOB_TOKEN: "",
|
|
25
|
+
GITLAB_AUTH_COOKIE_PATH: "",
|
|
26
|
+
...env,
|
|
27
|
+
},
|
|
28
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
29
|
+
});
|
|
30
|
+
running.add(child);
|
|
31
|
+
child.once("exit", () => running.delete(child));
|
|
32
|
+
return child;
|
|
33
|
+
}
|
|
34
|
+
async function waitForExit(child, timeoutMs = 5000) {
|
|
35
|
+
let output = "";
|
|
36
|
+
child.stdout?.on("data", chunk => {
|
|
37
|
+
output += chunk.toString();
|
|
38
|
+
});
|
|
39
|
+
child.stderr?.on("data", chunk => {
|
|
40
|
+
output += chunk.toString();
|
|
41
|
+
});
|
|
42
|
+
let timeoutHandle;
|
|
43
|
+
const timeout = new Promise((_, reject) => {
|
|
44
|
+
timeoutHandle = setTimeout(() => reject(new Error(`server did not exit within ${timeoutMs}ms`)), timeoutMs);
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
const [code] = (await Promise.race([once(child, "exit"), timeout]));
|
|
48
|
+
return { code, output };
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
clearTimeout(timeoutHandle);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function waitForHealth(port, timeoutMs = 5000) {
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
let lastError;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
try {
|
|
59
|
+
const response = await fetch(`http://${LOOPBACK}:${port}/health`);
|
|
60
|
+
if (response.ok)
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
lastError = error;
|
|
65
|
+
}
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`server did not become healthy: ${String(lastError)}`);
|
|
69
|
+
}
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
for (const child of running) {
|
|
72
|
+
if (!child.killed)
|
|
73
|
+
child.kill("SIGTERM");
|
|
74
|
+
}
|
|
75
|
+
running.clear();
|
|
76
|
+
});
|
|
77
|
+
describe("SSE remote auth guard", () => {
|
|
78
|
+
test("refuses unauthenticated SSE on non-loopback hosts", async () => {
|
|
79
|
+
const port = await findAvailablePort(4400);
|
|
80
|
+
const child = startSseServer({}, port);
|
|
81
|
+
const { code, output } = await waitForExit(child);
|
|
82
|
+
assert.notEqual(code, 0);
|
|
83
|
+
assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
84
|
+
});
|
|
85
|
+
test("requires bearer auth on SSE endpoints when SSE_AUTH_TOKEN is configured", async () => {
|
|
86
|
+
const port = await findAvailablePort(4410);
|
|
87
|
+
startSseServer({ SSE_AUTH_TOKEN: "mcp_sse_secret" }, port);
|
|
88
|
+
await waitForHealth(port);
|
|
89
|
+
const sseResponse = await fetch(`http://${LOOPBACK}:${port}/sse`);
|
|
90
|
+
assert.equal(sseResponse.status, 401);
|
|
91
|
+
const unauthenticatedMessage = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=missing`, { method: "POST" });
|
|
92
|
+
assert.equal(unauthenticatedMessage.status, 401);
|
|
93
|
+
const authenticatedMessage = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=missing`, { method: "POST", headers: { Authorization: "Bearer mcp_sse_secret" } });
|
|
94
|
+
assert.equal(authenticatedMessage.status, 400);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/build/utils/schema.js
CHANGED
|
@@ -87,17 +87,48 @@ export const toJSONSchema = (schema) => {
|
|
|
87
87
|
return obj;
|
|
88
88
|
}
|
|
89
89
|
const fixedSchema = fixNullableOptional(jsonSchema, true);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
90
|
+
// Flatten top-level anyOf/oneOf into a single object schema for Anthropic API compatibility.
|
|
91
|
+
// The Anthropic API rejects tool input_schema with top-level oneOf/allOf/anyOf.
|
|
92
|
+
for (const combiner of ["anyOf", "oneOf", "allOf"]) {
|
|
93
|
+
if (Array.isArray(fixedSchema[combiner])) {
|
|
94
|
+
const variants = fixedSchema[combiner].filter((item) => item?.type === "object" && item.properties);
|
|
95
|
+
if (variants.length > 0 && variants.length === fixedSchema[combiner].length) {
|
|
96
|
+
fixedSchema.type = "object";
|
|
97
|
+
fixedSchema.properties = fixedSchema.properties || {};
|
|
98
|
+
for (const variant of variants) {
|
|
99
|
+
for (const [key, value] of Object.entries(variant.properties)) {
|
|
100
|
+
if (!fixedSchema.properties[key]) {
|
|
101
|
+
fixedSchema.properties[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Compute required fields based on combiner semantics:
|
|
106
|
+
// - allOf: union (all schemas apply, so all requirements apply)
|
|
107
|
+
// - anyOf/oneOf: intersection (only shared requirements are universal)
|
|
108
|
+
const requiredSets = variants.map((v) => new Set(Array.isArray(v.required) ? v.required : []));
|
|
109
|
+
let mergedRequired;
|
|
110
|
+
if (combiner === "allOf") {
|
|
111
|
+
// Union: any field required in any variant is required
|
|
112
|
+
const all = new Set();
|
|
113
|
+
for (const s of requiredSets) {
|
|
114
|
+
for (const field of s)
|
|
115
|
+
all.add(field);
|
|
116
|
+
}
|
|
117
|
+
mergedRequired = [...all];
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Intersection: only fields required in ALL variants
|
|
121
|
+
mergedRequired = [...requiredSets[0]].filter(field => requiredSets.every((s) => s.has(field)));
|
|
122
|
+
}
|
|
123
|
+
if (mergedRequired.length > 0) {
|
|
124
|
+
const existing = new Set(Array.isArray(fixedSchema.required) ? fixedSchema.required : []);
|
|
125
|
+
for (const field of mergedRequired) {
|
|
126
|
+
existing.add(field);
|
|
127
|
+
}
|
|
128
|
+
fixedSchema.required = [...existing];
|
|
129
|
+
}
|
|
130
|
+
delete fixedSchema[combiner];
|
|
131
|
+
}
|
|
101
132
|
}
|
|
102
133
|
}
|
|
103
134
|
return fixedSchema;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.28",
|
|
4
4
|
"mcpName": "io.github.zereight/gitlab-mcp",
|
|
5
5
|
"description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"author": "zereight",
|
|
27
27
|
"type": "module",
|
|
28
28
|
"bin": {
|
|
29
|
-
"mcp-gitlab": "build/index.js"
|
|
29
|
+
"mcp-gitlab": "build/index.js",
|
|
30
|
+
"zereight-mcp-gitlab": "build/index.js"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"build"
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
"changelog": "auto-changelog -p",
|
|
52
53
|
"test": "npm run test:all",
|
|
53
54
|
"test:all": "npm run build && npm run test:mock && npm run test:live",
|
|
54
|
-
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
|
|
55
|
+
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/dynamic-api-url-allowlist.test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/sse-auth-guard.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
|
|
55
56
|
"test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
|
|
56
57
|
"test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
|
|
57
58
|
"test:live": "node test/validate-api.js",
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
"format:check": "prettier --check \"**/*.{js,ts,json,md}\""
|
|
71
72
|
},
|
|
72
73
|
"dependencies": {
|
|
73
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
74
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
74
75
|
"@types/node-fetch": "^2.6.12",
|
|
75
76
|
"diff": "^9.0.0",
|
|
76
77
|
"express": "^5.1.0",
|