@zereight/mcp-gitlab 2.1.28 → 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.
Files changed (72) hide show
  1. package/README.ko.md +82 -28
  2. package/README.md +56 -23
  3. package/README.zh-CN.md +82 -28
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +564 -482
  7. package/build/oauth-proxy.js +18 -4
  8. package/build/schemas.js +47 -9
  9. package/build/scripts/check-skill-sync.js +137 -0
  10. package/build/scripts/generate-tool-docs.js +7 -2
  11. package/build/server/metrics.js +84 -0
  12. package/build/server/transport-mode.js +25 -0
  13. package/build/test/callback-proxy-tests.js +1 -1
  14. package/build/test/client-pool-test.js +1 -1
  15. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  16. package/build/test/dynamic-api-url-test.js +4 -4
  17. package/build/test/dynamic-routing-tests.js +4 -4
  18. package/build/test/mcp-oauth-tests.js +17 -4
  19. package/build/test/multi-server-test.js +3 -3
  20. package/build/test/no-proxy-integration-test.js +1 -1
  21. package/build/test/path-segment-encoding.test.js +27 -0
  22. package/build/test/remote-auth-simple-test.js +226 -199
  23. package/build/test/server/metrics.test.js +47 -0
  24. package/build/test/sse-auth-guard.test.js +2 -2
  25. package/build/test/stateless/callback-proxy.test.js +8 -0
  26. package/build/test/stateless/session-id-integration.test.js +3 -3
  27. package/build/test/streamable-http-concurrent-session.test.js +1 -1
  28. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  29. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  30. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  31. package/build/test/test-ci-catalog.js +1 -1
  32. package/build/test/test-ci-lint.js +1 -1
  33. package/build/test/test-ci-variables.js +8 -5
  34. package/build/test/test-dependency-proxy.js +11 -7
  35. package/build/test/test-deployment-tools.js +16 -2
  36. package/build/test/test-download-attachment.js +1 -1
  37. package/build/test/test-get-file-blame.js +1 -1
  38. package/build/test/test-geteffectiveprojectid.js +7 -7
  39. package/build/test/test-issue-description-patch.js +1 -1
  40. package/build/test/test-job-artifacts.js +1 -1
  41. package/build/test/test-list-issues.js +1 -1
  42. package/build/test/test-list-merge-requests.js +1 -1
  43. package/build/test/test-list-project-members.js +1 -1
  44. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  45. package/build/test/test-merge-request-pipelines.js +1 -1
  46. package/build/test/test-mr-diffs-filter.js +1 -1
  47. package/build/test/test-mr-file-diffs.js +2 -2
  48. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  49. package/build/test/test-permission-mode.js +215 -0
  50. package/build/test/test-protected-branches.js +1 -1
  51. package/build/test/test-remote-downloads.js +2 -2
  52. package/build/test/test-search-code.js +1 -1
  53. package/build/test/test-tags.js +1 -1
  54. package/build/test/test-todos.js +1 -1
  55. package/build/test/test-token-optimizations.js +3 -3
  56. package/build/test/test-toolset-filtering.js +1 -1
  57. package/build/test/test-update-issue-slim.js +141 -0
  58. package/build/test/test-upload-markdown.js +1 -1
  59. package/build/test/utils/download-token.test.js +35 -0
  60. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  61. package/build/test/utils/graphql-query.test.js +64 -1
  62. package/build/test/utils/mock-gitlab-server.js +24 -31
  63. package/build/test/utils/redact-sensitive.test.js +32 -0
  64. package/build/test/utils/server-launcher.js +1 -2
  65. package/build/test/utils/version-check.test.js +52 -0
  66. package/build/tools/registry.js +30 -5
  67. package/build/utils/download-token.js +71 -0
  68. package/build/utils/forwarded-public-base-url.js +22 -0
  69. package/build/utils/graphql-query.js +109 -12
  70. package/build/utils/redact-sensitive.js +21 -0
  71. package/build/utils/version-check.js +40 -0
  72. package/package.json +5 -4
package/build/config.js CHANGED
@@ -28,9 +28,22 @@ export const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
28
28
  // Behavior flags
29
29
  // ---------------------------------------------------------------------------
30
30
  export const GITLAB_READ_ONLY_MODE = getConfig("read-only", "GITLAB_READ_ONLY_MODE") === "true";
31
+ const PERMISSION_MODES = ["readonly", "modify", "full"];
32
+ export const GITLAB_PERMISSION_MODE = (() => {
33
+ const raw = getConfig("permission-mode", "GITLAB_PERMISSION_MODE");
34
+ if (raw !== undefined && !PERMISSION_MODES.includes(raw)) {
35
+ throw new Error(`Invalid GITLAB_PERMISSION_MODE: "${raw}". Expected one of: ${PERMISSION_MODES.join(", ")}`);
36
+ }
37
+ // Legacy GITLAB_READ_ONLY_MODE=true always wins (most restrictive)
38
+ if (GITLAB_READ_ONLY_MODE) {
39
+ return "readonly";
40
+ }
41
+ return raw ?? "full";
42
+ })();
31
43
  export const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
32
44
  export const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
33
45
  export const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
46
+ export const GITLAB_DISABLE_VERSION_CHECK = getConfig("disable-version-check", "GITLAB_DISABLE_VERSION_CHECK") === "true";
34
47
  // ---------------------------------------------------------------------------
35
48
  // Tool filtering
36
49
  // ---------------------------------------------------------------------------
@@ -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
+ }