@zereight/mcp-gitlab 2.1.29 → 2.1.30
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 +71 -20
- package/README.md +44 -15
- package/README.zh-CN.md +71 -20
- package/build/config.js +13 -0
- package/build/downloads/proxy.js +199 -0
- package/build/index.js +485 -424
- package/build/schemas.js +30 -9
- package/build/scripts/check-skill-sync.js +137 -0
- package/build/scripts/generate-tool-docs.js +7 -2
- package/build/server/metrics.js +84 -0
- package/build/server/transport-mode.js +25 -0
- package/build/test/callback-proxy-tests.js +1 -1
- package/build/test/client-pool-test.js +1 -1
- package/build/test/dynamic-api-url-allowlist.test.js +2 -2
- package/build/test/dynamic-api-url-test.js +4 -4
- package/build/test/dynamic-routing-tests.js +4 -4
- package/build/test/mcp-oauth-tests.js +17 -4
- package/build/test/multi-server-test.js +3 -3
- package/build/test/no-proxy-integration-test.js +1 -1
- package/build/test/remote-auth-simple-test.js +226 -199
- package/build/test/server/metrics.test.js +47 -0
- package/build/test/sse-auth-guard.test.js +2 -2
- package/build/test/stateless/session-id-integration.test.js +3 -3
- package/build/test/streamable-http-concurrent-session.test.js +1 -1
- package/build/test/streamable-http-dns-rebinding.test.js +185 -0
- package/build/test/streamable-http-static-token-auth.test.js +25 -22
- package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
- package/build/test/test-ci-catalog.js +1 -1
- package/build/test/test-ci-lint.js +1 -1
- package/build/test/test-ci-variables.js +8 -5
- package/build/test/test-dependency-proxy.js +11 -7
- package/build/test/test-deployment-tools.js +16 -2
- package/build/test/test-download-attachment.js +1 -1
- package/build/test/test-get-file-blame.js +1 -1
- package/build/test/test-geteffectiveprojectid.js +7 -7
- package/build/test/test-issue-description-patch.js +1 -1
- package/build/test/test-job-artifacts.js +1 -1
- package/build/test/test-list-issues.js +1 -1
- package/build/test/test-list-merge-requests.js +1 -1
- package/build/test/test-list-project-members.js +1 -1
- package/build/test/test-merge-request-approval-state-tools.js +1 -1
- package/build/test/test-merge-request-pipelines.js +1 -1
- package/build/test/test-mr-diffs-filter.js +1 -1
- package/build/test/test-mr-file-diffs.js +2 -2
- package/build/test/test-oauth-proxy-rate-limit.js +1 -1
- package/build/test/test-permission-mode.js +215 -0
- package/build/test/test-protected-branches.js +1 -1
- package/build/test/test-remote-downloads.js +2 -2
- package/build/test/test-search-code.js +1 -1
- package/build/test/test-tags.js +1 -1
- package/build/test/test-todos.js +1 -1
- package/build/test/test-token-optimizations.js +3 -3
- package/build/test/test-toolset-filtering.js +1 -1
- package/build/test/test-update-issue-slim.js +141 -0
- package/build/test/test-upload-markdown.js +1 -1
- package/build/test/utils/download-token.test.js +35 -0
- package/build/test/utils/forwarded-public-base-url.test.js +9 -1
- package/build/test/utils/graphql-query.test.js +64 -1
- package/build/test/utils/mock-gitlab-server.js +24 -31
- package/build/test/utils/server-launcher.js +1 -2
- package/build/test/utils/version-check.test.js +52 -0
- package/build/tools/registry.js +30 -5
- package/build/utils/download-token.js +71 -0
- package/build/utils/forwarded-public-base-url.js +22 -0
- package/build/utils/graphql-query.js +109 -12
- package/build/utils/version-check.js +40 -0
- package/package.json +5 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { decryptDownloadToken } from "../utils/download-token.js";
|
|
2
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
3
|
+
function canonicalizeQueryParams(params) {
|
|
4
|
+
const sortedKeys = Object.keys(params).sort();
|
|
5
|
+
const normalized = {};
|
|
6
|
+
for (const key of sortedKeys) {
|
|
7
|
+
normalized[key] = params[key];
|
|
8
|
+
}
|
|
9
|
+
return JSON.stringify(normalized);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Register the /downloads/:type proxy endpoint on an Express app.
|
|
13
|
+
* Streams GitLab API responses directly to the client. Auth is read from
|
|
14
|
+
* an encrypted `_token` query param (self-contained URL) or from request headers.
|
|
15
|
+
*/
|
|
16
|
+
export function registerDownloadProxy(app, deps) {
|
|
17
|
+
const downloadRateLimits = {};
|
|
18
|
+
let lastEviction = Date.now();
|
|
19
|
+
const downloadTimeoutMs = deps.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
|
20
|
+
const checkDownloadRateLimit = (token) => {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
// Evict expired entries every 60s to prevent unbounded growth
|
|
23
|
+
if (now - lastEviction > 60000) {
|
|
24
|
+
for (const key of Object.keys(downloadRateLimits)) {
|
|
25
|
+
if (now > downloadRateLimits[key].resetAt)
|
|
26
|
+
delete downloadRateLimits[key];
|
|
27
|
+
}
|
|
28
|
+
lastEviction = now;
|
|
29
|
+
}
|
|
30
|
+
const entry = downloadRateLimits[token];
|
|
31
|
+
if (!entry || now > entry.resetAt) {
|
|
32
|
+
downloadRateLimits[token] = { count: 1, resetAt: now + 60000 };
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (entry.count >= deps.maxRequestsPerMinute)
|
|
36
|
+
return false;
|
|
37
|
+
entry.count++;
|
|
38
|
+
return true;
|
|
39
|
+
};
|
|
40
|
+
app.get("/downloads/:type", async (req, res) => {
|
|
41
|
+
const headers = { Accept: "application/octet-stream" };
|
|
42
|
+
let rateLimitKey;
|
|
43
|
+
// Try embedded encrypted token first (self-contained URL), then headers
|
|
44
|
+
const encryptedToken = req.query._token;
|
|
45
|
+
let tokenApiUrl;
|
|
46
|
+
if (encryptedToken) {
|
|
47
|
+
const decrypted = decryptDownloadToken(encryptedToken);
|
|
48
|
+
if (!decrypted) {
|
|
49
|
+
res.status(401).json({ error: "Invalid or expired download token" });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Verify resource binding — token must match the requested type and params
|
|
53
|
+
if (decrypted.resourceType || decrypted.resourceParams) {
|
|
54
|
+
const { type } = req.params;
|
|
55
|
+
const queryParams = {};
|
|
56
|
+
for (const [k, v] of Object.entries(req.query)) {
|
|
57
|
+
if (k !== "_token" && typeof v === "string")
|
|
58
|
+
queryParams[k] = v;
|
|
59
|
+
}
|
|
60
|
+
if (decrypted.resourceType !== type ||
|
|
61
|
+
canonicalizeQueryParams(decrypted.resourceParams ?? {}) !==
|
|
62
|
+
canonicalizeQueryParams(queryParams)) {
|
|
63
|
+
res.status(403).json({ error: "Download token does not match the requested resource" });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
headers[decrypted.header] = decrypted.token;
|
|
68
|
+
rateLimitKey = decrypted.token;
|
|
69
|
+
tokenApiUrl = decrypted.apiUrl;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const privateToken = req.headers["private-token"];
|
|
73
|
+
const jobToken = req.headers["job-token"];
|
|
74
|
+
const authHeader = req.headers["authorization"];
|
|
75
|
+
if (privateToken) {
|
|
76
|
+
headers["Private-Token"] = privateToken;
|
|
77
|
+
rateLimitKey = privateToken;
|
|
78
|
+
}
|
|
79
|
+
else if (jobToken) {
|
|
80
|
+
headers["JOB-TOKEN"] = jobToken;
|
|
81
|
+
rateLimitKey = jobToken;
|
|
82
|
+
}
|
|
83
|
+
else if (authHeader) {
|
|
84
|
+
headers["Authorization"] = authHeader;
|
|
85
|
+
rateLimitKey = authHeader;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
res.status(401).json({ error: "Authentication required" });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!checkDownloadRateLimit(rateLimitKey)) {
|
|
93
|
+
res.status(429).json({ error: "Rate limit exceeded" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// API URL: prefer token-embedded URL, then X-GitLab-API-URL header, then default
|
|
97
|
+
let apiUrl = deps.defaultApiUrl;
|
|
98
|
+
const requestedApiUrl = tokenApiUrl || req.headers["x-gitlab-api-url"]?.trim();
|
|
99
|
+
if (deps.enableDynamicApiUrl && requestedApiUrl) {
|
|
100
|
+
try {
|
|
101
|
+
apiUrl = deps.resolveTrustedGitLabApiUrl(requestedApiUrl);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const { type } = req.params;
|
|
109
|
+
let gitlabUrl;
|
|
110
|
+
try {
|
|
111
|
+
switch (type) {
|
|
112
|
+
case "job-artifacts": {
|
|
113
|
+
const { project_id, job_id } = req.query;
|
|
114
|
+
if (!project_id || !job_id) {
|
|
115
|
+
res.status(400).json({ error: "project_id and job_id are required" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
|
|
119
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${deps.encodeGitLabPathSegment(job_id)}/artifacts`;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case "attachment": {
|
|
123
|
+
const { project_id, secret, filename } = req.query;
|
|
124
|
+
if (!project_id || !secret || !filename) {
|
|
125
|
+
res.status(400).json({ error: "project_id, secret, and filename are required" });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
|
|
129
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${deps.encodeGitLabPathSegment(secret)}/${deps.encodeGitLabPath(filename)}`;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "release-asset": {
|
|
133
|
+
const { project_id, tag_name, direct_asset_path } = req.query;
|
|
134
|
+
if (!project_id || !tag_name || !direct_asset_path) {
|
|
135
|
+
res
|
|
136
|
+
.status(400)
|
|
137
|
+
.json({ error: "project_id, tag_name, and direct_asset_path are required" });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
|
|
141
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${deps.encodeGitLabPath(direct_asset_path)}`;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
default:
|
|
145
|
+
res.status(400).json({ error: `Unknown download type: ${type}` });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
// getEffectiveProjectId throws on access-denied
|
|
151
|
+
const message = e instanceof Error ? e.message : "Invalid parameters";
|
|
152
|
+
res.status(403).json({ error: message });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
|
|
157
|
+
try {
|
|
158
|
+
const agent = deps.getAgentFunctionForUrl(apiUrl);
|
|
159
|
+
const gitlabResponse = await deps.fetch(gitlabUrl, {
|
|
160
|
+
headers,
|
|
161
|
+
agent,
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
});
|
|
164
|
+
if (!gitlabResponse.ok) {
|
|
165
|
+
res.status(gitlabResponse.status).json({
|
|
166
|
+
error: `GitLab API error: ${gitlabResponse.status} ${gitlabResponse.statusText}`,
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const contentType = gitlabResponse.headers.get("content-type");
|
|
171
|
+
const contentDisposition = gitlabResponse.headers.get("content-disposition");
|
|
172
|
+
const contentLength = gitlabResponse.headers.get("content-length");
|
|
173
|
+
if (contentType)
|
|
174
|
+
res.setHeader("Content-Type", contentType);
|
|
175
|
+
if (contentDisposition)
|
|
176
|
+
res.setHeader("Content-Disposition", contentDisposition);
|
|
177
|
+
if (contentLength)
|
|
178
|
+
res.setHeader("Content-Length", contentLength);
|
|
179
|
+
if (gitlabResponse.body) {
|
|
180
|
+
gitlabResponse.body.pipe(res);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
res.status(502).json({ error: "No response body from GitLab" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
deps.logger.error({ err: error }, "Download proxy error");
|
|
188
|
+
if (!res.headersSent) {
|
|
189
|
+
const message = error instanceof Error && error.name === "AbortError"
|
|
190
|
+
? "GitLab download timed out"
|
|
191
|
+
: "Failed to proxy download from GitLab";
|
|
192
|
+
res.status(502).json({ error: message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
clearTimeout(timeout);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|