@zereight/mcp-gitlab 2.0.24 → 2.0.28

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
@@ -4,12 +4,12 @@ const args = process.argv.slice(2);
4
4
  const cliArgs = {};
5
5
  for (let i = 0; i < args.length; i++) {
6
6
  const arg = args[i];
7
- if (arg.startsWith('--')) {
8
- const [key, value] = arg.slice(2).split('=');
7
+ if (arg.startsWith("--")) {
8
+ const [key, value] = arg.slice(2).split("=");
9
9
  if (value) {
10
10
  cliArgs[key] = value;
11
11
  }
12
- else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
12
+ else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
13
13
  cliArgs[key] = args[++i];
14
14
  }
15
15
  }
@@ -89,14 +89,77 @@ try {
89
89
  catch {
90
90
  // Intentionally ignored: version read failure is non-critical
91
91
  }
92
- const server = new Server({
93
- name: "better-gitlab-mcp-server",
94
- version: SERVER_VERSION,
95
- }, {
96
- capabilities: {
97
- tools: {},
98
- },
99
- });
92
+ /**
93
+ * Create a new MCP Server instance with request handlers registered.
94
+ * Each transport connection gets its own Server instance to prevent
95
+ * cross-client data leakage (GHSA-345p-7cg4-v4c7).
96
+ */
97
+ function createServer() {
98
+ const serverInstance = new Server({
99
+ name: "better-gitlab-mcp-server",
100
+ version: SERVER_VERSION,
101
+ }, {
102
+ capabilities: {
103
+ tools: {},
104
+ },
105
+ });
106
+ serverInstance.setRequestHandler(ListToolsRequestSchema, async () => {
107
+ // Apply read-only filter first
108
+ const tools0 = GITLAB_READ_ONLY_MODE
109
+ ? allTools.filter(tool => readOnlyTools.has(tool.name))
110
+ : allTools;
111
+ // Toggle wiki tools by USE_GITLAB_WIKI flag
112
+ const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
113
+ // Toggle milestone tools by USE_MILESTONE flag
114
+ const tools2 = USE_MILESTONE
115
+ ? tools1
116
+ : tools1.filter(tool => !milestoneToolNames.has(tool.name));
117
+ // Toggle pipeline tools by USE_PIPELINE flag
118
+ let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
119
+ tools = GITLAB_DENIED_TOOLS_REGEX
120
+ ? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
121
+ : tools;
122
+ // <<< START: Gemini 호환성을 위해 $schema 제거 >>>
123
+ tools = tools.map(tool => {
124
+ // inputSchema가 존재하고 객체인지 확인
125
+ if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
126
+ // $schema 키가 존재하면 삭제
127
+ if ("$schema" in tool.inputSchema) {
128
+ // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장)
129
+ const modifiedSchema = { ...tool.inputSchema };
130
+ delete modifiedSchema.$schema;
131
+ return { ...tool, inputSchema: modifiedSchema };
132
+ }
133
+ }
134
+ // 변경이 필요 없으면 그대로 반환
135
+ return tool;
136
+ });
137
+ // <<< END: Gemini 호환성을 위해 $schema 제거 >>>
138
+ return {
139
+ tools, // $schema가 제거된 도구 목록 반환
140
+ };
141
+ });
142
+ serverInstance.setRequestHandler(CallToolRequestSchema, async (request) => {
143
+ // Manually retrieve the session context using the session ID passed in the request.
144
+ // This is a robust workaround for AsyncLocalStorage context loss.
145
+ const sessionId = request.params.sessionId;
146
+ if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
147
+ const authData = authBySession[sessionId];
148
+ const sessionContext = {
149
+ sessionId,
150
+ header: authData.header,
151
+ token: authData.token,
152
+ lastUsed: authData.lastUsed,
153
+ apiUrl: authData.apiUrl,
154
+ };
155
+ // Run the handler within the retrieved context
156
+ return await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
157
+ }
158
+ // Fallback for non-remote-auth mode or if session is not found
159
+ return handleToolCall(request.params);
160
+ });
161
+ return serverInstance;
162
+ }
100
163
  /**
101
164
  * Validate configuration at startup
102
165
  */
