@zereight/mcp-gitlab 2.0.22 → 2.0.24

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/build/index.js CHANGED
@@ -1,28 +1,46 @@
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";
29
+ import os from "node:os";
11
30
  import { HttpProxyAgent } from "http-proxy-agent";
12
31
  import { HttpsProxyAgent } from "https-proxy-agent";
13
32
  import nodeFetch from "node-fetch";
14
- import path, { dirname } from "path";
33
+ import path, { dirname } from "node:path";
15
34
  import { SocksProxyAgent } from "socks-proxy-agent";
16
35
  import { CookieJar, parse as parseCookie } from "tough-cookie";
17
- import { fileURLToPath } from "url";
36
+ import { fileURLToPath, URL } from "node:url";
18
37
  import { z } from "zod";
19
38
  import { zodToJsonSchema } from "zod-to-json-schema";
20
39
  import { initializeOAuth } from "./oauth.js";
21
40
  import { GitLabClientPool } from "./gitlab-client-pool.js";
22
41
  // Add type imports for proxy agents
23
- import { Agent } from "http";
24
- import { Agent as HttpsAgent } from "https";
25
- import { URL } from "url";
42
+ import { Agent } from "node:http";
43
+ import { Agent as HttpsAgent } from "node:https";
26
44
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
27
45
  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
46
  // pipeline job schemas
@@ -32,8 +50,8 @@ GitLabDiscussionNoteSchema, // Added
32
50
  GitLabDiscussionSchema,
33
51
  // Draft Notes Schemas
34
52
  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";
53
+ ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalStateSchema, 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";
54
+ import { randomUUID } from "node:crypto";
37
55
  import { pino } from "pino";
