@zereight/mcp-gitlab 2.1.56 → 2.1.57
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 +4 -4
- package/README.md +31 -28
- package/README.zh-CN.md +4 -4
- package/build/auth-retry.js +37 -20
- package/build/downloads/proxy.js +6 -6
- package/build/gitlab-client-pool.js +148 -112
- package/build/index.js +81 -16
- package/build/schemas.js +118 -8
- package/build/scripts/generate-tool-docs.js +1 -1
- package/build/test/no-proxy-test.js +11 -0
- package/build/test/remote-auth-tests.js +0 -1
- package/build/test/test-auth-retry.js +39 -32
- package/build/test/test-permission-mode.js +1 -0
- package/build/test/test-toolset-filtering.js +2 -2
- package/build/test/test-webhooks.js +218 -0
- package/build/test-note.js +0 -1
- package/build/test-resolve-issue-note.js +0 -1
- package/build/tools/registry.js +21 -1
- package/package.json +3 -7
package/README.ko.md
CHANGED
|
@@ -17,7 +17,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
|
|
|
17
17
|
|
|
18
18
|
### 왜 이 GitLab MCP를 사용하나요?
|
|
19
19
|
|
|
20
|
-
- **
|
|
20
|
+
- **232개 도구 + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화
|
|
21
21
|
- **MR 2단계 리뷰** — `list_merge_request_changed_files` → 배치 `get_merge_request_file_diff`
|
|
22
22
|
- **Agent Skill 내장** — `skills/gitlab-mcp/` 워크플로우 가이드
|
|
23
23
|
- **유연한 인증** — Personal Access Token, 로컬 OAuth2 브라우저 플로우, MCP OAuth 프록시, 요청별 원격 인증
|
|
@@ -30,9 +30,9 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
|
|
|
30
30
|
| | @zereight/mcp-gitlab | GitLab MCP A (커뮤니티 CQRS형) |
|
|
31
31
|
|---|----------------------|--------------------------------|
|
|
32
32
|
| **적합한 경우** | AI 에이전트 워크플로우 | 엔터프라이즈 멀티 인스턴스 / 그룹형 도구 |
|
|
33
|
-
| **도구 모델** | ~
|
|
33
|
+
| **도구 모델** | ~232개 세분화 도구 + `discover_tools` | ~50–60개 `browse_*` / `manage_*` 그룹 도구 |
|
|
34
34
|
| **MR 리뷰** | 2단계 배치 diff | 서버마다 다름 |
|
|
35
|
-
| **Node.js** | >=18 | 보통 >=24 |
|
|
35
|
+
| **Node.js** | >=18.17 | 보통 >=24 |
|
|
36
36
|
| **라이선스** | MIT | 서버마다 다름 |
|
|
37
37
|
|
|
38
38
|
[전체 비교 →](./docs/comparison/community-gitlab-mcp-a.md)
|
|
@@ -98,7 +98,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
98
98
|
|
|
99
99
|
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
100
100
|
|
|
101
|
-
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.
|
|
101
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.56`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
102
102
|
|
|
103
103
|
#### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
|
|
104
104
|
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization
|
|
|
22
22
|
|
|
23
23
|
### Why use this GitLab MCP?
|
|
24
24
|
|
|
25
|
-
- **
|
|
25
|
+
- **232 tools + `discover_tools`** — start with a small toolset; activate more at runtime without CQRS-style grouping
|
|
26
26
|
- **MR 2-step review** — `list_merge_request_changed_files` → batched `get_merge_request_file_diff`
|
|
27
27
|
- **Agent Skill built in** — workflow guidance in `skills/gitlab-mcp/`
|
|
28
28
|
- **Flexible auth** — Personal Access Token, local OAuth2 browser flow, MCP OAuth proxy, and per-request remote authorization
|
|
@@ -35,9 +35,9 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization
|
|
|
35
35
|
| | @zereight/mcp-gitlab | GitLab MCP A (community CQRS-style) |
|
|
36
36
|
|---|----------------------|-------------------------------------|
|
|
37
37
|
| **Best for** | AI agent workflows | Enterprise multi-instance / grouped tools |
|
|
38
|
-
| **Tool model** | ~
|
|
38
|
+
| **Tool model** | ~232 granular tools + `discover_tools` | ~50–60 grouped `browse_*` / `manage_*` tools |
|
|
39
39
|
| **MR review** | 2-step batched diff | Varies |
|
|
40
|
-
| **Node.js** | >=18 | Often >=24 |
|
|
40
|
+
| **Node.js** | >=18.17 | Often >=24 |
|
|
41
41
|
| **License** | MIT | Varies |
|
|
42
42
|
|
|
43
43
|
[Full comparison →](./docs/comparison/community-gitlab-mcp-a.md)
|
|
@@ -103,7 +103,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
103
103
|
|
|
104
104
|
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`.
|
|
105
105
|
|
|
106
|
-
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.
|
|
106
|
+
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.56`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
107
107
|
|
|
108
108
|
#### Using CLI Arguments (for clients with env var issues)
|
|
109
109
|
|
|
@@ -777,30 +777,33 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
|
|
|
777
777
|
204. `get_timeline_events` - List timeline events for an incident. Returns chronological events with notes, timestamps, and tags
|
|
778
778
|
205. `create_timeline_event` - Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'
|
|
779
779
|
206. `list_webhooks` - List all configured webhooks for a GitLab project or group. Provide either project_id or group_id
|
|
780
|
-
207. `
|
|
781
|
-
208. `
|
|
782
|
-
209. `
|
|
783
|
-
210. `
|
|
784
|
-
211. `
|
|
785
|
-
212. `
|
|
786
|
-
213. `
|
|
787
|
-
214. `
|
|
788
|
-
215. `
|
|
789
|
-
216. `
|
|
790
|
-
217. `
|
|
791
|
-
218. `
|
|
792
|
-
219. `
|
|
793
|
-
220. `
|
|
794
|
-
221. `
|
|
795
|
-
222. `
|
|
796
|
-
223. `
|
|
797
|
-
224. `
|
|
798
|
-
225. `
|
|
799
|
-
226. `
|
|
800
|
-
227. `
|
|
801
|
-
228. `
|
|
802
|
-
229. `
|
|
803
|
-
230. `
|
|
780
|
+
207. `create_webhook` - Create a webhook on a GitLab project or group
|
|
781
|
+
208. `update_webhook` - Update an existing project or group webhook
|
|
782
|
+
209. `delete_webhook` - Delete a project or group webhook
|
|
783
|
+
210. `list_webhook_events` - List recent webhook events (past 7 days) for a project or group webhook. Use summary mode for overview, then get_webhook_event for full details
|
|
784
|
+
211. `get_webhook_event` - Get full details of a specific webhook event by ID, including request/response payloads
|
|
785
|
+
212. `search_code` - Search for code across all projects on the GitLab instance (requires advanced search or exact code search to be enabled)
|
|
786
|
+
213. `search_project_code` - Search for code within a specific GitLab project (requires advanced search or exact code search to be enabled)
|
|
787
|
+
214. `search_group_code` - Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled)
|
|
788
|
+
215. `list_project_variables` - List CI/CD variables for a project with optional environment scope filter
|
|
789
|
+
216. `get_project_variable` - Get a single CI/CD variable from a project by key, with optional environment scope filter
|
|
790
|
+
217. `create_project_variable` - Create a new CI/CD variable in a project
|
|
791
|
+
218. `update_project_variable` - Update an existing CI/CD variable in a project, with optional filter to disambiguate by environment scope
|
|
792
|
+
219. `delete_project_variable` - Delete a CI/CD variable from a project, with optional filter to disambiguate by environment scope
|
|
793
|
+
220. `list_group_variables` - List CI/CD variables for a group with optional environment scope filter
|
|
794
|
+
221. `get_group_variable` - Get a single CI/CD variable from a group by key, with optional environment scope filter
|
|
795
|
+
222. `create_group_variable` - Create a new CI/CD variable in a group
|
|
796
|
+
223. `update_group_variable` - Update an existing CI/CD variable in a group, with optional filter to disambiguate by environment scope
|
|
797
|
+
224. `delete_group_variable` - Delete a CI/CD variable from a group, with optional filter to disambiguate by environment scope
|
|
798
|
+
225. `get_dependency_proxy_settings` - Get dependency proxy settings for a group (enabled status, blob count, total size, image prefix, TTL policy)
|
|
799
|
+
226. `update_dependency_proxy_settings` - Update dependency proxy settings for a group (enable/disable, credentials for authenticated Docker Hub pulls)
|
|
800
|
+
227. `list_dependency_proxy_blobs` - List cached dependency proxy blobs for a group with cursor-based pagination
|
|
801
|
+
228. `purge_dependency_proxy_cache` - Schedule purge of all cached dependency proxy blobs for a group
|
|
802
|
+
229. `list_project_vulnerabilities` - List vulnerabilities for a project with optional state, severity, and report type filters (GraphQL-backed, cursor pagination)
|
|
803
|
+
230. `get_vulnerability` - Get full details of a specific vulnerability
|
|
804
|
+
231. `dismiss_vulnerability` - Dismiss a vulnerability with a reason (acceptable_risk, false_positive, used_in_tests, mitigating_control, not_applicable) and optional comment
|
|
805
|
+
232. `confirm_vulnerability` - Confirm a vulnerability as a real finding requiring remediation
|
|
806
|
+
233. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities. Already-active categories are listed in the response.
|
|
804
807
|
|
|
805
808
|
<!-- TOOLS-END -->
|
|
806
809
|
|
package/README.zh-CN.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
### 为什么使用这个 GitLab MCP?
|
|
19
19
|
|
|
20
|
-
- **
|
|
20
|
+
- **232 个工具 + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别
|
|
21
21
|
- **MR 两步审查** — `list_merge_request_changed_files` → 批量 `get_merge_request_file_diff`
|
|
22
22
|
- **内置 Agent Skill** — `skills/gitlab-mcp/` 工作流指南
|
|
23
23
|
- **认证灵活** — Personal Access Token、本地 OAuth2 浏览器流程、MCP OAuth 代理、按请求远程授权
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
| | @zereight/mcp-gitlab | GitLab MCP A(社区 CQRS 型) |
|
|
31
31
|
|---|----------------------|------------------------------|
|
|
32
32
|
| **更适合** | AI 代理工作流 | 企业多实例 / 分组工具 |
|
|
33
|
-
| **工具模型** | ~
|
|
33
|
+
| **工具模型** | ~232 个细粒度工具 + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 |
|
|
34
34
|
| **MR 审查** | 两步批量 diff | 因服务器而异 |
|
|
35
|
-
| **Node.js** | >=18 | 通常 >=24 |
|
|
35
|
+
| **Node.js** | >=18.17 | 通常 >=24 |
|
|
36
36
|
| **许可证** | MIT | 因服务器而异 |
|
|
37
37
|
|
|
38
38
|
[完整对比 →](./docs/comparison/community-gitlab-mcp-a.md)
|
|
@@ -98,7 +98,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
98
98
|
|
|
99
99
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
100
100
|
|
|
101
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.
|
|
101
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.56`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
102
102
|
|
|
103
103
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
104
104
|
|
package/build/auth-retry.js
CHANGED
|
@@ -5,34 +5,55 @@
|
|
|
5
5
|
* importing index.ts (which has heavy side effects: starts the MCP server,
|
|
6
6
|
* reads env vars, etc.).
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
function isTupleArray(headers) {
|
|
9
|
+
if (!Array.isArray(headers))
|
|
10
|
+
return false;
|
|
11
|
+
return headers.every(entry => Array.isArray(entry) &&
|
|
12
|
+
entry.length >= 2 &&
|
|
13
|
+
typeof entry[0] === "string" &&
|
|
14
|
+
typeof entry[1] === "string");
|
|
15
|
+
}
|
|
16
|
+
function hasForEach(headers) {
|
|
17
|
+
return "forEach" in headers && typeof headers.forEach === "function";
|
|
18
|
+
}
|
|
19
|
+
function isPlainStringHeaders(headers) {
|
|
20
|
+
return Object.values(headers).every(value => typeof value === "string");
|
|
21
|
+
}
|
|
9
22
|
/**
|
|
10
23
|
* Convert various header representations to a plain Record<string, string>.
|
|
11
24
|
*/
|
|
12
25
|
export function headersToPlainObject(headers) {
|
|
13
|
-
if (!headers)
|
|
26
|
+
if (!headers || typeof headers !== "object")
|
|
14
27
|
return {};
|
|
15
|
-
if (headers
|
|
28
|
+
if (isTupleArray(headers)) {
|
|
29
|
+
return Object.fromEntries(headers);
|
|
30
|
+
}
|
|
31
|
+
if (hasForEach(headers)) {
|
|
16
32
|
const obj = {};
|
|
17
|
-
headers.forEach((value, key) => {
|
|
33
|
+
headers.forEach((value, key) => {
|
|
34
|
+
obj[key] = value;
|
|
35
|
+
});
|
|
18
36
|
return obj;
|
|
19
37
|
}
|
|
20
|
-
if (
|
|
21
|
-
return
|
|
38
|
+
if (isPlainStringHeaders(headers)) {
|
|
39
|
+
return headers;
|
|
22
40
|
}
|
|
23
|
-
return
|
|
41
|
+
return {};
|
|
24
42
|
}
|
|
25
43
|
/**
|
|
26
|
-
* Detect request bodies that cannot be replayed (streams
|
|
44
|
+
* Detect request bodies that cannot be replayed (streams).
|
|
27
45
|
*/
|
|
46
|
+
function hasCallableMethod(body, name) {
|
|
47
|
+
return name in body && typeof Reflect.get(body, name) === "function";
|
|
48
|
+
}
|
|
28
49
|
export function isNonReplayableBody(body) {
|
|
29
|
-
if (!body)
|
|
50
|
+
if (!body || typeof body !== "object")
|
|
30
51
|
return false;
|
|
31
|
-
|
|
32
|
-
if (typeof body.pipe === "function" || typeof body.read === "function")
|
|
52
|
+
if (hasCallableMethod(body, "pipe"))
|
|
33
53
|
return true;
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
if (hasCallableMethod(body, "read"))
|
|
55
|
+
return true;
|
|
56
|
+
if (hasCallableMethod(body, "getReader"))
|
|
36
57
|
return true;
|
|
37
58
|
return false;
|
|
38
59
|
}
|
|
@@ -41,24 +62,19 @@ export function isNonReplayableBody(body) {
|
|
|
41
62
|
* On a 401, force-refreshes the OAuth token and retries the request once.
|
|
42
63
|
* The retry calls baseFetch directly (not the wrapper), so infinite loops are impossible.
|
|
43
64
|
* In non-OAuth mode, the wrapper is a transparent pass-through.
|
|
44
|
-
*
|
|
45
|
-
* When called without `config`, falls back to module globals in index.ts.
|
|
46
|
-
* When called with `config` (tests), uses injected dependencies.
|
|
47
65
|
*/
|
|
48
66
|
export function wrapWithAuthRetry(baseFetch, config) {
|
|
49
67
|
let refreshLock = null;
|
|
50
68
|
const log = config.logger ?? { info: () => { }, error: () => { } };
|
|
51
|
-
|
|
69
|
+
const wrapped = async (url, options) => {
|
|
52
70
|
const response = await baseFetch(url, options);
|
|
53
71
|
if (response.status === 401 && config.isOAuthEnabled()) {
|
|
54
|
-
// Skip retry for non-replayable bodies (streams, FormData) since the first request consumed them
|
|
55
72
|
if (isNonReplayableBody(options?.body)) {
|
|
56
73
|
log.info("Received 401 but request body is not replayable (stream/FormData), skipping retry.");
|
|
57
74
|
return response;
|
|
58
75
|
}
|
|
59
76
|
log.info("Received 401, force-refreshing OAuth token and retrying...");
|
|
60
77
|
try {
|
|
61
|
-
// Mutex: coalesce concurrent refresh attempts into a single in-flight request
|
|
62
78
|
if (!refreshLock) {
|
|
63
79
|
refreshLock = config.refreshToken(true).finally(() => {
|
|
64
80
|
refreshLock = null;
|
|
@@ -77,5 +93,6 @@ export function wrapWithAuthRetry(baseFetch, config) {
|
|
|
77
93
|
}
|
|
78
94
|
}
|
|
79
95
|
return response;
|
|
80
|
-
}
|
|
96
|
+
};
|
|
97
|
+
return wrapped;
|
|
81
98
|
}
|
package/build/downloads/proxy.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
1
3
|
import { decryptDownloadToken } from "../utils/download-token.js";
|
|
2
4
|
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
3
5
|
function canonicalizeQueryParams(params) {
|
|
@@ -156,10 +158,9 @@ export function registerDownloadProxy(app, deps) {
|
|
|
156
158
|
const controller = new AbortController();
|
|
157
159
|
const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
|
|
158
160
|
try {
|
|
159
|
-
const agent = deps.getAgentFunctionForUrl(apiUrl);
|
|
160
161
|
const gitlabResponse = await deps.fetch(gitlabUrl, {
|
|
161
162
|
headers,
|
|
162
|
-
|
|
163
|
+
dispatcher: deps.getDispatcherForUrl(apiUrl),
|
|
163
164
|
signal: controller.signal,
|
|
164
165
|
});
|
|
165
166
|
if (!gitlabResponse.ok) {
|
|
@@ -177,12 +178,11 @@ export function registerDownloadProxy(app, deps) {
|
|
|
177
178
|
res.setHeader("Content-Disposition", contentDisposition);
|
|
178
179
|
if (contentLength)
|
|
179
180
|
res.setHeader("Content-Length", contentLength);
|
|
180
|
-
if (gitlabResponse.body) {
|
|
181
|
-
gitlabResponse.body.pipe(res);
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
181
|
+
if (!gitlabResponse.body) {
|
|
184
182
|
res.status(502).json({ error: "No response body from GitLab" });
|
|
183
|
+
return;
|
|
185
184
|
}
|
|
185
|
+
await pipeline(Readable.fromWeb(gitlabResponse.body), res);
|
|
186
186
|
}
|
|
187
187
|
catch (error) {
|
|
188
188
|
deps.logger.error({ err: error }, "Download proxy error");
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import { HttpProxyAgent } from "http-proxy-agent";
|
|
4
|
-
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
5
3
|
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
6
|
-
import
|
|
4
|
+
import { Agent, Dispatcher, ProxyAgent } from "undici";
|
|
7
5
|
/**
|
|
8
6
|
* Checks if a URL should bypass the proxy based on NO_PROXY patterns.
|
|
9
7
|
* Supports:
|
|
@@ -21,7 +19,6 @@ function shouldBypassProxy(url, noProxy) {
|
|
|
21
19
|
if (!noProxy) {
|
|
22
20
|
return false;
|
|
23
21
|
}
|
|
24
|
-
// Parse URL to get hostname and port
|
|
25
22
|
let hostname;
|
|
26
23
|
let port;
|
|
27
24
|
let protocol;
|
|
@@ -29,185 +26,224 @@ function shouldBypassProxy(url, noProxy) {
|
|
|
29
26
|
const parsedUrl = new URL(url);
|
|
30
27
|
hostname = parsedUrl.hostname.toLowerCase();
|
|
31
28
|
protocol = parsedUrl.protocol;
|
|
32
|
-
|
|
33
|
-
port = parsedUrl.port || (protocol === 'https:' ? '443' : '80');
|
|
29
|
+
port = parsedUrl.port || (protocol === "https:" ? "443" : "80");
|
|
34
30
|
}
|
|
35
31
|
catch {
|
|
36
32
|
return false;
|
|
37
33
|
}
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
const patterns = noProxy
|
|
35
|
+
.split(",")
|
|
36
|
+
.map(p => p.trim().toLowerCase())
|
|
37
|
+
.filter(p => p.length > 0);
|
|
40
38
|
for (const pattern of patterns) {
|
|
41
|
-
|
|
42
|
-
if (pattern === '*') {
|
|
39
|
+
if (pattern === "*") {
|
|
43
40
|
return true;
|
|
44
41
|
}
|
|
45
|
-
|
|
46
|
-
const [patternHost, patternPort] = pattern.split(':');
|
|
47
|
-
// If pattern specifies a port, check if it matches
|
|
42
|
+
const [patternHost, patternPort] = pattern.split(":");
|
|
48
43
|
if (patternPort && port !== patternPort) {
|
|
49
44
|
continue;
|
|
50
45
|
}
|
|
51
|
-
|
|
52
|
-
if (patternHost.startsWith('.')) {
|
|
46
|
+
if (patternHost.startsWith(".")) {
|
|
53
47
|
const suffix = patternHost.substring(1);
|
|
54
|
-
if (hostname === suffix || hostname.endsWith(
|
|
48
|
+
if (hostname === suffix || hostname.endsWith("." + suffix)) {
|
|
55
49
|
return true;
|
|
56
50
|
}
|
|
57
51
|
}
|
|
58
|
-
// Check for exact hostname match
|
|
59
52
|
else if (hostname === patternHost) {
|
|
60
53
|
return true;
|
|
61
54
|
}
|
|
62
55
|
}
|
|
63
56
|
return false;
|
|
64
57
|
}
|
|
58
|
+
class ProtocolDispatcher extends Dispatcher {
|
|
59
|
+
httpDirect;
|
|
60
|
+
httpsDirect;
|
|
61
|
+
httpProxyDispatcher;
|
|
62
|
+
httpsProxyDispatcher;
|
|
63
|
+
noProxy;
|
|
64
|
+
constructor(httpDirect, httpsDirect, httpProxyDispatcher, httpsProxyDispatcher, noProxy) {
|
|
65
|
+
super();
|
|
66
|
+
this.httpDirect = httpDirect;
|
|
67
|
+
this.httpsDirect = httpsDirect;
|
|
68
|
+
this.httpProxyDispatcher = httpProxyDispatcher;
|
|
69
|
+
this.httpsProxyDispatcher = httpsProxyDispatcher;
|
|
70
|
+
this.noProxy = noProxy;
|
|
71
|
+
}
|
|
72
|
+
dispatcherForOrigin(originText) {
|
|
73
|
+
const useHttps = originText.startsWith("https:");
|
|
74
|
+
const bypass = shouldBypassProxy(originText, this.noProxy);
|
|
75
|
+
if (bypass) {
|
|
76
|
+
return useHttps ? this.httpsDirect : this.httpDirect;
|
|
77
|
+
}
|
|
78
|
+
return useHttps ? this.httpsProxyDispatcher : this.httpProxyDispatcher;
|
|
79
|
+
}
|
|
80
|
+
dispatch(options, handler) {
|
|
81
|
+
const origin = options.origin;
|
|
82
|
+
const originText = origin instanceof URL ? origin.href : String(origin ?? "");
|
|
83
|
+
return this.dispatcherForOrigin(originText).dispatch(options, handler);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function createSocksConnect(proxyUrl, tls) {
|
|
87
|
+
const socksAgent = new SocksProxyAgent(proxyUrl);
|
|
88
|
+
return (options, callback) => {
|
|
89
|
+
const port = Number(options.port);
|
|
90
|
+
const host = options.hostname;
|
|
91
|
+
const dummyReq = http.request({
|
|
92
|
+
hostname: "127.0.0.1",
|
|
93
|
+
port: 9,
|
|
94
|
+
path: "/",
|
|
95
|
+
method: "HEAD",
|
|
96
|
+
agent: false,
|
|
97
|
+
});
|
|
98
|
+
dummyReq.on("error", () => undefined);
|
|
99
|
+
dummyReq.destroy();
|
|
100
|
+
const connectThroughSocks = (connectOpts) => {
|
|
101
|
+
void socksAgent
|
|
102
|
+
.connect(dummyReq, connectOpts)
|
|
103
|
+
.then(socket => {
|
|
104
|
+
callback(null, socket);
|
|
105
|
+
})
|
|
106
|
+
.catch((error) => {
|
|
107
|
+
const err = error instanceof Error ? error : new Error("SOCKS connect failed");
|
|
108
|
+
callback(err, null);
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
if (options.protocol === "https:") {
|
|
112
|
+
connectThroughSocks({
|
|
113
|
+
secureEndpoint: true,
|
|
114
|
+
host,
|
|
115
|
+
port,
|
|
116
|
+
servername: options.servername,
|
|
117
|
+
rejectUnauthorized: tls.rejectUnauthorized,
|
|
118
|
+
ca: tls.ca,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
connectThroughSocks({
|
|
123
|
+
secureEndpoint: false,
|
|
124
|
+
host,
|
|
125
|
+
port,
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function createDispatcher(proxyUrl, tls) {
|
|
130
|
+
const hasTls = tls.rejectUnauthorized === false || tls.ca !== undefined;
|
|
131
|
+
if (proxyUrl?.startsWith("socks")) {
|
|
132
|
+
return new Agent({ connect: createSocksConnect(proxyUrl, tls) });
|
|
133
|
+
}
|
|
134
|
+
if (proxyUrl) {
|
|
135
|
+
if (hasTls) {
|
|
136
|
+
return new ProxyAgent({ uri: proxyUrl, requestTls: tls });
|
|
137
|
+
}
|
|
138
|
+
return new ProxyAgent(proxyUrl);
|
|
139
|
+
}
|
|
140
|
+
if (hasTls) {
|
|
141
|
+
return new Agent({ connect: tls });
|
|
142
|
+
}
|
|
143
|
+
return new Agent();
|
|
144
|
+
}
|
|
65
145
|
/**
|
|
66
|
-
* Manages a pool of
|
|
146
|
+
* Manages a pool of undici dispatchers for different GitLab API URLs.
|
|
67
147
|
* This allows the server to efficiently handle requests to multiple GitLab instances
|
|
68
|
-
* by reusing
|
|
148
|
+
* by reusing dispatchers and their underlying TCP connections.
|
|
69
149
|
*/
|
|
70
150
|
export class GitLabClientPool {
|
|
71
151
|
clients = new Map();
|
|
72
152
|
options;
|
|
73
153
|
constructor(options) {
|
|
74
154
|
this.options = options;
|
|
75
|
-
// Initialization is now done on-demand
|
|
76
155
|
}
|
|
77
|
-
|
|
78
|
-
* Creates a pair of HTTP and HTTPS agents for a specific API URL,
|
|
79
|
-
* considering proxy and SSL/TLS settings.
|
|
80
|
-
* @param apiUrl The base URL for which to create the agents.
|
|
81
|
-
* @returns A `ClientAgents` object containing the configured agents.
|
|
82
|
-
*/
|
|
83
|
-
createAgentsForUrl(apiUrl) {
|
|
156
|
+
createDispatchersForUrl(apiUrl) {
|
|
84
157
|
const { httpProxy, httpsProxy, noProxy, rejectUnauthorized, caCertPath } = this.options;
|
|
85
|
-
|
|
158
|
+
const tls = {};
|
|
86
159
|
if (rejectUnauthorized === false) {
|
|
87
|
-
|
|
160
|
+
tls.rejectUnauthorized = false;
|
|
88
161
|
}
|
|
89
162
|
else if (caCertPath) {
|
|
90
163
|
try {
|
|
91
|
-
|
|
164
|
+
tls.ca = fs.readFileSync(caCertPath);
|
|
92
165
|
}
|
|
93
166
|
catch (error) {
|
|
94
167
|
console.error(`Failed to read CA certificate from ${caCertPath}:`, error);
|
|
95
168
|
throw new Error(`Failed to read CA certificate: ${caCertPath}`);
|
|
96
169
|
}
|
|
97
170
|
}
|
|
98
|
-
|
|
171
|
+
const httpDirect = createDispatcher(undefined, tls);
|
|
172
|
+
const httpsDirect = createDispatcher(undefined, tls);
|
|
173
|
+
const httpProxyDispatcher = createDispatcher(httpProxy, tls);
|
|
174
|
+
const httpsProxyDispatcher = createDispatcher(httpsProxy, tls);
|
|
99
175
|
const bypassProxy = shouldBypassProxy(apiUrl, noProxy);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
? new SocksProxyAgent(httpProxy)
|
|
106
|
-
: new HttpProxyAgent(httpProxy);
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
httpAgent = new Agent({ keepAlive: true });
|
|
110
|
-
}
|
|
111
|
-
// Configure HTTPS agent with proxy and SSL options if specified and not bypassed
|
|
112
|
-
if (httpsProxy && !bypassProxy) {
|
|
113
|
-
httpsAgent = httpsProxy.startsWith("socks")
|
|
114
|
-
// The `as any` cast is used here to bypass a TypeScript type mismatch error.
|
|
115
|
-
// The `socks-proxy-agent` documentation indicates that TLS options like
|
|
116
|
-
// `rejectUnauthorized` and `ca` are valid in the constructor's options
|
|
117
|
-
// object, but the type definitions in this environment seem to disagree.
|
|
118
|
-
// This cast ensures the options are passed through at runtime.
|
|
119
|
-
? new SocksProxyAgent(httpsProxy, sslOptions)
|
|
120
|
-
: new HttpsProxyAgent(httpsProxy, { ...sslOptions });
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
httpsAgent = new HttpsAgent({ ...sslOptions, keepAlive: true });
|
|
124
|
-
}
|
|
125
|
-
return { httpAgent, httpsAgent };
|
|
176
|
+
return {
|
|
177
|
+
httpDispatcher: bypassProxy ? httpDirect : httpProxyDispatcher,
|
|
178
|
+
httpsDispatcher: bypassProxy ? httpsDirect : httpsProxyDispatcher,
|
|
179
|
+
dispatcher: new ProtocolDispatcher(httpDirect, httpsDirect, httpProxyDispatcher, httpsProxyDispatcher, noProxy),
|
|
180
|
+
};
|
|
126
181
|
}
|
|
127
182
|
/**
|
|
128
|
-
* Retrieves the
|
|
129
|
-
*
|
|
130
|
-
* @param apiUrl The full URL of the request.
|
|
131
|
-
* @returns The corresponding `Agent` for the URL's protocol.
|
|
183
|
+
* Retrieves the protocol-specific dispatcher for a given API URL.
|
|
184
|
+
* Used by NO_PROXY tests to distinguish Agent vs ProxyAgent.
|
|
132
185
|
*/
|
|
133
186
|
getOrCreateAgentForUrl(apiUrl) {
|
|
134
|
-
const
|
|
187
|
+
const dispatchers = this.getOrCreateDispatchersForUrl(apiUrl);
|
|
135
188
|
const url = new URL(apiUrl);
|
|
136
|
-
return url.protocol === "https:" ?
|
|
189
|
+
return url.protocol === "https:" ? dispatchers.httpsDispatcher : dispatchers.httpDispatcher;
|
|
137
190
|
}
|
|
138
191
|
/**
|
|
139
|
-
* Returns
|
|
140
|
-
*
|
|
141
|
-
* request URL's protocol. This is critical for self-hosted GitLab instances
|
|
142
|
-
* where the server may redirect between HTTP and HTTPS (e.g., when
|
|
143
|
-
* `external_url` differs from the actual internal protocol).
|
|
144
|
-
* @param apiUrl The base API URL used to look up or create the agent pair.
|
|
145
|
-
* @returns A function `(parsedURL: URL) => Agent` suitable for node-fetch.
|
|
192
|
+
* Returns a dispatcher that picks HTTP vs HTTPS based on the request origin.
|
|
193
|
+
* Needed when a self-hosted GitLab redirects between HTTP and HTTPS.
|
|
146
194
|
*/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return (parsedURL) => {
|
|
150
|
-
return parsedURL.protocol === "https:" ? agents.httpsAgent : agents.httpAgent;
|
|
151
|
-
};
|
|
195
|
+
getDispatcherForUrl(apiUrl) {
|
|
196
|
+
return this.getOrCreateDispatchersForUrl(apiUrl).dispatcher;
|
|
152
197
|
}
|
|
153
198
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* @returns The `ClientAgents` (both HTTP and HTTPS agents) for the URL.
|
|
199
|
+
* Which inner dispatcher would handle `originUrl` after following a redirect
|
|
200
|
+
* from `apiUrl`. Used to verify NO_PROXY is re-evaluated per origin.
|
|
157
201
|
*/
|
|
158
|
-
|
|
202
|
+
getDispatcherForOrigin(apiUrl, originUrl) {
|
|
203
|
+
const dispatcher = this.getOrCreateDispatchersForUrl(apiUrl).dispatcher;
|
|
204
|
+
if (dispatcher instanceof ProtocolDispatcher) {
|
|
205
|
+
return dispatcher.dispatcherForOrigin(originUrl);
|
|
206
|
+
}
|
|
207
|
+
return dispatcher;
|
|
208
|
+
}
|
|
209
|
+
getOrCreateDispatchersForUrl(apiUrl) {
|
|
159
210
|
const url = new URL(apiUrl);
|
|
160
|
-
const
|
|
211
|
+
const apiIndex = url.pathname.lastIndexOf("/api/v4");
|
|
212
|
+
const basePath = apiIndex === -1 ? url.pathname : url.pathname.substring(0, apiIndex + "/api/v4".length);
|
|
213
|
+
const baseUrl = `${url.protocol}//${url.host}${basePath}`;
|
|
161
214
|
if (!this.clients.has(baseUrl)) {
|
|
162
|
-
// Check pool size limit
|
|
163
215
|
if (this.options.poolMaxSize !== undefined && this.clients.size >= this.options.poolMaxSize) {
|
|
164
216
|
throw new Error(`Server capacity reached: Connection pool is full (max ${this.options.poolMaxSize} instances). Please try again later.`);
|
|
165
217
|
}
|
|
166
|
-
this.clients.set(baseUrl, this.
|
|
218
|
+
this.clients.set(baseUrl, this.createDispatchersForUrl(baseUrl));
|
|
167
219
|
}
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
170
|
-
// This should not happen given the logic above, but it satisfies TypeScript
|
|
220
|
+
const dispatchers = this.clients.get(baseUrl);
|
|
221
|
+
if (!dispatchers) {
|
|
171
222
|
throw new Error(`Failed to create or get client for URL: ${baseUrl}`);
|
|
172
223
|
}
|
|
173
|
-
return
|
|
224
|
+
return dispatchers;
|
|
174
225
|
}
|
|
175
|
-
/**
|
|
176
|
-
* Retrieves the client agents for a specific base API URL.
|
|
177
|
-
* @param apiUrl The base API URL (e.g., "https://gitlab.com/api/v4").
|
|
178
|
-
* @returns The `ClientAgents` object or undefined if not found.
|
|
179
|
-
*/
|
|
180
226
|
getClient(apiUrl) {
|
|
181
227
|
return this.clients.get(apiUrl);
|
|
182
228
|
}
|
|
183
|
-
/**
|
|
184
|
-
* Returns the default client agents, which corresponds to the first URL in the list.
|
|
185
|
-
* @returns The default `ClientAgents`.
|
|
186
|
-
*/
|
|
187
229
|
getDefaultClient() {
|
|
188
230
|
const defaultUrl = this.options.apiUrls?.[0];
|
|
189
231
|
if (!defaultUrl) {
|
|
190
232
|
throw new Error("No default API URL configured.");
|
|
191
233
|
}
|
|
192
234
|
if (!this.clients.has(defaultUrl)) {
|
|
193
|
-
this.clients.set(defaultUrl, this.
|
|
235
|
+
this.clients.set(defaultUrl, this.createDispatchersForUrl(defaultUrl));
|
|
194
236
|
}
|
|
195
|
-
|
|
237
|
+
const client = this.clients.get(defaultUrl);
|
|
238
|
+
if (!client) {
|
|
239
|
+
throw new Error("No default API URL configured.");
|
|
240
|
+
}
|
|
241
|
+
return client;
|
|
196
242
|
}
|
|
197
|
-
/**
|
|
198
|
-
* Destroy all pooled agents and clear pool state.
|
|
199
|
-
* This should be called on graceful shutdown so sockets are closed
|
|
200
|
-
* and the process can exit cleanly.
|
|
201
|
-
*/
|
|
202
243
|
closeAll() {
|
|
203
|
-
for (const [,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
agent.destroy();
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
destroyIfSupported(agents.httpAgent);
|
|
210
|
-
destroyIfSupported(agents.httpsAgent);
|
|
244
|
+
for (const [, dispatchers] of this.clients) {
|
|
245
|
+
void dispatchers.httpDispatcher.destroy();
|
|
246
|
+
void dispatchers.httpsDispatcher.destroy();
|
|
211
247
|
}
|
|
212
248
|
this.clients.clear();
|
|
213
249
|
}
|