@@ -131,7 +194,7 @@ function validateConfiguration() {
131
194
  }
132
195
  }
133
196
  // Validate PORT
134
- const portStr = getConfig('port', 'PORT');
197
+ const portStr = getConfig("port", "PORT");
135
198
  if (portStr) {
136
199
  const port = Number.parseInt(portStr, 10);
137
200
  if (Number.isNaN(port) || port < 1 || port > 65535) {
@@ -139,7 +202,7 @@ function validateConfiguration() {
139
202
  }
140
203
  }
141
204
  // Validate GITLAB_API_URL format
142
- const apiUrls = getConfig('api-url', 'GITLAB_API_URL')?.split(",") || [];
205
+ const apiUrls = getConfig("api-url", "GITLAB_API_URL")?.split(",") || [];
143
206
  if (apiUrls.length > 0) {
144
207
  for (const url of apiUrls) {
145
208
  try {
@@ -151,14 +214,14 @@ function validateConfiguration() {
151
214
  }
152
215
  }
153
216
  // Validate auth configuration
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');
217
+ const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
218
+ const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
219
+ const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
220
+ const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
158
221
  if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
159
- errors.push('Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)');
222
+ errors.push("Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)");
160
223
  }
161
- const enableDynamicApiUrl = getConfig('enable-dynamic-api-url', 'ENABLE_DYNAMIC_API_URL') === "true";
224
+ const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
162
225
  if (enableDynamicApiUrl && !remoteAuth) {
163
226
  errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
164
227
  }
@@ -169,32 +232,57 @@ function validateConfiguration() {
169
232
  }
170
233
  logger.info("Configuration validation passed");
171
234
  }
172
- const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig('token', 'GITLAB_PERSONAL_ACCESS_TOKEN');
235
+ const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
173
236
  let OAUTH_ACCESS_TOKEN = null;
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);
237
+ const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
238
+ const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
239
+ const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
240
+ const GITLAB_READ_ONLY_MODE = getConfig("read-only", "GITLAB_READ_ONLY_MODE") === "true";
241
+ const GITLAB_DENIED_TOOLS_REGEX = (() => {
242
+ const pattern = getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX");
243
+ if (!pattern)
244
+ return undefined;
245
+ // Reject patterns that are too long (potential ReDoS vector)
246
+ const MAX_PATTERN_LENGTH = 200;
247
+ if (pattern.length > MAX_PATTERN_LENGTH) {
248
+ logger.error(`GITLAB_DENIED_TOOLS_REGEX pattern exceeds ${MAX_PATTERN_LENGTH} chars. Ignoring.`);
249
+ return undefined;
250
+ }
251
+ // Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
252
+ // e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
253
+ const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]|\(\?[^:)]/;
254
+ if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
255
+ logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
256
+ return undefined;
257
+ }
258
+ try {
259
+ const regex = new RegExp(pattern);
260
+ // Dry-run against a sample string to catch immediate issues
261
+ regex.test("sample_tool_name");
262
+ return regex;
263
+ }
264
+ catch {
265
+ logger.error(`Invalid GITLAB_DENIED_TOOLS_REGEX pattern: "${pattern}". Ignoring.`);
266
+ return undefined;
267
+ }
268
+ })();
269
+ const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
270
+ const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
271
+ const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
272
+ const SSE = getConfig("sse", "SSE") === "true";
273
+ const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
274
+ const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
275
+ const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
276
+ const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig("session-timeout", "SESSION_TIMEOUT_SECONDS", "3600"), 10);
277
+ const HOST = getConfig("host", "HOST") || "127.0.0.1";
278
+ const PORT = Number.parseInt(getConfig("port", "PORT", "3002"), 10);
191
279
  // Add proxy configuration
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)
280
+ const HTTP_PROXY = getConfig("http-proxy", "HTTP_PROXY");
281
+ const HTTPS_PROXY = getConfig("https-proxy", "HTTPS_PROXY");
282
+ const NODE_TLS_REJECT_UNAUTHORIZED = getConfig("tls-reject-unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED");
283
+ const GITLAB_CA_CERT_PATH = getConfig("ca-cert-path", "GITLAB_CA_CERT_PATH");
284
+ const GITLAB_POOL_MAX_SIZE = getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE")
285
+ ? Number.parseInt(getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE"), 10)
198
286
  : 100;
199
287
  let sslOptions = undefined;
200
288
  if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
@@ -415,10 +503,10 @@ const getFetchConfig = () => {
415
503
  };
416
504
  };