38
56
  const logger = pino({
39
57
  level: process.env.LOG_LEVEL || "info",
@@ -68,8 +86,8 @@ try {
68
86
  SERVER_VERSION = packageJson.version || SERVER_VERSION;
69
87
  }
70
88
  }
71
- catch (error) {
72
- // Warning: Could not read version from package.json - silently continue
89
+ catch {
90
+ // Intentionally ignored: version read failure is non-critical
73
91
  }
74
92
  const server = new Server({
75
93
  name: "better-gitlab-mcp-server",
@@ -87,9 +105,9 @@ function validateConfiguration() {
87
105
  // Validate SESSION_TIMEOUT_SECONDS
88
106
  const timeoutStr = process.env.SESSION_TIMEOUT_SECONDS;
89
107
  if (timeoutStr) {
90
- const timeout = parseInt(timeoutStr);
108
+ const timeout = Number.parseInt(timeoutStr, 10);
91
109
  // Allow values >=1 for testing purposes, but recommend 60-86400 for production
92
- if (isNaN(timeout) || timeout < 1 || timeout > 86400) {
110
+ if (Number.isNaN(timeout) || timeout < 1 || timeout > 86400) {
93
111
  errors.push(`SESSION_TIMEOUT_SECONDS must be between 1 and 86400 seconds, got: ${timeoutStr}`);
94
112
  }
95
113
  if (timeout < 60) {
@@ -99,80 +117,85 @@ function validateConfiguration() {
99
117
  // Validate MAX_SESSIONS
100
118
  const maxSessionsStr = process.env.MAX_SESSIONS;
101
119
  if (maxSessionsStr) {
102
- const maxSessions = parseInt(maxSessionsStr);
103
- if (isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
120
+ const maxSessions = Number.parseInt(maxSessionsStr, 10);
121
+ if (Number.isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
104
122
  errors.push(`MAX_SESSIONS must be between 1 and 10000, got: ${maxSessionsStr}`);
105
123
  }
106
124
  }
107
125
  // Validate MAX_REQUESTS_PER_MINUTE
108
126
  const maxReqStr = process.env.MAX_REQUESTS_PER_MINUTE;
109
127
  if (maxReqStr) {
110
- const maxReq = parseInt(maxReqStr);
111
- if (isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
128
+ const maxReq = Number.parseInt(maxReqStr, 10);
129
+ if (Number.isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
112
130
  errors.push(`MAX_REQUESTS_PER_MINUTE must be between 1 and 1000, got: ${maxReqStr}`);
113
131
  }
114
132
  }
115
133
  // Validate PORT
116
- const portStr = process.env.PORT;
134
+ const portStr = getConfig('port', 'PORT');
117
135
  if (portStr) {
118
- const port = parseInt(portStr);
119
- if (isNaN(port) || port < 1 || port > 65535) {
136
+ const port = Number.parseInt(portStr, 10);
137
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
120
138
  errors.push(`PORT must be between 1 and 65535, got: ${portStr}`);
121
139
  }
122
140
  }
123
141
  // Validate GITLAB_API_URL format
124
- const apiUrls = process.env.GITLAB_API_URL?.split(',') || [];
142
+ const apiUrls = getConfig('api-url', 'GITLAB_API_URL')?.split(",") || [];
125
143
  if (apiUrls.length > 0) {
126
144
  for (const url of apiUrls) {
127
145
  try {
128
146
  new URL(url.trim());
129
147
  }
130
- catch (error) {
148
+ catch {
131
149
  errors.push(`GITLAB_API_URL contains an invalid URL: ${url.trim()}`);
132
150
  }
133
151
  }
134
152
  }
135
153
  // 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;
154
+ const remoteAuth = getConfig('remote-auth', 'REMOTE_AUTHORIZATION') === "true";
155
+ const useOAuth = getConfig('use-oauth', 'GITLAB_USE_OAUTH') === "true";
156
+ const hasToken = !!getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
157
+ const hasCookie = !!getConfig('cookie-path', 'GITLAB_AUTH_COOKIE_PATH');
140
158
  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');
159
+ errors.push('Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)');
142
160
  }
143
- if (ENABLE_DYNAMIC_API_URL && !REMOTE_AUTHORIZATION) {
144
- errors.push('ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true');
161
+ const enableDynamicApiUrl = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
162
+ if (enableDynamicApiUrl && !remoteAuth) {
163
+ errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
145
164
  }
146
165
  if (errors.length > 0) {
147
- logger.error('Configuration validation failed:');
166
+ logger.error("Configuration validation failed:");
148
167
  errors.forEach(err => logger.error(` - ${err}`));
149
168
  process.exit(1);
150
169
  }
151
- logger.info('Configuration validation passed');
170
+ logger.info("Configuration validation passed");
152
171
  }
153
- const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
172
+ const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
154
173
  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;
174
+ const GITLAB_AUTH_COOKIE_PATH = getConfig('cookie-path', 'GITLAB_AUTH_COOKIE_PATH');
175
+ const USE_OAUTH = getConfig('use-oauth', 'GITLAB_USE_OAUTH') === "true";
176
+ const IS_OLD = getConfig('is-old', 'GITLAB_IS_OLD') === "true";
177
+ const GITLAB_READ_ONLY_MODE = getConfig('read-only', 'GITLAB_READ_ONLY_MODE') === "true";
178
+ const GITLAB_DENIED_TOOLS_REGEX = getConfig('denied-tools-regex', 'GITLAB_DENIED_TOOLS_REGEX')
179
+ ? new RegExp(getConfig('denied-tools-regex', 'GITLAB_DENIED_TOOLS_REGEX'))
180
+ : undefined;
181
+ const USE_GITLAB_WIKI = getConfig('use-wiki', 'USE_GITLAB_WIKI') === "true";
182
+ const USE_MILESTONE = getConfig('use-milestone', 'USE_MILESTONE') === "true";
183
+ const USE_PIPELINE = getConfig('use-pipeline', 'USE_PIPELINE') === "true";
184
+ const SSE = getConfig('sse', 'SSE') === "true";
185
+ const STREAMABLE_HTTP = getConfig('streamable-http', 'STREAMABLE_HTTP') === "true";
186
+ const REMOTE_AUTHORIZATION = getConfig('remote-auth', 'REMOTE_AUTHORIZATION') === "true";
187
+ const ENABLE_DYNAMIC_API_URL = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
188
+ const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig('session-timeout', 'SESSION_TIMEOUT_SECONDS', '3600'), 10);
189
+ const HOST = getConfig('host', 'HOST') || '127.0.0.1';
190
+ const PORT = Number.parseInt(getConfig('port', 'PORT', '3002'), 10);
170
191
  // 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;
192
+ const HTTP_PROXY = getConfig('http-proxy', 'HTTP_PROXY');
193
+ const HTTPS_PROXY = getConfig('https-proxy', 'HTTPS_PROXY');
194
+ const NODE_TLS_REJECT_UNAUTHORIZED = getConfig('tls-reject-unauthorized', 'NODE_TLS_REJECT_UNAUTHORIZED');
195
+ const GITLAB_CA_CERT_PATH = getConfig('ca-cert-path', 'GITLAB_CA_CERT_PATH');
196
+ const GITLAB_POOL_MAX_SIZE = getConfig('pool-max-size', 'GITLAB_POOL_MAX_SIZE')
197
+ ? Number.parseInt(getConfig('pool-max-size', 'GITLAB_POOL_MAX_SIZE'), 10)
198
+ : 100;
176
199
  let sslOptions = undefined;
177
200
  if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
178
201
  sslOptions = { rejectUnauthorized: false };
@@ -204,7 +227,9 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
204
227
  httpAgent = httpAgent || new Agent();
205
228
  // Initialize the client pool for managing multiple GitLab instances
206
229
  const clientPool = new GitLabClientPool({
207
- apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com").split(',').map(normalizeGitLabApiUrl),
230
+ apiUrls: (process.env.GITLAB_API_URL || "https://gitlab.com")
231
+ .split(",")
232
+ .map(normalizeGitLabApiUrl),
208
233
  httpProxy: HTTP_PROXY,
209
234
  httpsProxy: HTTPS_PROXY,
210
235
  rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
@@ -212,73 +237,113 @@ const clientPool = new GitLabClientPool({
212
237
  poolMaxSize: GITLAB_POOL_MAX_SIZE,
213
238
  });
214
239
  // Create cookie jar with clean Netscape file parsing
215
- const createCookieJar = () => {
216
- if (!GITLAB_AUTH_COOKIE_PATH)
240
+ // Resolve cookie path once using os.homedir() for cross-platform support
241
+ const resolvedCookiePath = GITLAB_AUTH_COOKIE_PATH
242
+ ? GITLAB_AUTH_COOKIE_PATH.startsWith("~/")
243
+ ? path.join(os.homedir(), GITLAB_AUTH_COOKIE_PATH.slice(2))
244
+ : GITLAB_AUTH_COOKIE_PATH
245
+ : null;
246
+ const createCookieJar = async () => {
247
+ if (!resolvedCookiePath)
217
248
  return null;
249
+ let cookieContent;
218
250
  try {
219
- const cookiePath = GITLAB_AUTH_COOKIE_PATH.startsWith("~/")
220
- ? path.join(process.env.HOME || "", GITLAB_AUTH_COOKIE_PATH.slice(2))
221
- : GITLAB_AUTH_COOKIE_PATH;
222
- const jar = new CookieJar();
223
- const cookieContent = fs.readFileSync(cookiePath, "utf8");
224
- cookieContent.split("\n").forEach(line => {
225
- // Handle #HttpOnly_ prefix
226
- if (line.startsWith("#HttpOnly_")) {
227
- line = line.slice(10);
228
- }
229
- // Skip comments and empty lines
230
- if (line.startsWith("#") || !line.trim()) {
231
- return;
232
- }
233
- // Parse Netscape format: domain, flag, path, secure, expires, name, value
234
- const parts = line.split("\t");
235
- if (parts.length >= 7) {
236
- const [domain, , path, secure, expires, name, value] = parts;
237
- // 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()}` : ""}`;
239
- // Use tough-cookie's parse function for robust parsing
240
- const cookie = parseCookie(cookieStr);
241
- if (cookie) {
242
- const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
243
- jar.setCookieSync(cookie, url);
244
- }
245
- }
246
- });
247
- return jar;
251
+ cookieContent = await fs.promises.readFile(resolvedCookiePath, "utf8");
248
252
  }
249
253
  catch (error) {
250
- logger.error("Error loading cookie file:", error);
254
+ logger.error({ error, path: resolvedCookiePath }, "Failed to read cookie file");
251
255
  return null;
252
256
  }
257
+ const jar = new CookieJar();
258
+ for (let line of cookieContent.split("\n")) {
259
+ // Handle #HttpOnly_ prefix
260
+ if (line.startsWith("#HttpOnly_")) {
261
+ line = line.slice(10);
262
+ }
263
+ // Skip comments and empty lines
264
+ if (line.startsWith("#") || !line.trim()) {
265
+ continue;
266
+ }
267
+ // Parse Netscape format: domain, flag, path, secure, expires, name, value
268
+ const parts = line.split("\t");
269
+ if (parts.length >= 7) {
270
+ const [domain, , cookiePath, secure, expires, name, value] = parts;
271
+ // Build cookie string in standard format
272
+ const secureFlag = secure === "TRUE" ? "; Secure" : "";
273
+ const expiresFlag = expires === "0"
274
+ ? ""
275
+ : `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
276
+ const cookieStr = `${name}=${value}; Domain=${domain}; Path=${cookiePath}${secureFlag}${expiresFlag}`;
277
+ // Use tough-cookie's parse function for robust parsing
278
+ const cookie = parseCookie(cookieStr);
279
+ if (cookie) {
280
+ const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
281
+ jar.setCookieSync(cookie, url);
282
+ }
283
+ }
284
+ }
285
+ return jar;
253
286
  };
254
- // Initialize cookie jar and fetch
255
- const cookieJar = createCookieJar();
256
- const fetch = cookieJar ? fetchCookie(nodeFetch, cookieJar) : nodeFetch;
257
- // Ensure session is established for the current request
258
- async function ensureSessionForRequest() {
259
- if (!cookieJar || !GITLAB_AUTH_COOKIE_PATH)
287
+ // Cookie jar and fetch - reloaded when cookie file changes
288
+ let cookieJar = null;
289
+ let fetch = nodeFetch;
290
+ let lastCookieMtime = 0;
291
+ let cookieReloadLock = null; // Mutex to prevent parallel reloads
292
+ // Auth proxies may redirect and set cookies on the first request. We make a throwaway
293
+ // request so subsequent requests have the correct cookies. Reset when cookies reload.
294
+ let initialSessionRequestMade = false;
295
+ // Cookie jar is loaded on first request via reloadCookiesIfChanged (lastCookieMtime=0 triggers load)
296
+ async function reloadCookiesIfChanged() {
297
+ if (!resolvedCookiePath)
260
298
  return;
261
- // Extract the base URL from GITLAB_API_URL
262
- const apiUrl = new URL(GITLAB_API_URL);
263
- const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`;
264
- // Check if we already have GitLab session cookies
265
- const gitlabCookies = cookieJar.getCookiesSync(baseUrl);
266
- const hasSessionCookie = gitlabCookies.some(cookie => cookie.key === "_gitlab_session" || cookie.key === "remember_user_token");
267
- if (!hasSessionCookie) {
299
+ if (cookieReloadLock)
300
+ return cookieReloadLock;
301
+ cookieReloadLock = (async () => {
268
302
  try {
269
- // Establish session with a lightweight request
270
- await fetch(`${GITLAB_API_URL}/user`, {
271
- ...getFetchConfig(),
272
- redirect: "follow",
273
- }).catch(() => {
274
- // Ignore errors - the important thing is that cookies get set during redirects
275
- });
276
- // Small delay to ensure cookies are fully processed
277
- await new Promise(resolve => setTimeout(resolve, 100));
303
+ const mtime = (await fs.promises.stat(resolvedCookiePath)).mtimeMs;
304
+ if (mtime !== lastCookieMtime) {
305
+ logger.info({ oldMtime: lastCookieMtime, newMtime: mtime }, lastCookieMtime === 0 ? "Loading cookie file" : "Cookie file changed, reloading");
306
+ lastCookieMtime = mtime;
307
+ const newJar = await createCookieJar();
308
+ cookieJar = newJar;
309
+ fetch = newJar ? fetchCookie(nodeFetch, newJar) : nodeFetch;
310
+ initialSessionRequestMade = false;
311
+ }
278
312
  }
279
- catch (error) {
280
- // Ignore session establishment errors
313
+ catch {
314
+ // File deleted or inaccessible - clear cached cookies
315
+ if (cookieJar) {
316
+ logger.info("Cookie file removed, clearing cached cookies");
317
+ cookieJar = null;
318
+ fetch = nodeFetch;
319
+ lastCookieMtime = 0;
320
+ initialSessionRequestMade = false;
321
+ }
281
322
  }
323
+ })();
324
+ try {
325
+ await cookieReloadLock;
326
+ }
327
+ finally {
328
+ cookieReloadLock = null;
329
+ }
330
+ }
331
+ async function ensureSessionForRequest() {
332
+ if (!resolvedCookiePath)
333
+ return;
334
+ await reloadCookiesIfChanged();
335
+ if (!cookieJar || initialSessionRequestMade)
336
+ return;
337
+ try {
338
+ const response = await fetch(`${getEffectiveApiUrl()}/user`, {
339
+ ...getFetchConfig(),
340
+ redirect: "follow",
341
+ });
342
+ // 401 means auth failed but the request completed - cookies were still exchanged
343
+ initialSessionRequestMade = response.ok || response.status === 401;
344
+ }
345
+ catch {
346
+ logger.debug("Session warmup request failed, will retry on next request");
282
347
  }
283
348
  }
284
349
  const sessionAuthStore = new AsyncLocalStorage();
@@ -299,9 +364,9 @@ function buildAuthHeaders() {
299
364
  if (REMOTE_AUTHORIZATION) {
300
365
  const ctx = sessionAuthStore.getStore();
301
366
  logger.debug({ context: ctx }, "buildAuthHeaders: session context");
302
- if (ctx && ctx.token) {
367
+ if (ctx?.token) {
303
368
  return {
304
- [ctx.header]: ctx.header === 'Authorization' ? `Bearer ${ctx.token}` : ctx.token
369
+ [ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
305
370
  };
306
371
  }
307
372
  return {}; // No auth headers if no session context
@@ -309,7 +374,7 @@ function buildAuthHeaders() {
309
374
  // Standard mode: prioritize OAuth token, then fall back to environment token
310
375
  const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
311
376
  if (IS_OLD && token) {
312
- return { 'Private-Token': String(token) };
377
+ return { "Private-Token": String(token) };
313
378
  }
314
379
  if (token) {
315
380
  return { Authorization: `Bearer ${token}` };
@@ -324,7 +389,7 @@ function buildAuthHeaders() {
324
389
  function getEffectiveApiUrl() {
325
390
  if (ENABLE_DYNAMIC_API_URL) {
326
391
  const ctx = sessionAuthStore.getStore();
327
- if (ctx && ctx.apiUrl) {
392
+ if (ctx?.apiUrl) {
328
393
  return ctx.apiUrl;
329
394
  }
330
395
  logger.warn({ ctx }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
@@ -349,7 +414,46 @@ const getFetchConfig = () => {
349
414
  agent: agent,
350
415
  };
351
416
  };
352
- const toJSONSchema = (schema) => zodToJsonSchema(schema, { $refStrategy: 'none' });
417
+ const toJSONSchema = (schema) => {
418
+ const jsonSchema = zodToJsonSchema(schema, { $refStrategy: 'none' });
419
+ // Post-process to fix nullable/optional fields that should truly be optional
420
+ function fixNullableOptional(obj) {
421
+ if (obj && typeof obj === 'object') {
422
+ // If this object has properties, process them
423
+ if (obj.properties) {
424
+ const requiredSet = new Set(obj.required || []);
425
+ Object.keys(obj.properties).forEach(key => {
426
+ const prop = obj.properties[key];
427
+ // Handle fields that can be null or omitted
428
+ // If a property has type: ["object", "null"] or anyOf with null, it should not be required
429
+ if (prop.anyOf && prop.anyOf.some((t) => t.type === 'null')) {
430
+ requiredSet.delete(key);
431
+ }
432
+ else if (Array.isArray(prop.type) && prop.type.includes('null')) {
433
+ requiredSet.delete(key);
434
+ }
435
+ // Recursively process nested objects
436
+ obj.properties[key] = fixNullableOptional(prop);
437
+ });
438
+ // Normalize the required array after processing all properties
439
+ if (requiredSet.size > 0) {
440
+ obj.required = Array.from(requiredSet);
441
+ }
442
+ else if (Object.prototype.hasOwnProperty.call(obj, 'required')) {
443
+ delete obj.required;
444
+ }
445
+ }
446
+ // Process anyOf/allOf/oneOf
447
+ ['anyOf', 'allOf', 'oneOf'].forEach(combiner => {
448
+ if (obj[combiner]) {
449
+ obj[combiner] = obj[combiner].map(fixNullableOptional);
450
+ }
451
+ });
452
+ }
453
+ return obj;
454
+ }
455
+ return fixNullableOptional(jsonSchema);
456
+ };
353
457
  // Define all available tools
354
458
  const allTools = [
355
459
  {
@@ -357,6 +461,21 @@ const allTools = [
357
461
  description: "Merge a merge request in a GitLab project",
358
462
  inputSchema: toJSONSchema(MergeMergeRequestSchema),
359
463
  },
464
+ {
465
+ name: "approve_merge_request",
466
+ description: "Approve a merge request. Requires appropriate permissions.",
467
+ inputSchema: toJSONSchema(ApproveMergeRequestSchema),
468
+ },
469
+ {
470
+ name: "unapprove_merge_request",
471
+ description: "Unapprove a previously approved merge request. Requires appropriate permissions.",
472
+ inputSchema: toJSONSchema(UnapproveMergeRequestSchema),
473
+ },
474
+ {
475
+ name: "get_merge_request_approval_state",
476
+ description: "Get the approval state of a merge request including approval rules and who has approved",
477
+ inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
478
+ },
360
479
  {
361
480
  name: "execute_graphql",
362
481
  description: "Execute a GitLab GraphQL query",
@@ -422,6 +541,16 @@ const allTools = [
422
541
  description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
423
542
  inputSchema: toJSONSchema(ListMergeRequestDiffsSchema),
424
543
  },
544
+ {
545
+ name: "list_merge_request_versions",
546
+ description: "List all versions of a merge request",
547
+ inputSchema: toJSONSchema(ListMergeRequestVersionsSchema),
548
+ },
549
+ {
550
+ name: "get_merge_request_version",
551
+ description: "Get a specific version of a merge request",
552
+ inputSchema: toJSONSchema(GetMergeRequestVersionSchema),
553
+ },
425
554
  {
426
555
  name: "get_branch_diffs",
427
556
  description: "Get the changes/diffs between two branches or commits in a GitLab project",
@@ -443,7 +572,7 @@ const allTools = [
443
572
  inputSchema: toJSONSchema(CreateMergeRequestThreadSchema),
444
573
  },
445
574
  {
446
- name: 'resolve_merge_request_thread',
575
+ name: "resolve_merge_request_thread",
447
576
  description: "Resolve a thread on a merge request",
448
577
  inputSchema: toJSONSchema(ResolveMergeRequestThreadSchema),
449
578
  },
@@ -483,7 +612,7 @@ const allTools = [
483
612
  inputSchema: toJSONSchema(GetMergeRequestNoteSchema),
484
613
  },
485
614
  {
486
- name: 'get_merge_request_notes',
615
+ name: "get_merge_request_notes",
487
616
  description: "List notes for a merge request",
488
617
  inputSchema: toJSONSchema(GetMergeRequestNotesSchema),
489
618
  },
@@ -869,12 +998,14 @@ const allTools = [
869
998
  },
870
999
  ];
871
1000
  // Define which tools are read-only
872
- const readOnlyTools = [
1001
+ const readOnlyTools = new Set([
873
1002
  "search_repositories",
874
1003
  "execute_graphql",
875
1004
  "get_file_contents",
876
1005
  "get_merge_request",
877
1006
  "get_merge_request_diffs",
1007
+ "list_merge_request_versions",
1008
+ "get_merge_request_version",
878
1009
  "get_branch_diffs",
879
1010
  "mr_discussions",
880
1011
  "list_issues",
@@ -919,18 +1050,19 @@ const readOnlyTools = [
919
1050
  "list_releases",
920
1051
  "get_release",
921
1052
  "download_release_asset",
922
- ];
1053
+ "get_merge_request_approval_state",
1054
+ ]);
923
1055
  // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
924
- const wikiToolNames = [
1056
+ const wikiToolNames = new Set([
925
1057
  "list_wiki_pages",
926
1058
  "get_wiki_page",
927
1059
  "create_wiki_page",
928
1060
  "update_wiki_page",
929
1061
  "delete_wiki_page",
930
1062
  "upload_wiki_attachment",
931
- ];
1063
+ ]);
932
1064
  // Define which tools are related to milestones and can be toggled by USE_MILESTONE
933
- const milestoneToolNames = [
1065
+ const milestoneToolNames = new Set([
934
1066
  "list_milestones",
935
1067
  "get_milestone",
936
1068
  "create_milestone",
@@ -940,9 +1072,9 @@ const milestoneToolNames = [
940
1072
  "get_milestone_merge_requests",
941
1073
  "promote_milestone",
942
1074
  "get_milestone_burndown_events",
943
- ];
1075
+ ]);
944
1076
  // Define which tools are related to pipelines and can be toggled by USE_PIPELINE
945
- const pipelineToolNames = [
1077
+ const pipelineToolNames = new Set([
946
1078
  "list_pipelines",
947
1079
  "get_pipeline",
948
1080
  "list_pipeline_jobs",
@@ -955,7 +1087,7 @@ const pipelineToolNames = [
955
1087
  "play_pipeline_job",
956
1088
  "retry_pipeline_job",
957
1089
  "cancel_pipeline_job",
958
- ];
1090
+ ]);
959
1091
  /**
960
1092
  * Smart URL handling for GitLab API
961
1093
  *
@@ -976,11 +1108,17 @@ function normalizeGitLabApiUrl(url) {
976
1108
  return normalizedUrl;
977
1109
  }
978
1110
  // 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);
1111
+ const GITLAB_API_URLS = (process.env.GITLAB_API_URL || "https://gitlab.com")
1112
+ .split(",")
1113
+ .map(normalizeGitLabApiUrl);
980
1114
  const GITLAB_API_URL = GITLAB_API_URLS[0];
981
1115
  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;
1116
+ const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(",")
1117
+ .map(id => id.trim())
1118
+ .filter(Boolean) || [];
1119
+ const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE
1120
+ ? Number.parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE, 10)
1121
+ : 20;
984
1122
  // Validate authentication configuration
985
1123
  if (REMOTE_AUTHORIZATION) {
986
1124
  // Remote authorization mode: token comes from HTTP headers
@@ -1037,11 +1175,11 @@ function getEffectiveProjectId(projectId) {
1037
1175
  }
1038
1176
  // If a project ID is provided, check if it's in the whitelist
1039
1177
  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(', ')}`);
1178
+ throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}`);
1041
1179
  }
1042
1180
  // If no project ID provided but we have multiple allowed projects, require an explicit choice
1043
1181
  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.`);
1182
+ throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}). Please specify a project ID.`);
1045
1183
  }
1046
1184
  return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
1047
1185
  }
@@ -1473,18 +1611,22 @@ async function listDiscussions(projectId, resourceType, resourceIid, options = {
1473
1611
  // Extract pagination headers
1474
1612
  const pagination = {
1475
1613
  x_next_page: response.headers.get("x-next-page")
1476
- ? parseInt(response.headers.get("x-next-page"))
1614
+ ? Number.parseInt(response.headers.get("x-next-page"), 10)
1477
1615
  : null,
1478
- x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")) : undefined,
1616
+ x_page: response.headers.get("x-page")
1617
+ ? Number.parseInt(response.headers.get("x-page"), 10)
1618
+ : undefined,
1479
1619
  x_per_page: response.headers.get("x-per-page")
1480
- ? parseInt(response.headers.get("x-per-page"))
1620
+ ? Number.parseInt(response.headers.get("x-per-page"), 10)
1481
1621
  : undefined,
1482
1622
  x_prev_page: response.headers.get("x-prev-page")
1483
- ? parseInt(response.headers.get("x-prev-page"))
1623
+ ? Number.parseInt(response.headers.get("x-prev-page"), 10)
1624
+ : null,
1625
+ x_total: response.headers.get("x-total")
1626
+ ? Number.parseInt(response.headers.get("x-total"), 10)
1484
1627
  : null,
1485
- x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")) : null,
1486
1628
  x_total_pages: response.headers.get("x-total-pages")
1487
- ? parseInt(response.headers.get("x-total-pages"))
1629
+ ? Number.parseInt(response.headers.get("x-total-pages"), 10)
1488
1630
  : null,
1489
1631
  };
1490
1632
  return PaginatedDiscussionsResponseSchema.parse({
@@ -1881,10 +2023,10 @@ async function searchProjects(query, page = 1, perPage = 20) {
1881
2023
  const totalCount = response.headers.get("x-total");
1882
2024
  const totalPages = response.headers.get("x-total-pages");
1883
2025
  // GitLab API doesn't return these headers for results > 10,000
1884
- const count = totalCount ? parseInt(totalCount) : projects.length;
2026
+ const count = totalCount ? Number.parseInt(totalCount, 10) : projects.length;
1885
2027
  return GitLabSearchResponseSchema.parse({
1886
2028
  count,
1887
- total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage),
2029
+ total_pages: totalPages ? Number.parseInt(totalPages, 10) : Math.ceil(count / perPage),
1888
2030
  current_page: page,
1889
2031
  items: projects,
1890
2032
  });
@@ -1906,7 +2048,7 @@ async function createRepository(options) {
1906
2048
  visibility: options.visibility,
1907
2049
  initialize_with_readme: options.initialize_with_readme,
1908
2050
  default_branch: "main",
1909
- path: options.name.toLowerCase().replace(/\s+/g, "-"),
2051
+ path: options.name.toLowerCase().replaceAll(/\s+/g, "-"),
1910
2052
  }),
1911
2053
  });
1912
2054
  if (!response.ok) {
@@ -2088,6 +2230,68 @@ async function mergeMergeRequest(projectId, options, mergeRequestIid) {
2088
2230
  await handleGitLabError(response);
2089
2231
  return GitLabMergeRequestSchema.parse(await response.json());
2090
2232
  }
2233
+ /**
2234
+ * Approve a merge request
2235
+ *
2236
+ * @param {string} projectId - The ID or URL-encoded path of the project
2237
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
2238
+ * @param {string} sha - Optional SHA to approve (for validation that MR hasn't changed)
2239
+ * @param {string} approvalPassword - Optional password for approvals requiring re-authentication
2240
+ * @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after approving
2241
+ */
2242
+ async function approveMergeRequest(projectId, mergeRequestIid, sha, approvalPassword) {
2243
+ projectId = decodeURIComponent(projectId);
2244
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approve`);
2245
+ const body = {};
2246
+ if (sha) {
2247
+ body.sha = sha;
2248
+ }
2249
+ if (approvalPassword) {
2250
+ body.approval_password = approvalPassword;
2251
+ }
2252
+ const response = await fetch(url.toString(), {
2253
+ ...getFetchConfig(),
2254
+ method: "POST",
2255
+ body: JSON.stringify(body),
2256
+ });
2257
+ await handleGitLabError(response);
2258
+ return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2259
+ }
2260
+ /**
2261
+ * Unapprove a previously approved merge request
2262
+ *
2263
+ * @param {string} projectId - The ID or URL-encoded path of the project
2264
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
2265
+ * @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after unapproving
2266
+ */
2267
+ async function unapproveMergeRequest(projectId, mergeRequestIid) {
2268
+ projectId = decodeURIComponent(projectId);
2269
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/unapprove`);
2270
+ const response = await fetch(url.toString(), {
2271
+ ...getFetchConfig(),
2272
+ method: "POST",
2273
+ body: JSON.stringify({}),
2274
+ });
2275
+ await handleGitLabError(response);
2276
+ return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2277
+ }
2278
+ /**
2279
+ * Get the approval state of a merge request
2280
+ *
2281
+ * @param {string} projectId - The ID or URL-encoded path of the project
2282
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
2283
+ * @returns {Promise<GitLabMergeRequestApprovalState>} The approval state
2284
+ */
2285
+ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
2286
+ projectId = decodeURIComponent(projectId);
2287
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
2288
+ const response = await fetch(url.toString(), {
2289
+ ...getFetchConfig(),
2290
+ method: "GET",
2291
+ });
2292
+ await handleGitLabError(response);
2293
+ return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2294
+ }
2091
2295
  /**
2092
2296
  * Create a new note (comment) on an issue or merge request
2093
2297
  * 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수
@@ -2250,7 +2454,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
2250
2454
  }
2251
2455
  // Handle empty response (204 No Content) or successful response
2252
2456
  const responseText = await response.text();
2253
- if (!responseText || responseText.trim() === '') {
2457
+ if (!responseText || responseText.trim() === "") {
2254
2458
  // Return a success indicator for empty responses
2255
2459
  return {
2256
2460
  id: draftNoteId.toString(),
@@ -2260,7 +2464,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
2260
2464
  updated_at: new Date().toISOString(),
2261
2465
  system: false,
2262
2466
  noteable_id: mergeRequestIid.toString(),
2263
- noteable_type: "MergeRequest"
2467
+ noteable_type: "MergeRequest",
2264
2468
  };
2265
2469
  }
2266
2470
  try {
@@ -2279,7 +2483,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
2279
2483
  updated_at: new Date().toISOString(),
2280
2484
  system: false,
2281
2485
  noteable_id: mergeRequestIid.toString(),
2282
- noteable_type: "MergeRequest"
2486
+ noteable_type: "MergeRequest",
2283
2487
  };
2284
2488
  }
2285
2489
  }
@@ -2303,7 +2507,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
2303
2507
  }
2304
2508
  // Handle empty response (204 No Content) or successful response
2305
2509
  const responseText = await response.text();
2306
- if (!responseText || responseText.trim() === '') {
2510
+ if (!responseText || responseText.trim() === "") {
2307
2511
  // Return empty array for successful bulk publish with no content
2308
2512
  return [];
2309
2513
  }
@@ -2354,7 +2558,6 @@ async function createMergeRequestThread(projectId, mergeRequestIid, body, positi
2354
2558
  projectId = decodeURIComponent(projectId); // Decode project ID
2355
2559
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions`);
2356
2560
  const payload = { body };
2357
- // Add optional parameters if provided
2358
2561
  if (position) {
2359
2562
  payload.position = position;
2360
2563
  }
@@ -2370,6 +2573,46 @@ async function createMergeRequestThread(projectId, mergeRequestIid, body, positi
2370
2573
  const data = await response.json();
2371
2574
  return GitLabDiscussionSchema.parse(data);
2372
2575
  }
2576
+ /**
2577
+ * List all versions of a merge request
2578
+ * 병합 요청의 모든 버전 목록 조회
2579
+ *
2580
+ * @param {string} projectId - The ID or URL-encoded path of the project
2581
+ * @param {number} mergeRequestIid - The internal ID of the merge request
2582
+ * @returns {Promise<GitLabMergeRequestVersion[]>} List of merge request versions
2583
+ */
2584
+ async function listMergeRequestVersions(projectId, mergeRequestIid) {
2585
+ projectId = decodeURIComponent(projectId); // Decode project ID
2586
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/versions`);
2587
+ const response = await fetch(url.toString(), {
2588
+ ...getFetchConfig(),
2589
+ });
2590
+ await handleGitLabError(response);
2591
+ const data = await response.json();
2592
+ return z.array(GitLabMergeRequestVersionSchema).parse(data);
2593
+ }
2594
+ /**
2595
+ * Get a specific version of a merge request
2596
+ * 병합 요청의 특정 버전 상세 정보 조회
2597
+ *
2598
+ * @param {string} projectId - The ID or URL-encoded path of the project
2599
+ * @param {number} mergeRequestIid - The internal ID of the merge request
2600
+ * @param {number} versionId - The ID of the version
2601
+ * @returns {Promise<GitLabMergeRequestVersionDetail>} The merge request version details
2602
+ */
2603
+ async function getMergeRequestVersion(projectId, mergeRequestIid, versionId, unidiff) {
2604
+ projectId = decodeURIComponent(projectId); // Decode project ID
2605
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/versions/${versionId}`);
2606
+ if (unidiff !== undefined) {
2607
+ url.searchParams.append("unidiff", String(unidiff));
2608
+ }
2609
+ const response = await fetch(url.toString(), {
2610
+ ...getFetchConfig(),
2611
+ });
2612
+ await handleGitLabError(response);
2613
+ const data = await response.json();
2614
+ return GitLabMergeRequestVersionDetailSchema.parse(data);
2615
+ }
2373
2616
  /**
2374
2617
  * List all namespaces
2375
2618
  * 사용 가능한 모든 네임스페이스 목록 조회
@@ -3027,7 +3270,7 @@ async function cancelPipelineJob(projectId, jobId, force) {
3027
3270
  projectId = decodeURIComponent(projectId);
3028
3271
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/cancel`);
3029
3272
  if (force !== undefined) {
3030
- url.searchParams.append('force', force.toString());
3273
+ url.searchParams.append("force", force.toString());
3031
3274
  }
3032
3275
  const response = await fetch(url.toString(), {
3033
3276
  ...getFetchConfig(),
@@ -3444,7 +3687,8 @@ async function myIssues(options = {}) {
3444
3687
  async function listProjectMembers(projectId, options = {}) {
3445
3688
  projectId = decodeURIComponent(projectId);
3446
3689
  const effectiveProjectId = getEffectiveProjectId(projectId);
3447
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/members`);
3690
+ const membersPath = options.include_inheritance ? "members/all" : "members";
3691
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/${membersPath}`);
3448
3692
  // Add query parameters
3449
3693
  if (options.query)
3450
3694
  url.searchParams.append("query", options.query);
@@ -3744,24 +3988,19 @@ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
3744
3988
  return await response.text();
3745
3989
  }
3746
3990
  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
3991
  // Apply read-only filter first
3751
3992
  const tools0 = GITLAB_READ_ONLY_MODE
3752
- ? allTools.filter(tool => readOnlyTools.includes(tool.name))
3993
+ ? allTools.filter(tool => readOnlyTools.has(tool.name))
3753
3994
  : allTools;
3754
3995
  // Toggle wiki tools by USE_GITLAB_WIKI flag
3755
- const tools1 = USE_GITLAB_WIKI
3756
- ? tools0
3757
- : tools0.filter(tool => !wikiToolNames.includes(tool.name));
3996
+ const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
3758
3997
  // Toggle milestone tools by USE_MILESTONE flag
3759
- const tools2 = USE_MILESTONE
3760
- ? tools1
3761
- : tools1.filter(tool => !milestoneToolNames.includes(tool.name));
3998
+ const tools2 = USE_MILESTONE ? tools1 : tools1.filter(tool => !milestoneToolNames.has(tool.name));
3762
3999
  // 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;
4000
+ let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
4001
+ tools = GITLAB_DENIED_TOOLS_REGEX
4002
+ ? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
4003
+ : tools;
3765
4004
  // <<< START: Gemini 호환성을 위해 $schema 제거 >>>
3766
4005
  tools = tools.map(tool => {
3767
4006
  // inputSchema가 존재하고 객체인지 확인
@@ -3801,6 +4040,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3801
4040
  // Fallback for non-remote-auth mode or if session is not found
3802
4041
  return handleToolCall(request.params);
3803
4042
  });
4043
+ /**
4044
+ * Filter diffs by excluded file patterns
4045
+ * Safely handles invalid regex patterns by logging and ignoring them
4046
+ *
4047
+ * @param diffs - Array of diff objects with new_path property
4048
+ * @param excludedFilePatterns - Array of regex patterns to exclude
4049
+ * @returns Filtered array of diffs
4050
+ */
4051
+ function filterDiffsByPatterns(diffs, excludedFilePatterns) {
4052
+ if (!excludedFilePatterns?.length)
4053
+ return diffs;
4054
+ const regexPatterns = excludedFilePatterns
4055
+ .map((pattern) => {
4056
+ try {
4057
+ return new RegExp(pattern);
4058
+ }
4059
+ catch (e) {
4060
+ console.warn(`Invalid regex pattern ignored: ${pattern}`);
4061
+ return null;
4062
+ }
4063
+ })
4064
+ .filter((regex) => regex !== null);
4065
+ if (regexPatterns.length === 0)
4066
+ return diffs;
4067
+ const matchesAnyPattern = (path) => {
4068
+ if (!path)
4069
+ return false;
4070
+ return regexPatterns.some((regex) => regex.test(path));
4071
+ };
4072
+ return diffs.filter((diff) => !matchesAnyPattern(diff.new_path));
4073
+ }
3804
4074
  async function handleToolCall(params) {
3805
4075
  try {
3806
4076
  if (!params.arguments) {
@@ -3903,17 +4173,7 @@ async function handleToolCall(params) {
3903
4173
  case "get_branch_diffs": {
3904
4174
  const args = GetBranchDiffsSchema.parse(params.arguments);
3905
4175
  const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight);
3906
- if (args.excluded_file_patterns?.length) {
3907
- const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern));
3908
- // Helper function to check if a path matches any regex pattern
3909
- const matchesAnyPattern = (path) => {
3910
- if (!path)
3911
- return false;
3912
- return regexPatterns.some(regex => regex.test(path));
3913
- };
3914
- // Filter out files that match any of the regex patterns on new files
3915
- diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path));
3916
- }
4176
+ diffResp.diffs = filterDiffsByPatterns(diffResp.diffs, args.excluded_file_patterns);
3917
4177
  return {
3918
4178
  content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }],
3919
4179
  };
@@ -4010,21 +4270,21 @@ async function handleToolCall(params) {
4010
4270
  content: [{ type: "text", text: "Merge request note deleted successfully" }],
4011
4271
  };
4012
4272
  }
4013
- case 'get_merge_request_note': {
4273
+ case "get_merge_request_note": {
4014
4274
  const args = GetMergeRequestNoteSchema.parse(params.arguments);
4015
4275
  const note = await getMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id);
4016
4276
  return {
4017
4277
  content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
4018
4278
  };
4019
4279
  }
4020
- case 'get_merge_request_notes': {
4280
+ case "get_merge_request_notes": {
4021
4281
  const args = GetMergeRequestNotesSchema.parse(params.arguments);
4022
4282
  const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
4023
4283
  return {
4024
4284
  content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
4025
4285
  };
4026
4286
  }
4027
- case 'update_merge_request_note': {
4287
+ case "update_merge_request_note": {
4028
4288
  const args = UpdateMergeRequestNoteSchema.parse(params.arguments);
4029
4289
  const note = await updateMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id, args.body);
4030
4290
  return {
@@ -4055,8 +4315,9 @@ async function handleToolCall(params) {
4055
4315
  case "get_merge_request_diffs": {
4056
4316
  const args = GetMergeRequestDiffsSchema.parse(params.arguments);
4057
4317
  const diffs = await getMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.view);
4318
+ const filteredDiffs = filterDiffsByPatterns(diffs, args.excluded_file_patterns);
4058
4319
  return {
4059
- content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }],
4320
+ content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
4060
4321
  };
4061
4322
  }
4062
4323
  case "list_merge_request_diffs": {
@@ -4066,6 +4327,20 @@ async function handleToolCall(params) {
4066
4327
  content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
4067
4328
  };
4068
4329
  }
4330
+ case "list_merge_request_versions": {
4331
+ const args = ListMergeRequestVersionsSchema.parse(params.arguments);
4332
+ const versions = await listMergeRequestVersions(args.project_id, args.merge_request_iid);
4333
+ return {
4334
+ content: [{ type: "text", text: JSON.stringify(versions, null, 2) }],
4335
+ };
4336
+ }
4337
+ case "get_merge_request_version": {
4338
+ const args = GetMergeRequestVersionSchema.parse(params.arguments);
4339
+ const version = await getMergeRequestVersion(args.project_id, args.merge_request_iid, args.version_id, args.unidiff);
4340
+ return {
4341
+ content: [{ type: "text", text: JSON.stringify(version, null, 2) }],
4342
+ };
4343
+ }
4069
4344
  case "update_merge_request": {
4070
4345
  const args = UpdateMergeRequestSchema.parse(params.arguments);
4071
4346
  const { project_id, merge_request_iid, source_branch, ...options } = args;
@@ -4082,6 +4357,27 @@ async function handleToolCall(params) {
4082
4357
  content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
4083
4358
  };
4084
4359
  }
4360
+ case "approve_merge_request": {
4361
+ const args = ApproveMergeRequestSchema.parse(params.arguments);
4362
+ const approvalState = await approveMergeRequest(args.project_id, args.merge_request_iid, args.sha, args.approval_password);
4363
+ return {
4364
+ content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
4365
+ };
4366
+ }
4367
+ case "unapprove_merge_request": {
4368
+ const args = UnapproveMergeRequestSchema.parse(params.arguments);
4369
+ const approvalState = await unapproveMergeRequest(args.project_id, args.merge_request_iid);
4370
+ return {
4371
+ content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
4372
+ };
4373
+ }
4374
+ case "get_merge_request_approval_state": {
4375
+ const args = GetMergeRequestApprovalStateSchema.parse(params.arguments);
4376
+ const approvalState = await getMergeRequestApprovalState(args.project_id, args.merge_request_iid);
4377
+ return {
4378
+ content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
4379
+ };
4380
+ }
4085
4381
  case "mr_discussions": {
4086
4382
  const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
4087
4383
  const { project_id, merge_request_iid, ...options } = args;
@@ -4749,7 +5045,9 @@ async function handleToolCall(params) {
4749
5045
  const args = DownloadAttachmentSchema.parse(params.arguments);
4750
5046
  const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
4751
5047
  return {
4752
- content: [{ type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) }],
5048
+ content: [
5049
+ { type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) },
5050
+ ],
4753
5051
  };
4754
5052
  }
4755
5053
  case "list_events": {
@@ -4921,8 +5219,8 @@ async function startStreamableHTTPServer() {
4921
5219
  const streamableTransports = {};
4922
5220
  const authTimeouts = {};
4923
5221
  // 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');
5222
+ const MAX_SESSIONS = Number.parseInt(process.env.MAX_SESSIONS || "1000", 10);
5223
+ const MAX_REQUESTS_PER_MINUTE = Number.parseInt(process.env.MAX_REQUESTS_PER_MINUTE || "60", 10);
4926
5224
  // Metrics tracking
4927
5225
  const metrics = {
4928
5226
  activeSessions: 0,
@@ -4942,7 +5240,7 @@ async function startStreamableHTTPServer() {
4942
5240
  // GitLab PAT format: glpat-xxxxx (min 20 chars)
4943
5241
  if (token.length < 20)
4944
5242
  return false;
4945
- if (!/^[a-zA-Z0-9_\.-]+$/.test(token))
5243
+ if (!/^[-a-zA-Z0-9_.]+$/.test(token))
4946
5244
  return false;
4947
5245
  return true;
4948
5246
  };
@@ -4967,9 +5265,9 @@ async function startStreamableHTTPServer() {
4967
5265
  * Returns null if no auth found or invalid format
4968
5266
  */
4969
5267
  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();
5268
+ const authHeader = req.headers["authorization"] || "";
5269
+ const privateToken = req.headers["private-token"] || "";
5270
+ const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
4973
5271
  let apiUrl = GITLAB_API_URL; // Default API URL
4974
5272
  // Only process dynamic URL if the feature is enabled
4975
5273
  if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
@@ -4977,7 +5275,7 @@ async function startStreamableHTTPServer() {
4977
5275
  new URL(dynamicApiUrl); // Ensure it's a valid URL format
4978
5276
  apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
4979
5277
  }
4980
- catch (e) {
5278
+ catch {
4981
5279
  logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. Auth will fail.`);
4982
5280
  return null; // Reject if URL is malformed
4983
5281
  }
@@ -4987,13 +5285,16 @@ async function startStreamableHTTPServer() {
4987
5285
  let header = null;
4988
5286
  if (privateToken) {
4989
5287
  token = privateToken.trim();
4990
- header = 'Private-Token';
5288
+ header = "Private-Token";
4991
5289
  }
4992
5290
  else if (authHeader) {
4993
- const match = authHeader.match(/^Bearer\s+(.+)$/i);
5291
+ // Use \S+ instead of .+ to prevent ReDoS attacks
5292
+ // \S+ only matches non-whitespace, so trim() is technically unnecessary,
5293
+ // but we keep it for defensive coding and backward compatibility
5294
+ const match = /^Bearer\s+(\S+)$/i.exec(authHeader);
4994
5295
  if (match) {
4995
5296
  token = match[1].trim();
4996
- header = 'Authorization';
5297
+ header = "Authorization";
4997
5298
  }
4998
5299
  }
4999
5300
  // Validate token and return AuthData object
@@ -5048,8 +5349,8 @@ async function startStreamableHTTPServer() {
5048
5349
  if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
5049
5350
  metrics.rejectedByRateLimit++;
5050
5351
  res.status(429).json({
5051
- error: 'Rate limit exceeded',
5052
- message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed`
5352
+ error: "Rate limit exceeded",
5353
+ message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed`,
5053
5354
  });
5054
5355
  return;
5055
5356
  }
@@ -5057,8 +5358,8 @@ async function startStreamableHTTPServer() {
5057
5358
  if (!sessionId && Object.keys(streamableTransports).length >= MAX_SESSIONS) {
5058
5359
  metrics.rejectedByCapacity++;
5059
5360
  res.status(503).json({
5060
- error: 'Server capacity reached',
5061
- message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later.`
5361
+ error: "Server capacity reached",
5362
+ message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later.`,
5062
5363
  });
5063
5364
  return;
5064
5365
  }
@@ -5070,8 +5371,8 @@ async function startStreamableHTTPServer() {
5070
5371
  if (!authData) {
5071
5372
  metrics.authFailures++;
5072
5373
  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.'
5374
+ error: "Missing Authorization or Private-Token header",
5375
+ message: "Remote authorization is enabled. Please provide Authorization or Private-Token header.",
5075
5376
  });
5076
5377
  return;
5077
5378
  }
@@ -5160,7 +5461,7 @@ async function startStreamableHTTPServer() {
5160
5461
  header: authData.header,
5161
5462
  token: authData.token,
5162
5463
  lastUsed: authData.lastUsed,
5163
- apiUrl: authData.apiUrl
5464
+ apiUrl: authData.apiUrl,
5164
5465
  };
5165
5466
  // Run the entire request handling within AsyncLocalStorage context
5166
5467
  await sessionAuthStore.run(ctx, handleRequest);
@@ -5175,7 +5476,7 @@ async function startStreamableHTTPServer() {
5175
5476
  res.setHeader("Allow", "POST, DELETE");
5176
5477
  res.status(405).json({
5177
5478
  error: "Method Not Allowed",
5178
- message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server."
5479
+ message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server.",
5179
5480
  });
5180
5481
  });
5181
5482
  // Metrics endpoint
@@ -5191,14 +5492,14 @@ async function startStreamableHTTPServer() {
5191
5492
  maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
5192
5493
  sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
5193
5494
  remoteAuthEnabled: REMOTE_AUTHORIZATION,
5194
- }
5495
+ },
5195
5496
  });
5196
5497
  });
5197
5498
  // Health check endpoint
5198
5499
  app.get("/health", (_req, res) => {
5199
5500
  const isHealthy = Object.keys(streamableTransports).length < MAX_SESSIONS;
5200
5501
  res.status(isHealthy ? 200 : 503).json({
5201
- status: isHealthy ? 'healthy' : 'degraded',
5502
+ status: isHealthy ? "healthy" : "degraded",
5202
5503
  activeSessions: Object.keys(streamableTransports).length,
5203
5504
  maxSessions: MAX_SESSIONS,
5204
5505
  uptime: process.uptime(),
@@ -5242,7 +5543,7 @@ async function startStreamableHTTPServer() {
5242
5543
  logger.info(`${signal} received, starting graceful shutdown...`);
5243
5544
  // Stop accepting new connections
5244
5545
  httpServer.close(() => {
5245
- logger.info('HTTP server closed');
5546
+ logger.info("HTTP server closed");
5246
5547
  });
5247
5548
  // Close all active sessions
5248
5549
  const sessionIds = Object.keys(streamableTransports);
@@ -5267,12 +5568,12 @@ async function startStreamableHTTPServer() {
5267
5568
  Object.keys(authTimeouts).forEach(sessionId => {
5268
5569
  clearAuthTimeout(sessionId);
5269
5570
  });
5270
- logger.info('Graceful shutdown complete');
5571
+ logger.info("Graceful shutdown complete");
5271
5572
  process.exit(0);
5272
5573
  };
5273
5574
  // Register signal handlers
5274
- process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
5275
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
5575
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
5576
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
5276
5577
  }
5277
5578
  /**
5278
5579
  * Initialize server with specific transport mode
@@ -5293,10 +5594,11 @@ async function initializeServerByTransportMode(mode) {
5293
5594
  logger.warn("Starting GitLab MCP Server with Streamable HTTP transport");
5294
5595
  await startStreamableHTTPServer();
5295
5596
  break;
5296
- default:
5597
+ default: {
5297
5598
  // This should never happen with proper enum usage, but TypeScript requires it
5298
5599
  const exhaustiveCheck = mode;
5299
5600
  throw new Error(`Unknown transport mode: ${exhaustiveCheck}`);
5601
+ }
5300
5602
  }
5301
5603
  }
5302
5604
  /**