@zereight/mcp-gitlab 2.0.22 → 2.0.23

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.md CHANGED
@@ -6,12 +6,8 @@
6
6
 
7
7
  ## @zereight/mcp-gitlab
8
8
 
9
- [![smithery badge](https://smithery.ai/badge/@zereight/gitlab-mcp)](https://smithery.ai/server/@zereight/gitlab-mcp)
10
-
11
9
  GitLab MCP(Model Context Protocol) Server. **Includes bug fixes and improvements over the original GitLab MCP server.**
12
10
 
13
- <a href="https://glama.ai/mcp/servers/7jwbk4r6d7"><img width="380" height="200" src="https://glama.ai/mcp/servers/7jwbk4r6d7/badge" alt="gitlab mcp MCP server" /></a>
14
-
15
11
  ## Usage
16
12
 
17
13
  ### Using with Claude App, Cline, Roo Code, Cursor, Kilo Code
@@ -94,6 +90,38 @@ Then configure the MCP server with OAuth:
94
90
  }
95
91
  ```
96
92
 
93
+ #### Using CLI Arguments (for clients with env var issues)
94
+
95
+ Some MCP clients (like GitHub Copilot CLI) have issues with environment variables. Use CLI arguments instead:
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "gitlab": {
101
+ "command": "npx",
102
+ "args": [
103
+ "-y",
104
+ "@zereight/mcp-gitlab",
105
+ "--token=YOUR_GITLAB_TOKEN",
106
+ "--api-url=https://gitlab.com/api/v4"
107
+ ],
108
+ "tools": ["*"]
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ **Available CLI arguments:**
115
+
116
+ - `--token` - GitLab Personal Access Token (replaces `GITLAB_PERSONAL_ACCESS_TOKEN`)
117
+ - `--api-url` - GitLab API URL (replaces `GITLAB_API_URL`)
118
+ - `--read-only=true` - Enable read-only mode (replaces `GITLAB_READ_ONLY_MODE`)
119
+ - `--use-wiki=true` - Enable wiki API (replaces `USE_GITLAB_WIKI`)
120
+ - `--use-milestone=true` - Enable milestone API (replaces `USE_MILESTONE`)
121
+ - `--use-pipeline=true` - Enable pipeline API (replaces `USE_PIPELINE`)
122
+
123
+ CLI arguments take precedence over environment variables.
124
+
97
125
  #### vscode .vscode/mcp.json
98
126
 
99
127
  **Using OAuth2 (Non-Confidential - Recommended):**
package/build/index.js CHANGED
@@ -1,28 +1,45 @@
1
1
  #!/usr/bin/env node
2
+ // Parse CLI arguments
3
+ const args = process.argv.slice(2);
4
+ const cliArgs = {};
5
+ for (let i = 0; i < args.length; i++) {
6
+ const arg = args[i];
7
+ if (arg.startsWith('--')) {
8
+ const [key, value] = arg.slice(2).split('=');
9
+ if (value) {
10
+ cliArgs[key] = value;
11
+ }
12
+ else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
13
+ cliArgs[key] = args[++i];
14
+ }
15
+ }
16
+ }
17
+ function getConfig(cliKey, envKey, defaultValue) {
18
+ return cliArgs[cliKey] || process.env[envKey] || defaultValue;
19
+ }
2
20
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
21
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
4
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
23
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
24
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
- import { AsyncLocalStorage } from "async_hooks";
25
+ import { AsyncLocalStorage } from "node:async_hooks";
8
26
  import express from "express";
9
27
  import fetchCookie from "fetch-cookie";
10
- import fs from "fs";
28
+ import fs from "node:fs";
11
29
  import { HttpProxyAgent } from "http-proxy-agent";
12
30
  import { HttpsProxyAgent } from "https-proxy-agent";
13
31
  import nodeFetch from "node-fetch";
14
- import path, { dirname } from "path";
32
+ import path, { dirname } from "node:path";
15
33
  import { SocksProxyAgent } from "socks-proxy-agent";
16
34
  import { CookieJar, parse as parseCookie } from "tough-cookie";
17
- import { fileURLToPath } from "url";
35
+ import { fileURLToPath, URL } from "node:url";
18
36
  import { z } from "zod";
19
37
  import { zodToJsonSchema } from "zod-to-json-schema";
20
38
  import { initializeOAuth } from "./oauth.js";
21
39
  import { GitLabClientPool } from "./gitlab-client-pool.js";
22
40
  // Add type imports for proxy agents
23
- import { Agent } from "http";
24
- import { Agent as HttpsAgent } from "https";
25
- import { URL } from "url";
41
+ import { Agent } from "node:http";
42
+ import { Agent as HttpsAgent } from "node:https";
26
43
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
27
44
  CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, DeleteDraftNoteSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema,
28
45
  // pipeline job schemas
@@ -32,8 +49,8 @@ GitLabDiscussionNoteSchema, // Added
32
49
  GitLabDiscussionSchema,
33
50
  // Draft Notes Schemas
34
51
  GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
35
- ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema } from "./schemas.js";
36
- import { randomUUID } from "crypto";
52
+ ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, } from "./schemas.js";
53
+ import { randomUUID } from "node:crypto";
37
54
  import { pino } from "pino";
38
55
  const logger = pino({
39
56
  level: process.env.LOG_LEVEL || "info",
@@ -68,8 +85,8 @@ try {
68
85
  SERVER_VERSION = packageJson.version || SERVER_VERSION;
69
86
  }
70
87
  }
71
- catch (error) {
72
- // Warning: Could not read version from package.json - silently continue
88
+ catch {
89
+ // Intentionally ignored: version read failure is non-critical
73
90
  }
74
91
  const server = new Server({
75
92
  name: "better-gitlab-mcp-server",
@@ -87,9 +104,9 @@ function validateConfiguration() {
87
104
  // Validate SESSION_TIMEOUT_SECONDS
88
105
  const timeoutStr = process.env.SESSION_TIMEOUT_SECONDS;
89
106
  if (timeoutStr) {
90
- const timeout = parseInt(timeoutStr);
107
+ const timeout = Number.parseInt(timeoutStr, 10);
91
108
  // Allow values >=1 for testing purposes, but recommend 60-86400 for production
92
- if (isNaN(timeout) || timeout < 1 || timeout > 86400) {
109
+ if (Number.isNaN(timeout) || timeout < 1 || timeout > 86400) {
93
110
  errors.push(`SESSION_TIMEOUT_SECONDS must be between 1 and 86400 seconds, got: ${timeoutStr}`);
94
111
  }
95
112
  if (timeout < 60) {
@@ -99,80 +116,85 @@ function validateConfiguration() {
99
116
  // Validate MAX_SESSIONS
100
117
  const maxSessionsStr = process.env.MAX_SESSIONS;
101
118
  if (maxSessionsStr) {
102
- const maxSessions = parseInt(maxSessionsStr);
103
- if (isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
119
+ const maxSessions = Number.parseInt(maxSessionsStr, 10);
120
+ if (Number.isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
104
121
  errors.push(`MAX_SESSIONS must be between 1 and 10000, got: ${maxSessionsStr}`);
105
122
  }
106
123
  }
107
124
  // Validate MAX_REQUESTS_PER_MINUTE
108
125
  const maxReqStr = process.env.MAX_REQUESTS_PER_MINUTE;
109
126
  if (maxReqStr) {
110
- const maxReq = parseInt(maxReqStr);
111
- if (isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
127
+ const maxReq = Number.parseInt(maxReqStr, 10);
128
+ if (Number.isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
112
129
  errors.push(`MAX_REQUESTS_PER_MINUTE must be between 1 and 1000, got: ${maxReqStr}`);
113
130
  }
114
131
  }
115
132
  // Validate PORT
116
- const portStr = process.env.PORT;
133
+ const portStr = getConfig('port', 'PORT');
117
134
  if (portStr) {
118
- const port = parseInt(portStr);
119
- if (isNaN(port) || port < 1 || port > 65535) {
135
+ const port = Number.parseInt(portStr, 10);
136
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
120
137
  errors.push(`PORT must be between 1 and 65535, got: ${portStr}`);
121
138
  }
122
139
  }
123
140
  // Validate GITLAB_API_URL format
124
- const apiUrls = process.env.GITLAB_API_URL?.split(',') || [];
141
+ const apiUrls = getConfig('api-url', 'GITLAB_API_URL')?.split(",") || [];
125
142
  if (apiUrls.length > 0) {
126
143
  for (const url of apiUrls) {
127
144
  try {
128
145
  new URL(url.trim());
129
146
  }
130
- catch (error) {
147
+ catch {
131
148
  errors.push(`GITLAB_API_URL contains an invalid URL: ${url.trim()}`);
132
149
  }
133
150
  }
134
151
  }
135
152
  // Validate auth configuration
136
- const remoteAuth = process.env.REMOTE_AUTHORIZATION === "true";
137
- const useOAuth = process.env.GITLAB_USE_OAUTH === "true";
138
- const hasToken = !!process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
139
- const hasCookie = !!process.env.GITLAB_AUTH_COOKIE_PATH;
153
+ const remoteAuth = getConfig('remote-auth', 'REMOTE_AUTHORIZATION') === "true";
154
+ const useOAuth = getConfig('use-oauth', 'GITLAB_USE_OAUTH') === "true";
155
+ const hasToken = !!getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
156
+ const hasCookie = !!getConfig('cookie-path', 'GITLAB_AUTH_COOKIE_PATH');
140
157
  if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
141
- errors.push('Either GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_AUTH_COOKIE_PATH, GITLAB_USE_OAUTH=true, or REMOTE_AUTHORIZATION=true must be set');
158
+ errors.push('Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)');
142
159
  }
143
- if (ENABLE_DYNAMIC_API_URL && !REMOTE_AUTHORIZATION) {
144
- errors.push('ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true');
160
+ const enableDynamicApiUrl = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
161
+ if (enableDynamicApiUrl && !remoteAuth) {
162
+ errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
145
163
  }
146
164
  if (errors.length > 0) {
147
- logger.error('Configuration validation failed:');
165
+ logger.error("Configuration validation failed:");
148
166
  errors.forEach(err => logger.error(` - ${err}`));
149
167
  process.exit(1);
150
168
  }
151
- logger.info('Configuration validation passed');
169
+ logger.info("Configuration validation passed");
152
170
  }
153
- const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
171
+ const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
154
172
  let OAUTH_ACCESS_TOKEN = null;
155
- const GITLAB_AUTH_COOKIE_PATH = process.env.GITLAB_AUTH_COOKIE_PATH;
156
- const USE_OAUTH = process.env.GITLAB_USE_OAUTH === "true";
157
- const IS_OLD = process.env.GITLAB_IS_OLD === "true";
158
- const GITLAB_READ_ONLY_MODE = process.env.GITLAB_READ_ONLY_MODE === "true";
159
- const GITLAB_DENIED_TOOLS_REGEX = process.env.GITLAB_DENIED_TOOLS_REGEX ? new RegExp(process.env.GITLAB_DENIED_TOOLS_REGEX) : undefined;
160
- const USE_GITLAB_WIKI = process.env.USE_GITLAB_WIKI === "true";
161
- const USE_MILESTONE = process.env.USE_MILESTONE === "true";
162
- const USE_PIPELINE = process.env.USE_PIPELINE === "true";
163
- const SSE = process.env.SSE === "true";
164
- const STREAMABLE_HTTP = process.env.STREAMABLE_HTTP === "true";
165
- const REMOTE_AUTHORIZATION = process.env.REMOTE_AUTHORIZATION === "true";
166
- const ENABLE_DYNAMIC_API_URL = process.env.ENABLE_DYNAMIC_API_URL === "true";
167
- const SESSION_TIMEOUT_SECONDS = process.env.SESSION_TIMEOUT_SECONDS ? parseInt(process.env.SESSION_TIMEOUT_SECONDS) : 3600;
168
- const HOST = process.env.HOST || "127.0.0.1";
169
- const PORT = process.env.PORT || 3002;
173
+ const GITLAB_AUTH_COOKIE_PATH = getConfig('cookie-path', 'GITLAB_AUTH_COOKIE_PATH');
174
+ const USE_OAUTH = getConfig('use-oauth', 'GITLAB_USE_OAUTH') === "true";
175
+ const IS_OLD = getConfig('is-old', 'GITLAB_IS_OLD') === "true";
176
+ const GITLAB_READ_ONLY_MODE = getConfig('read-only', 'GITLAB_READ_ONLY_MODE') === "true";
177
+ const GITLAB_DENIED_TOOLS_REGEX = getConfig('denied-tools-regex', 'GITLAB_DENIED_TOOLS_REGEX')
178
+ ? new RegExp(getConfig('denied-tools-regex', 'GITLAB_DENIED_TOOLS_REGEX'))
179
+ : undefined;
180
+ const USE_GITLAB_WIKI = getConfig('use-wiki', 'USE_GITLAB_WIKI') === "true";
181
+ const USE_MILESTONE = getConfig('use-milestone', 'USE_MILESTONE') === "true";
182
+ const USE_PIPELINE = getConfig('use-pipeline', 'USE_PIPELINE') === "true";
183
+ const SSE = getConfig('sse', 'SSE') === "true";
184
+ const STREAMABLE_HTTP = getConfig('streamable-http', 'STREAMABLE_HTTP') === "true";
185
+ const REMOTE_AUTHORIZATION = getConfig('remote-auth', 'REMOTE_AUTHORIZATION') === "true";
186
+ const ENABLE_DYNAMIC_API_URL = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
187
+ const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig('session-timeout', 'SESSION_TIMEOUT_SECONDS', '3600'), 10);
188
+ const HOST = getConfig('host', 'HOST') || '127.0.0.1';
189
+ const PORT = Number.parseInt(getConfig('port', 'PORT', '3002'), 10);
170
190
  // Add proxy configuration
171
- const HTTP_PROXY = process.env.HTTP_PROXY;
172
- const HTTPS_PROXY = process.env.HTTPS_PROXY;
173
- const NODE_TLS_REJECT_UNAUTHORIZED = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
174
- const GITLAB_CA_CERT_PATH = process.env.GITLAB_CA_CERT_PATH;
175
- const GITLAB_POOL_MAX_SIZE = process.env.GITLAB_POOL_MAX_SIZE ? parseInt(process.env.GITLAB_POOL_MAX_SIZE) : 100;
191
+ const HTTP_PROXY = getConfig('http-proxy', 'HTTP_PROXY');
192
+ const HTTPS_PROXY = getConfig('https-proxy', 'HTTPS_PROXY');
193
+ const NODE_TLS_REJECT_UNAUTHORIZED = getConfig('tls-reject-unauthorized', 'NODE_TLS_REJECT_UNAUTHORIZED');
194
+ const GITLAB_CA_CERT_PATH = getConfig('ca-cert-path', 'GITLAB_CA_CERT_PATH');
195
+ const GITLAB_POOL_MAX_SIZE = getConfig('pool-max-size', 'GITLAB_POOL_MAX_SIZE')
196
+ ? Number.parseInt(getConfig('pool-max-size', 'GITLAB_POOL_MAX_SIZE'), 10)
197
+ : 100;
176
198
  let sslOptions = undefined;
177
199
  if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
178
200
  sslOptions = { rejectUnauthorized: false };
@@ -204,7 +226,9 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
204
226
  httpAgent = httpAgent || new Agent();
205
227
  // Initialize the client pool for managing multiple GitLab instances
206
228
  const clientPool = new GitLabClientPool({
207
- apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com").split(',').map(normalizeGitLabApiUrl),
229
+ apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
230
+ .split(",")
231
+ .map(normalizeGitLabApiUrl),
208
232
  httpProxy: HTTP_PROXY,
209
233
  httpsProxy: HTTPS_PROXY,
210
234
  rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
@@ -235,7 +259,11 @@ const createCookieJar = () => {
235
259
  if (parts.length >= 7) {
236
260
  const [domain, , path, secure, expires, name, value] = parts;
237
261
  // Build cookie string in standard format
238
- const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`;
262
+ const secureFlag = secure === "TRUE" ? "; Secure" : "";
263
+ const expiresFlag = expires === "0"
264
+ ? ""
265
+ : `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
266
+ const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secureFlag}${expiresFlag}`;
239
267
  // Use tough-cookie's parse function for robust parsing
240
268
  const cookie = parseCookie(cookieStr);
241
269
  if (cookie) {
@@ -276,8 +304,8 @@ async function ensureSessionForRequest() {
276
304
  // Small delay to ensure cookies are fully processed
277
305
  await new Promise(resolve => setTimeout(resolve, 100));
278
306
  }
279
- catch (error) {
280
- // Ignore session establishment errors
307
+ catch {
308
+ // Intentionally ignored: session establishment errors are non-critical
281
309
  }
282
310
  }
283
311
  }
@@ -299,9 +327,9 @@ function buildAuthHeaders() {
299
327
  if (REMOTE_AUTHORIZATION) {
300
328
  const ctx = sessionAuthStore.getStore();
301
329
  logger.debug({ context: ctx }, "buildAuthHeaders: session context");
302
- if (ctx && ctx.token) {
330
+ if (ctx?.token) {
303
331
  return {
304
- [ctx.header]: ctx.header === 'Authorization' ? `Bearer ${ctx.token}` : ctx.token
332
+ [ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
305
333
  };
306
334
  }
307
335
  return {}; // No auth headers if no session context
@@ -309,7 +337,7 @@ function buildAuthHeaders() {
309
337
  // Standard mode: prioritize OAuth token, then fall back to environment token
310
338
  const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
311
339
  if (IS_OLD && token) {
312
- return { 'Private-Token': String(token) };
340
+ return { "Private-Token": String(token) };
313
341
  }
314
342
  if (token) {
315
343
  return { Authorization: `Bearer ${token}` };
@@ -324,7 +352,7 @@ function buildAuthHeaders() {
324
352
  function getEffectiveApiUrl() {
325
353
  if (ENABLE_DYNAMIC_API_URL) {
326
354
  const ctx = sessionAuthStore.getStore();
327
- if (ctx && ctx.apiUrl) {
355
+ if (ctx?.apiUrl) {
328
356
  return ctx.apiUrl;
329
357
  }
330
358
  logger.warn({ ctx }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
@@ -349,7 +377,46 @@ const getFetchConfig = () => {
349
377
  agent: agent,
350
378
  };
351
379
  };
352
- const toJSONSchema = (schema) => zodToJsonSchema(schema, { $refStrategy: 'none' });
380
+ const toJSONSchema = (schema) => {
381
+ const jsonSchema = zodToJsonSchema(schema, { $refStrategy: 'none' });
382
+ // Post-process to fix nullable/optional fields that should truly be optional
383
+ function fixNullableOptional(obj) {
384
+ if (obj && typeof obj === 'object') {
385
+ // If this object has properties, process them
386
+ if (obj.properties) {
387
+ const requiredSet = new Set(obj.required || []);
388
+ Object.keys(obj.properties).forEach(key => {
389
+ const prop = obj.properties[key];
390
+ // Handle fields that can be null or omitted
391
+ // If a property has type: ["object", "null"] or anyOf with null, it should not be required
392
+ if (prop.anyOf && prop.anyOf.some((t) => t.type === 'null')) {
393
+ requiredSet.delete(key);
394
+ }
395
+ else if (Array.isArray(prop.type) && prop.type.includes('null')) {
396
+ requiredSet.delete(key);
397
+ }
398
+ // Recursively process nested objects
399
+ obj.properties[key] = fixNullableOptional(prop);
400
+ });
401
+ // Normalize the required array after processing all properties
402
+ if (requiredSet.size > 0) {
403
+ obj.required = Array.from(requiredSet);
404
+ }
405
+ else if (Object.prototype.hasOwnProperty.call(obj, 'required')) {
406
+ delete obj.required;
407
+ }
408
+ }
409
+ // Process anyOf/allOf/oneOf
410
+ ['anyOf', 'allOf', 'oneOf'].forEach(combiner => {
411
+ if (obj[combiner]) {
412
+ obj[combiner] = obj[combiner].map(fixNullableOptional);
413
+ }
414
+ });
415
+ }
416
+ return obj;
417
+ }
418
+ return fixNullableOptional(jsonSchema);
419
+ };
353
420
  // Define all available tools
354
421
  const allTools = [
355
422
  {
@@ -422,6 +489,16 @@ const allTools = [
422
489
  description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
423
490
  inputSchema: toJSONSchema(ListMergeRequestDiffsSchema),
424
491
  },
492
+ {
493
+ name: "list_merge_request_versions",
494
+ description: "List all versions of a merge request",
495
+ inputSchema: toJSONSchema(ListMergeRequestVersionsSchema),
496
+ },
497
+ {
498
+ name: "get_merge_request_version",
499
+ description: "Get a specific version of a merge request",
500
+ inputSchema: toJSONSchema(GetMergeRequestVersionSchema),
501
+ },
425
502
  {
426
503
  name: "get_branch_diffs",
427
504
  description: "Get the changes/diffs between two branches or commits in a GitLab project",
@@ -443,7 +520,7 @@ const allTools = [
443
520
  inputSchema: toJSONSchema(CreateMergeRequestThreadSchema),
444
521
  },
445
522
  {
446
- name: 'resolve_merge_request_thread',
523
+ name: "resolve_merge_request_thread",
447
524
  description: "Resolve a thread on a merge request",
448
525
  inputSchema: toJSONSchema(ResolveMergeRequestThreadSchema),
449
526
  },
@@ -483,7 +560,7 @@ const allTools = [
483
560
  inputSchema: toJSONSchema(GetMergeRequestNoteSchema),
484
561
  },
485
562
  {
486
- name: 'get_merge_request_notes',
563
+ name: "get_merge_request_notes",
487
564
  description: "List notes for a merge request",
488
565
  inputSchema: toJSONSchema(GetMergeRequestNotesSchema),
489
566
  },
@@ -869,12 +946,14 @@ const allTools = [
869
946
  },
870
947
  ];
871
948
  // Define which tools are read-only
872
- const readOnlyTools = [
949
+ const readOnlyTools = new Set([
873
950
  "search_repositories",
874
951
  "execute_graphql",
875
952
  "get_file_contents",
876
953
  "get_merge_request",
877
954
  "get_merge_request_diffs",
955
+ "list_merge_request_versions",
956
+ "get_merge_request_version",
878
957
  "get_branch_diffs",
879
958
  "mr_discussions",
880
959
  "list_issues",
@@ -919,18 +998,18 @@ const readOnlyTools = [
919
998
  "list_releases",
920
999
  "get_release",
921
1000
  "download_release_asset",
922
- ];
1001
+ ]);
923
1002
  // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
924
- const wikiToolNames = [
1003
+ const wikiToolNames = new Set([
925
1004
  "list_wiki_pages",
926
1005
  "get_wiki_page",
927
1006
  "create_wiki_page",
928
1007
  "update_wiki_page",
929
1008
  "delete_wiki_page",
930
1009
  "upload_wiki_attachment",
931
- ];
1010
+ ]);
932
1011
  // Define which tools are related to milestones and can be toggled by USE_MILESTONE
933
- const milestoneToolNames = [
1012
+ const milestoneToolNames = new Set([
934
1013
  "list_milestones",
935
1014
  "get_milestone",
936
1015
  "create_milestone",
@@ -940,9 +1019,9 @@ const milestoneToolNames = [
940
1019
  "get_milestone_merge_requests",
941
1020
  "promote_milestone",
942
1021
  "get_milestone_burndown_events",
943
- ];
1022
+ ]);
944
1023
  // Define which tools are related to pipelines and can be toggled by USE_PIPELINE
945
- const pipelineToolNames = [
1024
+ const pipelineToolNames = new Set([
946
1025
  "list_pipelines",
947
1026
  "get_pipeline",
948
1027
  "list_pipeline_jobs",
@@ -955,7 +1034,7 @@ const pipelineToolNames = [
955
1034
  "play_pipeline_job",
956
1035
  "retry_pipeline_job",
957
1036
  "cancel_pipeline_job",
958
- ];
1037
+ ]);
959
1038
  /**
960
1039
  * Smart URL handling for GitLab API
961
1040
  *
@@ -976,11 +1055,17 @@ function normalizeGitLabApiUrl(url) {
976
1055
  return normalizedUrl;
977
1056
  }
978
1057
  // Use the normalizeGitLabApiUrl function to handle various URL formats
979
- const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com").split(',').map(normalizeGitLabApiUrl);
1058
+ const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
1059
+ .split(",")
1060
+ .map(normalizeGitLabApiUrl);
980
1061
  const GITLAB_API_URL = GITLAB_API_URLS[0];
981
1062
  const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
982
- const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(',').map(id => id.trim()).filter(Boolean) || [];
983
- const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE ? parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE) : 20;
1063
+ const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(",")
1064
+ .map(id => id.trim())
1065
+ .filter(Boolean) || [];
1066
+ const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE
1067
+ ? Number.parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE, 10)
1068
+ : 20;
984
1069
  // Validate authentication configuration
985
1070
  if (REMOTE_AUTHORIZATION) {
986
1071
  // Remote authorization mode: token comes from HTTP headers
@@ -1037,11 +1122,11 @@ function getEffectiveProjectId(projectId) {
1037
1122
  }
1038
1123
  // If a project ID is provided, check if it's in the whitelist
1039
1124
  if (projectId && !GITLAB_ALLOWED_PROJECT_IDS.includes(projectId)) {
1040
- throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(', ')}`);
1125
+ throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}`);
1041
1126
  }
1042
1127
  // If no project ID provided but we have multiple allowed projects, require an explicit choice
1043
1128
  if (!projectId && GITLAB_ALLOWED_PROJECT_IDS.length > 1) {
1044
- throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(', ')}). Please specify a project ID.`);
1129
+ throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}). Please specify a project ID.`);
1045
1130
  }
1046
1131
  return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
1047
1132
  }
@@ -1473,18 +1558,22 @@ async function listDiscussions(projectId, resourceType, resourceIid, options = {
1473
1558
  // Extract pagination headers
1474
1559
  const pagination = {
1475
1560
  x_next_page: response.headers.get("x-next-page")
1476
- ? parseInt(response.headers.get("x-next-page"))
1561
+ ? Number.parseInt(response.headers.get("x-next-page"), 10)
1477
1562
  : null,
1478
- x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")) : undefined,
1563
+ x_page: response.headers.get("x-page")
1564
+ ? Number.parseInt(response.headers.get("x-page"), 10)
1565
+ : undefined,
1479
1566
  x_per_page: response.headers.get("x-per-page")
1480
- ? parseInt(response.headers.get("x-per-page"))
1567
+ ? Number.parseInt(response.headers.get("x-per-page"), 10)
1481
1568
  : undefined,
1482
1569
  x_prev_page: response.headers.get("x-prev-page")
1483
- ? parseInt(response.headers.get("x-prev-page"))
1570
+ ? Number.parseInt(response.headers.get("x-prev-page"), 10)
1571
+ : null,
1572
+ x_total: response.headers.get("x-total")
1573
+ ? Number.parseInt(response.headers.get("x-total"), 10)
1484
1574
  : null,
1485
- x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")) : null,
1486
1575
  x_total_pages: response.headers.get("x-total-pages")
1487
- ? parseInt(response.headers.get("x-total-pages"))
1576
+ ? Number.parseInt(response.headers.get("x-total-pages"), 10)
1488
1577
  : null,
1489
1578
  };
1490
1579
  return PaginatedDiscussionsResponseSchema.parse({
@@ -1881,10 +1970,10 @@ async function searchProjects(query, page = 1, perPage = 20) {
1881
1970
  const totalCount = response.headers.get("x-total");
1882
1971
  const totalPages = response.headers.get("x-total-pages");
1883
1972
  // GitLab API doesn't return these headers for results > 10,000
1884
- const count = totalCount ? parseInt(totalCount) : projects.length;
1973
+ const count = totalCount ? Number.parseInt(totalCount, 10) : projects.length;
1885
1974
  return GitLabSearchResponseSchema.parse({
1886
1975
  count,
1887
- total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage),
1976
+ total_pages: totalPages ? Number.parseInt(totalPages, 10) : Math.ceil(count / perPage),
1888
1977
  current_page: page,
1889
1978
  items: projects,
1890
1979
  });
@@ -1906,7 +1995,7 @@ async function createRepository(options) {
1906
1995
  visibility: options.visibility,
1907
1996
  initialize_with_readme: options.initialize_with_readme,
1908
1997
  default_branch: "main",
1909
- path: options.name.toLowerCase().replace(/\s+/g, "-"),
1998
+ path: options.name.toLowerCase().replaceAll(/\s+/g, "-"),
1910
1999
  }),
1911
2000
  });
1912
2001
  if (!response.ok) {
@@ -2250,7 +2339,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
2250
2339
  }
2251
2340
  // Handle empty response (204 No Content) or successful response
2252
2341
  const responseText = await response.text();
2253
- if (!responseText || responseText.trim() === '') {
2342
+ if (!responseText || responseText.trim() === "") {
2254
2343
  // Return a success indicator for empty responses
2255
2344
  return {
2256
2345
  id: draftNoteId.toString(),
@@ -2260,7 +2349,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
2260
2349
  updated_at: new Date().toISOString(),
2261
2350
  system: false,
2262
2351
  noteable_id: mergeRequestIid.toString(),
2263
- noteable_type: "MergeRequest"
2352
+ noteable_type: "MergeRequest",
2264
2353
  };
2265
2354
  }
2266
2355
  try {
@@ -2279,7 +2368,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
2279
2368
  updated_at: new Date().toISOString(),
2280
2369
  system: false,
2281
2370
  noteable_id: mergeRequestIid.toString(),
2282
- noteable_type: "MergeRequest"
2371
+ noteable_type: "MergeRequest",
2283
2372
  };
2284
2373
  }
2285
2374
  }
@@ -2303,7 +2392,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
2303
2392
  }
2304
2393
  // Handle empty response (204 No Content) or successful response
2305
2394
  const responseText = await response.text();
2306
- if (!responseText || responseText.trim() === '') {
2395
+ if (!responseText || responseText.trim() === "") {
2307
2396
  // Return empty array for successful bulk publish with no content
2308
2397
  return [];
2309
2398
  }
@@ -2354,7 +2443,6 @@ async function createMergeRequestThread(projectId, mergeRequestIid, body, positi
2354
2443
  projectId = decodeURIComponent(projectId); // Decode project ID
2355
2444
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions`);
2356
2445
  const payload = { body };
2357
- // Add optional parameters if provided
2358
2446
  if (position) {
2359
2447
  payload.position = position;
2360
2448
  }
@@ -2370,6 +2458,46 @@ async function createMergeRequestThread(projectId, mergeRequestIid, body, positi
2370
2458
  const data = await response.json();
2371
2459
  return GitLabDiscussionSchema.parse(data);
2372
2460
  }
2461
+ /**
2462
+ * List all versions of a merge request
2463
+ * 병합 요청의 모든 버전 목록 조회
2464
+ *
2465
+ * @param {string} projectId - The ID or URL-encoded path of the project
2466
+ * @param {number} mergeRequestIid - The internal ID of the merge request
2467
+ * @returns {Promise<GitLabMergeRequestVersion[]>} List of merge request versions
2468
+ */
2469
+ async function listMergeRequestVersions(projectId, mergeRequestIid) {
2470
+ projectId = decodeURIComponent(projectId); // Decode project ID
2471
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/versions`);
2472
+ const response = await fetch(url.toString(), {
2473
+ ...getFetchConfig(),
2474
+ });
2475
+ await handleGitLabError(response);
2476
+ const data = await response.json();
2477
+ return z.array(GitLabMergeRequestVersionSchema).parse(data);
2478
+ }
2479
+ /**
2480
+ * Get a specific version of a merge request
2481
+ * 병합 요청의 특정 버전 상세 정보 조회
2482
+ *
2483
+ * @param {string} projectId - The ID or URL-encoded path of the project
2484
+ * @param {number} mergeRequestIid - The internal ID of the merge request
2485
+ * @param {number} versionId - The ID of the version
2486
+ * @returns {Promise<GitLabMergeRequestVersionDetail>} The merge request version details
2487
+ */
2488
+ async function getMergeRequestVersion(projectId, mergeRequestIid, versionId, unidiff) {
2489
+ projectId = decodeURIComponent(projectId); // Decode project ID
2490
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/versions/${versionId}`);
2491
+ if (unidiff !== undefined) {
2492
+ url.searchParams.append("unidiff", String(unidiff));
2493
+ }
2494
+ const response = await fetch(url.toString(), {
2495
+ ...getFetchConfig(),
2496
+ });
2497
+ await handleGitLabError(response);
2498
+ const data = await response.json();
2499
+ return GitLabMergeRequestVersionDetailSchema.parse(data);
2500
+ }
2373
2501
  /**
2374
2502
  * List all namespaces
2375
2503
  * 사용 가능한 모든 네임스페이스 목록 조회
@@ -3027,7 +3155,7 @@ async function cancelPipelineJob(projectId, jobId, force) {
3027
3155
  projectId = decodeURIComponent(projectId);
3028
3156
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/cancel`);
3029
3157
  if (force !== undefined) {
3030
- url.searchParams.append('force', force.toString());
3158
+ url.searchParams.append("force", force.toString());
3031
3159
  }
3032
3160
  const response = await fetch(url.toString(), {
3033
3161
  ...getFetchConfig(),
@@ -3744,24 +3872,19 @@ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
3744
3872
  return await response.text();
3745
3873
  }
3746
3874
  server.setRequestHandler(ListToolsRequestSchema, async () => {
3747
- // In remote auth mode, retrieve session context from AsyncLocalStorage
3748
- // This ensures the context is available even when called from SDK's async chains
3749
- const sessionContext = REMOTE_AUTHORIZATION ? sessionAuthStore.getStore() : null;
3750
3875
  // Apply read-only filter first
3751
3876
  const tools0 = GITLAB_READ_ONLY_MODE
3752
- ? allTools.filter(tool => readOnlyTools.includes(tool.name))
3877
+ ? allTools.filter(tool => readOnlyTools.has(tool.name))
3753
3878
  : allTools;
3754
3879
  // Toggle wiki tools by USE_GITLAB_WIKI flag
3755
- const tools1 = USE_GITLAB_WIKI
3756
- ? tools0
3757
- : tools0.filter(tool => !wikiToolNames.includes(tool.name));
3880
+ const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
3758
3881
  // Toggle milestone tools by USE_MILESTONE flag
3759
- const tools2 = USE_MILESTONE
3760
- ? tools1
3761
- : tools1.filter(tool => !milestoneToolNames.includes(tool.name));
3882
+ const tools2 = USE_MILESTONE ? tools1 : tools1.filter(tool => !milestoneToolNames.has(tool.name));
3762
3883
  // Toggle pipeline tools by USE_PIPELINE flag
3763
- let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.includes(tool.name));
3764
- tools = GITLAB_DENIED_TOOLS_REGEX ? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name)) : tools;
3884
+ let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
3885
+ tools = GITLAB_DENIED_TOOLS_REGEX
3886
+ ? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
3887
+ : tools;
3765
3888
  // <<< START: Gemini 호환성을 위해 $schema 제거 >>>
3766
3889
  tools = tools.map(tool => {
3767
3890
  // inputSchema가 존재하고 객체인지 확인
@@ -4010,21 +4133,21 @@ async function handleToolCall(params) {
4010
4133
  content: [{ type: "text", text: "Merge request note deleted successfully" }],
4011
4134
  };
4012
4135
  }
4013
- case 'get_merge_request_note': {
4136
+ case "get_merge_request_note": {
4014
4137
  const args = GetMergeRequestNoteSchema.parse(params.arguments);
4015
4138
  const note = await getMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id);
4016
4139
  return {
4017
4140
  content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
4018
4141
  };
4019
4142
  }
4020
- case 'get_merge_request_notes': {
4143
+ case "get_merge_request_notes": {
4021
4144
  const args = GetMergeRequestNotesSchema.parse(params.arguments);
4022
4145
  const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
4023
4146
  return {
4024
4147
  content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
4025
4148
  };
4026
4149
  }
4027
- case 'update_merge_request_note': {
4150
+ case "update_merge_request_note": {
4028
4151
  const args = UpdateMergeRequestNoteSchema.parse(params.arguments);
4029
4152
  const note = await updateMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id, args.body);
4030
4153
  return {
@@ -4066,6 +4189,20 @@ async function handleToolCall(params) {
4066
4189
  content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
4067
4190
  };
4068
4191
  }
4192
+ case "list_merge_request_versions": {
4193
+ const args = ListMergeRequestVersionsSchema.parse(params.arguments);
4194
+ const versions = await listMergeRequestVersions(args.project_id, args.merge_request_iid);
4195
+ return {
4196
+ content: [{ type: "text", text: JSON.stringify(versions, null, 2) }],
4197
+ };
4198
+ }
4199
+ case "get_merge_request_version": {
4200
+ const args = GetMergeRequestVersionSchema.parse(params.arguments);
4201
+ const version = await getMergeRequestVersion(args.project_id, args.merge_request_iid, args.version_id, args.unidiff);
4202
+ return {
4203
+ content: [{ type: "text", text: JSON.stringify(version, null, 2) }],
4204
+ };
4205
+ }
4069
4206
  case "update_merge_request": {
4070
4207
  const args = UpdateMergeRequestSchema.parse(params.arguments);
4071
4208
  const { project_id, merge_request_iid, source_branch, ...options } = args;
@@ -4749,7 +4886,9 @@ async function handleToolCall(params) {
4749
4886
  const args = DownloadAttachmentSchema.parse(params.arguments);
4750
4887
  const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
4751
4888
  return {
4752
- content: [{ type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) }],
4889
+ content: [
4890
+ { type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) },
4891
+ ],
4753
4892
  };
4754
4893
  }
4755
4894
  case "list_events": {
@@ -4921,8 +5060,8 @@ async function startStreamableHTTPServer() {
4921
5060
  const streamableTransports = {};
4922
5061
  const authTimeouts = {};
4923
5062
  // Configuration and limits
4924
- const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '1000');
4925
- const MAX_REQUESTS_PER_MINUTE = parseInt(process.env.MAX_REQUESTS_PER_MINUTE || '60');
5063
+ const MAX_SESSIONS = Number.parseInt(process.env.MAX_SESSIONS || "1000", 10);
5064
+ const MAX_REQUESTS_PER_MINUTE = Number.parseInt(process.env.MAX_REQUESTS_PER_MINUTE || "60", 10);
4926
5065
  // Metrics tracking
4927
5066
  const metrics = {
4928
5067
  activeSessions: 0,
@@ -4942,7 +5081,7 @@ async function startStreamableHTTPServer() {
4942
5081
  // GitLab PAT format: glpat-xxxxx (min 20 chars)
4943
5082
  if (token.length < 20)
4944
5083
  return false;
4945
- if (!/^[a-zA-Z0-9_\.-]+$/.test(token))
5084
+ if (!/^[-a-zA-Z0-9_.]+$/.test(token))
4946
5085
  return false;
4947
5086
  return true;
4948
5087
  };
@@ -4967,9 +5106,9 @@ async function startStreamableHTTPServer() {
4967
5106
  * Returns null if no auth found or invalid format
4968
5107
  */
4969
5108
  const parseAuthHeaders = (req) => {
4970
- const authHeader = req.headers['authorization'] || '';
4971
- const privateToken = req.headers['private-token'] || '';
4972
- const dynamicApiUrl = req.headers['x-gitlab-api-url']?.trim();
5109
+ const authHeader = req.headers["authorization"] || "";
5110
+ const privateToken = req.headers["private-token"] || "";
5111
+ const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
4973
5112
  let apiUrl = GITLAB_API_URL; // Default API URL
4974
5113
  // Only process dynamic URL if the feature is enabled
4975
5114
  if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
@@ -4977,7 +5116,7 @@ async function startStreamableHTTPServer() {
4977
5116
  new URL(dynamicApiUrl); // Ensure it's a valid URL format
4978
5117
  apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
4979
5118
  }
4980
- catch (e) {
5119
+ catch {
4981
5120
  logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. Auth will fail.`);
4982
5121
  return null; // Reject if URL is malformed
4983
5122
  }
@@ -4987,13 +5126,16 @@ async function startStreamableHTTPServer() {
4987
5126
  let header = null;
4988
5127
  if (privateToken) {
4989
5128
  token = privateToken.trim();
4990
- header = 'Private-Token';
5129
+ header = "Private-Token";
4991
5130
  }
4992
5131
  else if (authHeader) {
4993
- const match = authHeader.match(/^Bearer\s+(.+)$/i);
5132
+ // Use \S+ instead of .+ to prevent ReDoS attacks
5133
+ // \S+ only matches non-whitespace, so trim() is technically unnecessary,
5134
+ // but we keep it for defensive coding and backward compatibility
5135
+ const match = /^Bearer\s+(\S+)$/i.exec(authHeader);
4994
5136
  if (match) {
4995
5137
  token = match[1].trim();
4996
- header = 'Authorization';
5138
+ header = "Authorization";
4997
5139
  }
4998
5140
  }
4999
5141
  // Validate token and return AuthData object
@@ -5048,8 +5190,8 @@ async function startStreamableHTTPServer() {
5048
5190
  if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
5049
5191
  metrics.rejectedByRateLimit++;
5050
5192
  res.status(429).json({
5051
- error: 'Rate limit exceeded',
5052
- message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed`
5193
+ error: "Rate limit exceeded",
5194
+ message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed`,
5053
5195
  });
5054
5196
  return;
5055
5197
  }
@@ -5057,8 +5199,8 @@ async function startStreamableHTTPServer() {
5057
5199
  if (!sessionId && Object.keys(streamableTransports).length >= MAX_SESSIONS) {
5058
5200
  metrics.rejectedByCapacity++;
5059
5201
  res.status(503).json({
5060
- error: 'Server capacity reached',
5061
- message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later.`
5202
+ error: "Server capacity reached",
5203
+ message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later.`,
5062
5204
  });
5063
5205
  return;
5064
5206
  }
@@ -5070,8 +5212,8 @@ async function startStreamableHTTPServer() {
5070
5212
  if (!authData) {
5071
5213
  metrics.authFailures++;
5072
5214
  res.status(401).json({
5073
- error: 'Missing Authorization or Private-Token header',
5074
- message: 'Remote authorization is enabled. Please provide Authorization or Private-Token header.'
5215
+ error: "Missing Authorization or Private-Token header",
5216
+ message: "Remote authorization is enabled. Please provide Authorization or Private-Token header.",
5075
5217
  });
5076
5218
  return;
5077
5219
  }
@@ -5160,7 +5302,7 @@ async function startStreamableHTTPServer() {
5160
5302
  header: authData.header,
5161
5303
  token: authData.token,
5162
5304
  lastUsed: authData.lastUsed,
5163
- apiUrl: authData.apiUrl
5305
+ apiUrl: authData.apiUrl,
5164
5306
  };
5165
5307
  // Run the entire request handling within AsyncLocalStorage context
5166
5308
  await sessionAuthStore.run(ctx, handleRequest);
@@ -5175,7 +5317,7 @@ async function startStreamableHTTPServer() {
5175
5317
  res.setHeader("Allow", "POST, DELETE");
5176
5318
  res.status(405).json({
5177
5319
  error: "Method Not Allowed",
5178
- message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server."
5320
+ message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server.",
5179
5321
  });
5180
5322
  });
5181
5323
  // Metrics endpoint
@@ -5191,14 +5333,14 @@ async function startStreamableHTTPServer() {
5191
5333
  maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
5192
5334
  sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
5193
5335
  remoteAuthEnabled: REMOTE_AUTHORIZATION,
5194
- }
5336
+ },
5195
5337
  });
5196
5338
  });
5197
5339
  // Health check endpoint
5198
5340
  app.get("/health", (_req, res) => {
5199
5341
  const isHealthy = Object.keys(streamableTransports).length < MAX_SESSIONS;
5200
5342
  res.status(isHealthy ? 200 : 503).json({
5201
- status: isHealthy ? 'healthy' : 'degraded',
5343
+ status: isHealthy ? "healthy" : "degraded",
5202
5344
  activeSessions: Object.keys(streamableTransports).length,
5203
5345
  maxSessions: MAX_SESSIONS,
5204
5346
  uptime: process.uptime(),
@@ -5242,7 +5384,7 @@ async function startStreamableHTTPServer() {
5242
5384
  logger.info(`${signal} received, starting graceful shutdown...`);
5243
5385
  // Stop accepting new connections
5244
5386
  httpServer.close(() => {
5245
- logger.info('HTTP server closed');
5387
+ logger.info("HTTP server closed");
5246
5388
  });
5247
5389
  // Close all active sessions
5248
5390
  const sessionIds = Object.keys(streamableTransports);
@@ -5267,12 +5409,12 @@ async function startStreamableHTTPServer() {
5267
5409
  Object.keys(authTimeouts).forEach(sessionId => {
5268
5410
  clearAuthTimeout(sessionId);
5269
5411
  });
5270
- logger.info('Graceful shutdown complete');
5412
+ logger.info("Graceful shutdown complete");
5271
5413
  process.exit(0);
5272
5414
  };
5273
5415
  // Register signal handlers
5274
- process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
5275
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
5416
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
5417
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
5276
5418
  }
5277
5419
  /**
5278
5420
  * Initialize server with specific transport mode
@@ -5293,10 +5435,11 @@ async function initializeServerByTransportMode(mode) {
5293
5435
  logger.warn("Starting GitLab MCP Server with Streamable HTTP transport");
5294
5436
  await startStreamableHTTPServer();
5295
5437
  break;
5296
- default:
5438
+ default: {
5297
5439
  // This should never happen with proper enum usage, but TypeScript requires it
5298
5440
  const exhaustiveCheck = mode;
5299
5441
  throw new Error(`Unknown transport mode: ${exhaustiveCheck}`);
5442
+ }
5300
5443
  }
5301
5444
  }
5302
5445
  /**
package/build/schemas.js CHANGED
@@ -792,12 +792,12 @@ export const GitLabDiscussionNoteSchema = z.object({
792
792
  position: z
793
793
  .object({
794
794
  // Only present for DiffNote
795
- base_sha: z.string().optional(),
796
- start_sha: z.string().optional(),
797
- head_sha: z.string().optional(),
795
+ base_sha: z.string().nullable().optional(),
796
+ start_sha: z.string().nullable().optional(),
797
+ head_sha: z.string().nullable().optional(),
798
798
  old_path: z.string().nullable().optional().describe("File path before change"),
799
799
  new_path: z.string().nullable().optional().describe("File path after change"),
800
- position_type: z.enum(["text", "image", "file"]).optional(),
800
+ position_type: z.enum(["text", "image", "file"]).nullable().optional(),
801
801
  new_line: z
802
802
  .number()
803
803
  .nullable()
@@ -808,11 +808,11 @@ export const GitLabDiscussionNoteSchema = z.object({
808
808
  .nullable()
809
809
  .optional()
810
810
  .describe("Line number in the original file (before changes). Used for deleted lines and context lines. Null for newly added lines."),
811
- line_range: LineRangeSchema.nullable().optional(), // For multi-line diff notes
812
- width: z.number().optional(), // For image diff notes
813
- height: z.number().optional(), // For image diff notes
814
- x: z.number().optional(), // For image diff notes
815
- y: z.number().optional(), // For image diff notes
811
+ line_range: LineRangeSchema.nullable().optional(), // Accept any value for line_range including null
812
+ width: z.number().nullable().optional(), // For image diff notes
813
+ height: z.number().nullable().optional(), // For image diff notes
814
+ x: z.number().nullable().optional(), // For image diff notes
815
+ y: z.number().nullable().optional(), // For image diff notes
816
816
  })
817
817
  .passthrough() // Allow additional fields
818
818
  .optional(),
@@ -1049,6 +1049,16 @@ export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1049
1049
  .optional()
1050
1050
  .describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
1051
1051
  });
1052
+ // Merge Request Versions API operation schemas
1053
+ export const ListMergeRequestVersionsSchema = ProjectParamsSchema.extend({
1054
+ merge_request_iid: z.coerce.string().describe("The internal ID of the merge request"),
1055
+ });
1056
+ export const GetMergeRequestVersionSchema = ListMergeRequestVersionsSchema.extend({
1057
+ version_id: z.coerce.string().describe("The ID of the merge request diff version"),
1058
+ unidiff: z.boolean()
1059
+ .optional()
1060
+ .describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
1061
+ });
1052
1062
  export const CreateNoteSchema = z.object({
1053
1063
  project_id: z.coerce.string().describe("Project ID or namespace/project_path"),
1054
1064
  noteable_type: z
@@ -1418,6 +1428,7 @@ export const MergeRequestThreadPositionCreateSchema = z.object({
1418
1428
  x: z.number().optional().describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
1419
1429
  y: z.number().optional().describe("IMAGE DIFFS ONLY: Y coordinate on the image (for position_type='image')."),
1420
1430
  });
1431
+ // Schema for creating/sending position to GitLab API (stricter)
1421
1432
  export const MergeRequestThreadPositionSchema = z.object({
1422
1433
  base_sha: z
1423
1434
  .string()
@@ -1454,18 +1465,22 @@ export const MergeRequestThreadPositionSchema = z.object({
1454
1465
  line_range: LineRangeSchema.nullable().optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
1455
1466
  width: z
1456
1467
  .number()
1468
+ .nullable()
1457
1469
  .optional()
1458
1470
  .describe("IMAGE DIFFS ONLY: Width of the image (for position_type='image')."),
1459
1471
  height: z
1460
1472
  .number()
1473
+ .nullable()
1461
1474
  .optional()
1462
1475
  .describe("IMAGE DIFFS ONLY: Height of the image (for position_type='image')."),
1463
1476
  x: z
1464
1477
  .number()
1478
+ .nullable()
1465
1479
  .optional()
1466
1480
  .describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
1467
1481
  y: z
1468
1482
  .number()
1483
+ .nullable()
1469
1484
  .optional()
1470
1485
  .describe("IMAGE DIFFS ONLY: Y coordinate on the image (for position_type='image')."),
1471
1486
  });
@@ -1502,7 +1517,7 @@ export const ListDraftNotesSchema = ProjectParamsSchema.extend({
1502
1517
  export const CreateDraftNoteSchema = ProjectParamsSchema.extend({
1503
1518
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
1504
1519
  body: z.string().describe("The content of the draft note"),
1505
- position: MergeRequestThreadPositionCreateSchema.optional().describe("Position when creating a diff note"),
1520
+ position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1506
1521
  resolve_discussion: z.boolean().optional().describe("Whether to resolve the discussion when publishing"),
1507
1522
  });
1508
1523
  // Update draft note schema
@@ -1510,7 +1525,7 @@ export const UpdateDraftNoteSchema = ProjectParamsSchema.extend({
1510
1525
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
1511
1526
  draft_note_id: z.coerce.string().describe("The ID of the draft note"),
1512
1527
  body: z.string().optional().describe("The content of the draft note"),
1513
- position: MergeRequestThreadPositionCreateSchema.optional().describe("Position when creating a diff note"),
1528
+ position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1514
1529
  resolve_discussion: z.boolean().optional().describe("Whether to resolve the discussion when publishing"),
1515
1530
  });
1516
1531
  // Delete draft note schema
@@ -1785,6 +1800,22 @@ export const GetProjectEventsSchema = z.object({
1785
1800
  page: z.number().optional().describe("Returns the specified results page. Default: 1"),
1786
1801
  per_page: z.number().optional().describe("Number of results per page. Default: 20"),
1787
1802
  });
1803
+ // Merge Request Versions schemas - Response schemas based on GitLab API documentation
1804
+ export const GitLabMergeRequestVersionSchema = z.object({
1805
+ id: z.number(),
1806
+ head_commit_sha: z.string(),
1807
+ base_commit_sha: z.string(),
1808
+ start_commit_sha: z.string(),
1809
+ created_at: z.string(),
1810
+ merge_request_id: z.number(),
1811
+ state: z.string(),
1812
+ real_size: z.string(),
1813
+ patch_id_sha: z.string(),
1814
+ });
1815
+ export const GitLabMergeRequestVersionDetailSchema = GitLabMergeRequestVersionSchema.extend({
1816
+ commits: z.array(GitLabCommitSchema),
1817
+ diffs: z.array(GitLabDiffSchema),
1818
+ });
1788
1819
  // GraphQL generic execution schema
1789
1820
  export const ExecuteGraphQLSchema = z.object({
1790
1821
  query: z.string().describe("GraphQL query string"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.0.22",
3
+ "version": "2.0.23",
4
4
  "description": "MCP server for using the GitLab API",
5
5
  "license": "MIT",
6
6
  "author": "zereight",
@@ -27,14 +27,14 @@
27
27
  "watch": "tsc --watch",
28
28
  "deploy": "npm publish --access public",
29
29
  "changelog": "auto-changelog -p",
30
- "test": "node test/validate-api.js && npm run test:remote-auth",
31
- "test:integration": "node test/validate-api.js",
30
+ "test": "npm run test:all",
31
+ "test:all": "npm run build && npm run test:mock && npm run test:live",
32
+ "test:mock": "npx tsx --test test/remote-auth-simple-test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts",
33
+ "test:live": "node test/validate-api.js && tsx test/readonly-mcp-tests.ts",
32
34
  "test:remote-auth": "npm run build && npx tsx --test test/remote-auth-simple-test.ts",
33
- "test:server": "npm run build && node build/test/test-all-transport-server.js",
34
35
  "test:mcp:readonly": "tsx test/readonly-mcp-tests.ts",
35
36
  "test:oauth": "tsx test/oauth-tests.ts",
36
37
  "test:list-merge-requests": "npm run build && tsx test/test-list-merge-requests.ts",
37
- "test:all": "npm run test && npm run test:mcp:readonly && npm run test:oauth && npm run test:list-merge-requests",
38
38
  "lint": "eslint . --ext .ts",
39
39
  "lint:fix": "eslint . --ext .ts --fix",
40
40
  "format": "prettier --write \"**/*.{js,ts,json,md}\"",