417
505
  const toJSONSchema = (schema) => {
418
- const jsonSchema = zodToJsonSchema(schema, { $refStrategy: 'none' });
506
+ const jsonSchema = zodToJsonSchema(schema, { $refStrategy: "none" });
419
507
  // Post-process to fix nullable/optional fields that should truly be optional
420
508
  function fixNullableOptional(obj) {
421
- if (obj && typeof obj === 'object') {
509
+ if (obj && typeof obj === "object") {
422
510
  // If this object has properties, process them
423
511
  if (obj.properties) {
424
512
  const requiredSet = new Set(obj.required || []);
@@ -426,10 +514,10 @@ const toJSONSchema = (schema) => {
426
514
  const prop = obj.properties[key];
427
515
  // Handle fields that can be null or omitted
428
516
  // 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')) {
517
+ if (prop.anyOf && prop.anyOf.some((t) => t.type === "null")) {
430
518
  requiredSet.delete(key);
431
519
  }
432
- else if (Array.isArray(prop.type) && prop.type.includes('null')) {
520
+ else if (Array.isArray(prop.type) && prop.type.includes("null")) {
433
521
  requiredSet.delete(key);
434
522
  }
435
523
  // Recursively process nested objects
@@ -439,12 +527,12 @@ const toJSONSchema = (schema) => {
439
527
  if (requiredSet.size > 0) {
440
528
  obj.required = Array.from(requiredSet);
441
529
  }
442
- else if (Object.prototype.hasOwnProperty.call(obj, 'required')) {
530
+ else if (Object.prototype.hasOwnProperty.call(obj, "required")) {
443
531
  delete obj.required;
444
532
  }
445
533
  }
446
534
  // Process anyOf/allOf/oneOf
447
- ['anyOf', 'allOf', 'oneOf'].forEach(combiner => {
535
+ ["anyOf", "allOf", "oneOf"].forEach(combiner => {
448
536
  if (obj[combiner]) {
449
537
  obj[combiner] = obj[combiner].map(fixNullableOptional);
450
538
  }
@@ -1703,17 +1791,41 @@ async function updateMergeRequestDiscussionNote(projectId, mergeRequestIid, disc
1703
1791
  }
1704
1792
  /**
1705
1793
  * Update an issue discussion note
1794
+ *
1795
+ * Note: Only one of `body` or `resolved` can be provided per GitLab API requirements.
1796
+ * At least one parameter must be provided.
1797
+ *
1706
1798
  * @param {string} projectId - The ID or URL-encoded path of the project
1707
- * @param {number} issueIid - The IID of an issue
1799
+ * @param {number|string} issueIid - The IID of an issue
1708
1800
  * @param {string} discussionId - The ID of a thread
1709
- * @param {number} noteId - The ID of a thread note
1710
- * @param {string} body - The new content of the note
1801
+ * @param {number|string} noteId - The ID of a thread note
1802
+ * @param {string} [body] - The new content of the note (optional, mutually exclusive with resolved)
1803
+ * @param {boolean} [resolved] - Resolve (true) or unresolve (false) the thread (optional, mutually exclusive with body)
1711
1804
  * @returns {Promise<GitLabDiscussionNote>} The updated note
1805
+ *
1806
+ * @example
1807
+ * // Resolve a thread
1808
+ * await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, undefined, true);
1809
+ *
1810
+ * @example
1811
+ * // Unresolve a thread
1812
+ * await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, undefined, false);
1813
+ *
1814
+ * @example
1815
+ * // Update note body
1816
+ * await updateIssueNote('mygroup/myproject', 123, 'abc123', 456, 'Updated content');
1712
1817
  */
1713
- async function updateIssueNote(projectId, issueIid, discussionId, noteId, body) {
1818
+ async function updateIssueNote(projectId, issueIid, discussionId, noteId, body, resolved) {
1714
1819
  projectId = decodeURIComponent(projectId); // Decode project ID
1715
1820
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}`);
1716
- const payload = { body };
1821
+ // Only one of body or resolved can be sent according to GitLab API
1822
+ const payload = {};
1823
+ if (body !== undefined) {
1824
+ payload.body = body;
1825
+ }
1826
+ else if (resolved !== undefined) {
1827
+ payload.resolved = resolved;
1828
+ }
1717
1829
  const response = await fetch(url.toString(), {
1718
1830
  ...getFetchConfig(),
1719
1831
  method: "PUT",
@@ -1815,7 +1927,7 @@ async function getMergeRequestNote(projectId, mergeRequestIid, noteId) {
1815
1927
  const data = await response.json();
1816
1928
  return GitLabDiscussionNoteSchema.parse(data);
1817
1929
  }
1818
- async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by) {
1930
+ async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by, per_page, page) {
1819
1931
  projectId = decodeURIComponent(projectId); // Decode project ID
1820
1932
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes`);
1821
1933
  if (sort) {
@@ -1824,6 +1936,12 @@ async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by)
1824
1936
  if (order_by) {
1825
1937
  url.searchParams.append("order_by", order_by);
1826
1938
  }
1939
+ if (per_page) {
1940
+ url.searchParams.append("per_page", per_page.toString());
1941
+ }
1942
+ if (page) {
1943
+ url.searchParams.append("page", page.toString());
1944
+ }
1827
1945
  const response = await fetch(url.toString(), {
1828
1946
  ...getFetchConfig(),
1829
1947
  method: "GET",
@@ -3987,59 +4105,8 @@ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
3987
4105
  await handleGitLabError(response);
3988
4106
  return await response.text();
3989
4107
  }
3990
- server.setRequestHandler(ListToolsRequestSchema, async () => {
3991
- // Apply read-only filter first
3992
- const tools0 = GITLAB_READ_ONLY_MODE
3993
- ? allTools.filter(tool => readOnlyTools.has(tool.name))
3994
- : allTools;
3995
- // Toggle wiki tools by USE_GITLAB_WIKI flag
3996
- const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
3997
- // Toggle milestone tools by USE_MILESTONE flag
3998
- const tools2 = USE_MILESTONE ? tools1 : tools1.filter(tool => !milestoneToolNames.has(tool.name));
3999
- // Toggle pipeline tools by USE_PIPELINE flag
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;
4004
- // <<< START: Gemini 호환성을 위해 $schema 제거 >>>
4005
- tools = tools.map(tool => {
4006
- // inputSchema가 존재하고 객체인지 확인
4007
- if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
4008
- // $schema 키가 존재하면 삭제
4009
- if ("$schema" in tool.inputSchema) {
4010
- // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장)
4011
- const modifiedSchema = { ...tool.inputSchema };
4012
- delete modifiedSchema.$schema;
4013
- return { ...tool, inputSchema: modifiedSchema };
4014
- }
4015
- }
4016
- // 변경이 필요 없으면 그대로 반환
4017
- return tool;
4018
- });
4019
- // <<< END: Gemini 호환성을 위해 $schema 제거 >>>
4020
- return {
4021
- tools, // $schema가 제거된 도구 목록 반환
4022
- };
4023
- });
4024
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
4025
- // Manually retrieve the session context using the session ID passed in the request.
4026
- // This is a robust workaround for AsyncLocalStorage context loss.
4027
- const sessionId = request.params.sessionId;
4028
- if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
4029
- const authData = authBySession[sessionId];
4030
- const sessionContext = {
4031
- sessionId,
4032
- header: authData.header,
4033
- token: authData.token,
4034
- lastUsed: authData.lastUsed,
4035
- apiUrl: authData.apiUrl,
4036
- };
4037
- // Run the handler within the retrieved context
4038
- return await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
4039
- }
4040
- // Fallback for non-remote-auth mode or if session is not found
4041
- return handleToolCall(request.params);
4042
- });
4108
+ // Request handlers are now registered inside createServer() factory function
4109
+ // to ensure each transport connection gets its own Server instance (GHSA-345p-7cg4-v4c7).
4043
4110
  /**
4044
4111
  * Filter diffs by excluded file patterns
4045
4112
  * Safely handles invalid regex patterns by logging and ignoring them
@@ -4052,7 +4119,7 @@ function filterDiffsByPatterns(diffs, excludedFilePatterns) {
4052
4119
  if (!excludedFilePatterns?.length)
4053
4120
  return diffs;
4054
4121
  const regexPatterns = excludedFilePatterns
4055
- .map((pattern) => {
4122
+ .map(pattern => {
4056
4123
  try {
4057
4124
  return new RegExp(pattern);
4058
4125
  }
@@ -4067,9 +4134,9 @@ function filterDiffsByPatterns(diffs, excludedFilePatterns) {
4067
4134
  const matchesAnyPattern = (path) => {
4068
4135
  if (!path)
4069
4136
  return false;
4070
- return regexPatterns.some((regex) => regex.test(path));
4137
+ return regexPatterns.some(regex => regex.test(path));
4071
4138
  };
4072
- return diffs.filter((diff) => !matchesAnyPattern(diff.new_path));
4139
+ return diffs.filter(diff => !matchesAnyPattern(diff.new_path));
4073
4140
  }
4074
4141
  async function handleToolCall(params) {
4075
4142
  try {
@@ -4279,7 +4346,7 @@ async function handleToolCall(params) {
4279
4346
  }
4280
4347
  case "get_merge_request_notes": {
4281
4348
  const args = GetMergeRequestNotesSchema.parse(params.arguments);
4282
- const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
4349
+ const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by, args.per_page, args.page);
4283
4350
  return {
4284
4351
  content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
4285
4352
  };
@@ -4293,7 +4360,7 @@ async function handleToolCall(params) {
4293
4360
  }
4294
4361
  case "update_issue_note": {
4295
4362
  const args = UpdateIssueNoteSchema.parse(params.arguments);
4296
- const note = await updateIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.note_id, args.body);
4363
+ const note = await updateIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.note_id, args.body, args.resolved);
4297
4364
  return {
4298
4365
  content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
4299
4366
  };
@@ -5170,8 +5237,9 @@ function determineTransportMode() {
5170
5237
  * Start server with stdio transport
5171
5238
  */
5172
5239
  async function startStdioServer() {
5240
+ const serverInstance = createServer();
5173
5241
  const transport = new StdioServerTransport();
5174
- await server.connect(transport);
5242
+ await serverInstance.connect(transport);
5175
5243
  }
5176
5244
  /**
5177
5245
  * Start server with traditional SSE transport
@@ -5180,12 +5248,13 @@ async function startSSEServer() {
5180
5248
  const app = express();
5181
5249
  const transports = {};
5182
5250
  app.get("/sse", async (_, res) => {
5251
+ const serverInstance = createServer();
5183
5252
  const transport = new SSEServerTransport("/messages", res);
5184
5253
  transports[transport.sessionId] = transport;
5185
5254
  res.on("close", () => {
5186
5255
  delete transports[transport.sessionId];
5187
5256
  });
5188
- await server.connect(transport);
5257
+ await serverInstance.connect(transport);
5189
5258
  });
5190
5259
  app.post("/messages", async (req, res) => {
5191
5260
  const sessionId = req.query.sessionId;
@@ -5439,8 +5508,10 @@ async function startStreamableHTTPServer() {
5439
5508
  }
5440
5509
  }
5441
5510
  };
5442
- // Connect transport to MCP server
5443
- await server.connect(transport);
5511
+ // Create a new Server instance per session to prevent
5512
+ // cross-client data leakage (GHSA-345p-7cg4-v4c7)
5513
+ const serverInstance = createServer();
5514
+ await serverInstance.connect(transport);
5444
5515
  // Handle the request - context is already set up in the outer handleRequest wrapper
5445
5516
  await transport.handleRequest(req, res, req.body);
5446
5517
  }
package/build/schemas.js CHANGED
@@ -856,6 +856,8 @@ export const GetMergeRequestNotesSchema = ProjectParamsSchema.extend({
856
856
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
857
857
  sort: z.enum(["asc", "desc"]).optional().describe("The sort order of the notes"),
858
858
  order_by: z.enum(["created_at", "updated_at"]).optional().describe("The field to sort the notes by"),
859
+ per_page: z.coerce.number().optional().describe("Number of items per page"),
860
+ page: z.coerce.number().optional().describe("Page number for pagination"),
859
861
  });
860
862
  export const GetMergeRequestNoteSchema = ProjectParamsSchema.extend({
861
863
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
@@ -908,7 +910,14 @@ export const UpdateIssueNoteSchema = ProjectParamsSchema.extend({
908
910
  issue_iid: z.coerce.string().describe("The IID of an issue"),
909
911
  discussion_id: z.coerce.string().describe("The ID of a thread"),
910
912
  note_id: z.coerce.string().describe("The ID of a thread note"),
911
- body: z.string().describe("The content of the note or reply"),
913
+ body: z.string().optional().describe("The content of the note or reply"),
914
+ resolved: z.boolean().optional().describe("Resolve or unresolve the note"),
915
+ })
916
+ .refine(data => data.body !== undefined || data.resolved !== undefined, {
917
+ message: "At least one of 'body' or 'resolved' must be provided",
918
+ })
919
+ .refine(data => !(data.body !== undefined && data.resolved !== undefined), {
920
+ message: "Only one of 'body' or 'resolved' can be provided, not both",
912
921
  });
913
922
  // Input schema for adding a note to an existing issue discussion
914
923
  export const CreateIssueNoteSchema = ProjectParamsSchema.extend({
@@ -0,0 +1,127 @@
1
+ /**
2
+ * This test file demonstrates the new resolve functionality for issue notes.
3
+ * It shows how to use the update_issue_note tool to resolve or unresolve
4
+ * issue discussion threads.
5
+ */
6
+ import fetch from "node-fetch";
7
+ // GitLab API configuration (replace with actual values when testing)
8
+ const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
9
+ const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_TOKEN || "";
10
+ const PROJECT_ID = process.env.PROJECT_ID || "your/project";
11
+ const ISSUE_IID = Number(process.env.ISSUE_IID || "1");
12
+ const DISCUSSION_ID = process.env.DISCUSSION_ID || "your-discussion-id";
13
+ const NOTE_ID = process.env.NOTE_ID || "your-note-id";
14
+ /**
15
+ * Test resolving an issue note
16
+ */
17
+ async function testResolveIssueNote() {
18
+ try {
19
+ const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
20
+ const response = await fetch(url.toString(), {
21
+ method: "PUT",
22
+ headers: {
23
+ Accept: "application/json",
24
+ "Content-Type": "application/json",
25
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
26
+ },
27
+ body: JSON.stringify({ resolved: true }),
28
+ });
29
+ if (!response.ok) {
30
+ const errorBody = await response.text();
31
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
32
+ }
33
+ const data = await response.json();
34
+ console.log("Successfully resolved issue note:");
35
+ console.log(JSON.stringify(data, null, 2));
36
+ return true;
37
+ }
38
+ catch (error) {
39
+ console.error("Error resolving issue note:", error);
40
+ return false;
41
+ }
42
+ }
43
+ /**
44
+ * Test unresolving an issue note
45
+ */
46
+ async function testUnresolveIssueNote() {
47
+ try {
48
+ const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
49
+ const response = await fetch(url.toString(), {
50
+ method: "PUT",
51
+ headers: {
52
+ Accept: "application/json",
53
+ "Content-Type": "application/json",
54
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
55
+ },
56
+ body: JSON.stringify({ resolved: false }),
57
+ });
58
+ if (!response.ok) {
59
+ const errorBody = await response.text();
60
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
61
+ }
62
+ const data = await response.json();
63
+ console.log("Successfully unresolved issue note:");
64
+ console.log(JSON.stringify(data, null, 2));
65
+ return true;
66
+ }
67
+ catch (error) {
68
+ console.error("Error unresolving issue note:", error);
69
+ return false;
70
+ }
71
+ }
72
+ /**
73
+ * Test updating note body (existing functionality should still work)
74
+ */
75
+ async function testUpdateIssueNoteBody() {
76
+ try {
77
+ const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
78
+ const response = await fetch(url.toString(), {
79
+ method: "PUT",
80
+ headers: {
81
+ Accept: "application/json",
82
+ "Content-Type": "application/json",
83
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
84
+ },
85
+ body: JSON.stringify({ body: "Updated note content" }),
86
+ });
87
+ if (!response.ok) {
88
+ const errorBody = await response.text();
89
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
90
+ }
91
+ const data = await response.json();
92
+ console.log("Successfully updated issue note body:");
93
+ console.log(JSON.stringify(data, null, 2));
94
+ return true;
95
+ }
96
+ catch (error) {
97
+ console.error("Error updating issue note body:", error);
98
+ return false;
99
+ }
100
+ }
101
+ // Only run the test if executed directly
102
+ if (require.main === module) {
103
+ console.log("Testing issue note resolve functionality...\n");
104
+ console.log("Note: This is a demonstration test file.");
105
+ console.log("To run actual tests, set the following environment variables:");
106
+ console.log(" - GITLAB_API_URL");
107
+ console.log(" - GITLAB_TOKEN");
108
+ console.log(" - PROJECT_ID");
109
+ console.log(" - ISSUE_IID");
110
+ console.log(" - DISCUSSION_ID");
111
+ console.log(" - NOTE_ID");
112
+ console.log("\nExample MCP tool usage:");
113
+ console.log(`
114
+ {
115
+ "name": "update_issue_note",
116
+ "arguments": {
117
+ "project_id": "your/project",
118
+ "issue_iid": "1",
119
+ "discussion_id": "abc123",
120
+ "note_id": "456",
121
+ "resolved": true
122
+ }
123
+ }
124
+ `);
125
+ }
126
+ // Export for use in other tests
127
+ export { testResolveIssueNote, testUnresolveIssueNote, testUpdateIssueNoteBody };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.0.24",
3
+ "version": "2.0.28",
4
4
  "description": "MCP server for using the GitLab API",
5
5
  "license": "MIT",
6
6
  "author": "zereight",
@@ -37,10 +37,12 @@
37
37
  "test:approvals": "npm run build && tsx test/test-merge-request-approvals.ts",
38
38
  "lint": "eslint . --ext .ts",
39
39
  "lint:fix": "eslint . --ext .ts --fix",
40
+ "release": "bash scripts/release.sh",
40
41
  "format": "prettier --write \"**/*.{js,ts,json,md}\"",
41
42
  "format:check": "prettier --check \"**/*.{js,ts,json,md}\""
42
43
  },
43
44
  "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.24.2",
44
46
  "@types/node-fetch": "^2.6.12",
45
47
  "express": "^5.1.0",
46
48
  "fetch-cookie": "^3.1.0",
@@ -53,9 +55,9 @@
53
55
  "pino-pretty": "^13.0.0",
54
56
  "pkce-challenge": "^5.0.0",
55
57
  "socks-proxy-agent": "^8.0.5",
58
+ "tldts": "^6.1.86",
56
59
  "tough-cookie": "^5.1.2",
57
60
  "zod": "^3.24.2",
58
- "@modelcontextprotocol/sdk": "^1.24.2",
59
61
  "zod-to-json-schema": "3.24.5"
60
62
  },
61
63
  "devDependencies": {