@zereight/mcp-gitlab 2.0.21 → 2.0.23

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