@zereight/mcp-gitlab 2.0.8 → 2.0.11

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/oauth.js ADDED
@@ -0,0 +1,518 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as http from "http";
4
+ import * as net from "net";
5
+ import * as url from "url";
6
+ import open from "open";
7
+ import pkceChallenge from "pkce-challenge";
8
+ import { pino } from "pino";
9
+ const logger = pino({
10
+ name: "gitlab-mcp-oauth",
11
+ level: process.env.LOG_LEVEL || "info",
12
+ });
13
+ // Track pending auth requests across multiple MCP instances
14
+ const pendingAuthRequests = new Map();
15
+ /**
16
+ * Check if a port is already in use
17
+ */
18
+ async function isPortInUse(port) {
19
+ return new Promise((resolve) => {
20
+ const server = net.createServer();
21
+ server.once("error", (err) => {
22
+ if (err.code === "EADDRINUSE") {
23
+ resolve(true);
24
+ }
25
+ else {
26
+ resolve(false);
27
+ }
28
+ });
29
+ server.once("listening", () => {
30
+ server.close();
31
+ resolve(false);
32
+ });
33
+ server.listen(port, "127.0.0.1");
34
+ });
35
+ }
36
+ /**
37
+ * Request authentication from an existing OAuth server
38
+ */
39
+ async function requestAuthFromExistingServer(port, requestId) {
40
+ return new Promise((resolve, reject) => {
41
+ const options = {
42
+ hostname: "127.0.0.1",
43
+ port: port,
44
+ path: `/auth-request?requestId=${requestId}`,
45
+ method: "GET",
46
+ };
47
+ const req = http.request(options, (res) => {
48
+ let data = "";
49
+ res.on("data", (chunk) => {
50
+ data += chunk;
51
+ });
52
+ res.on("end", () => {
53
+ if (res.statusCode === 200) {
54
+ try {
55
+ const tokenData = JSON.parse(data);
56
+ resolve(tokenData);
57
+ }
58
+ catch (error) {
59
+ reject(new Error(`Failed to parse token data: ${error}`));
60
+ }
61
+ }
62
+ else {
63
+ reject(new Error(`Auth request failed with status ${res.statusCode}: ${data}`));
64
+ }
65
+ });
66
+ });
67
+ req.on("error", (error) => {
68
+ reject(new Error(`Failed to connect to existing OAuth server: ${error.message}`));
69
+ });
70
+ req.setTimeout(5 * 60 * 1000, () => {
71
+ req.destroy();
72
+ reject(new Error("Auth request timed out"));
73
+ });
74
+ req.end();
75
+ });
76
+ }
77
+ export class GitLabOAuth {
78
+ config;
79
+ tokenStoragePath;
80
+ codeVerifier;
81
+ codeChallenge;
82
+ constructor(config) {
83
+ this.config = config;
84
+ this.tokenStoragePath =
85
+ config.tokenStoragePath ||
86
+ path.join(process.env.HOME || "", ".gitlab-mcp-token.json");
87
+ }
88
+ /**
89
+ * Get the authorization URL for OAuth flow
90
+ */
91
+ async getAuthorizationUrl(state) {
92
+ const challenge = await pkceChallenge();
93
+ this.codeVerifier = challenge.code_verifier;
94
+ this.codeChallenge = challenge.code_challenge;
95
+ const params = new URLSearchParams();
96
+ params.append("client_id", this.config.clientId);
97
+ params.append("redirect_uri", this.config.redirectUri);
98
+ params.append("response_type", "code");
99
+ params.append("state", state);
100
+ params.append("scope", this.config.scopes.join(" "));
101
+ if (this.codeChallenge) {
102
+ params.append("code_challenge", this.codeChallenge);
103
+ params.append("code_challenge_method", "S256");
104
+ }
105
+ return `${this.config.gitlabUrl}/oauth/authorize?${params.toString()}`;
106
+ }
107
+ /**
108
+ * Exchange authorization code for access token
109
+ */
110
+ async exchangeCodeForToken(code) {
111
+ if (!this.codeVerifier) {
112
+ throw new Error("Code verifier not found. Authorization flow not started.");
113
+ }
114
+ const tokenUrl = `${this.config.gitlabUrl}/oauth/token`;
115
+ const params = new URLSearchParams({
116
+ client_id: this.config.clientId,
117
+ code: code,
118
+ grant_type: "authorization_code",
119
+ redirect_uri: this.config.redirectUri,
120
+ code_verifier: this.codeVerifier,
121
+ });
122
+ const response = await fetch(tokenUrl, {
123
+ method: "POST",
124
+ headers: {
125
+ "Content-Type": "application/x-www-form-urlencoded",
126
+ },
127
+ body: params.toString(),
128
+ });
129
+ if (!response.ok) {
130
+ const errorText = await response.text();
131
+ throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
132
+ }
133
+ const data = await response.json();
134
+ return {
135
+ access_token: data.access_token,
136
+ refresh_token: data.refresh_token,
137
+ expires_in: data.expires_in,
138
+ created_at: Date.now(),
139
+ token_type: data.token_type,
140
+ };
141
+ }
142
+ /**
143
+ * Refresh the access token using the refresh token
144
+ */
145
+ async refreshAccessToken(refreshToken) {
146
+ const tokenUrl = `${this.config.gitlabUrl}/oauth/token`;
147
+ const params = new URLSearchParams({
148
+ client_id: this.config.clientId,
149
+ refresh_token: refreshToken,
150
+ grant_type: "refresh_token",
151
+ redirect_uri: this.config.redirectUri,
152
+ });
153
+ const response = await fetch(tokenUrl, {
154
+ method: "POST",
155
+ headers: {
156
+ "Content-Type": "application/x-www-form-urlencoded",
157
+ },
158
+ body: params.toString(),
159
+ });
160
+ if (!response.ok) {
161
+ const errorText = await response.text();
162
+ throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
163
+ }
164
+ const data = await response.json();
165
+ return {
166
+ access_token: data.access_token,
167
+ refresh_token: data.refresh_token || refreshToken,
168
+ expires_in: data.expires_in,
169
+ created_at: Date.now(),
170
+ token_type: data.token_type,
171
+ };
172
+ }
173
+ /**
174
+ * Save token data to storage
175
+ */
176
+ saveToken(tokenData) {
177
+ try {
178
+ fs.writeFileSync(this.tokenStoragePath, JSON.stringify(tokenData, null, 2), { mode: 0o600 } // Restrict access to owner only
179
+ );
180
+ logger.info(`Token saved to ${this.tokenStoragePath}`);
181
+ }
182
+ catch (error) {
183
+ logger.error("Failed to save token:", error);
184
+ throw error;
185
+ }
186
+ }
187
+ /**
188
+ * Load token data from storage
189
+ */
190
+ loadToken() {
191
+ try {
192
+ if (!fs.existsSync(this.tokenStoragePath)) {
193
+ return null;
194
+ }
195
+ const data = fs.readFileSync(this.tokenStoragePath, "utf8");
196
+ return JSON.parse(data);
197
+ }
198
+ catch (error) {
199
+ logger.error("Failed to load token:", error);
200
+ return null;
201
+ }
202
+ }
203
+ /**
204
+ * Check if the token is expired
205
+ */
206
+ isTokenExpired(tokenData) {
207
+ if (!tokenData.expires_in) {
208
+ return false; // If no expiry, assume it's still valid
209
+ }
210
+ const expiryTime = tokenData.created_at + tokenData.expires_in * 1000;
211
+ // Add 5 minute buffer to refresh before actual expiry
212
+ return Date.now() >= expiryTime - 5 * 60 * 1000;
213
+ }
214
+ /**
215
+ * Start OAuth flow and wait for callback
216
+ * Uses a shared server if port is already in use
217
+ */
218
+ async startOAuthFlow() {
219
+ const callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888");
220
+ const requestId = Math.random().toString(36).substring(7);
221
+ // Check if port is already in use
222
+ const portInUse = await isPortInUse(callbackPort);
223
+ if (portInUse) {
224
+ // Port is in use, try to connect to existing server
225
+ logger.info(`Port ${callbackPort} is already in use. Connecting to existing OAuth server...`);
226
+ try {
227
+ return await requestAuthFromExistingServer(callbackPort, requestId);
228
+ }
229
+ catch (error) {
230
+ logger.error("Failed to connect to existing OAuth server:", error);
231
+ throw new Error(`Port ${callbackPort} is in use but cannot connect to existing OAuth server. Please close other instances or use a different port.`);
232
+ }
233
+ }
234
+ // Port is free, start the shared OAuth server
235
+ return this.startSharedOAuthServer(callbackPort, requestId);
236
+ }
237
+ /**
238
+ * Start a shared OAuth server that can handle multiple authentication requests
239
+ */
240
+ async startSharedOAuthServer(callbackPort, initialRequestId) {
241
+ const stateToRequestId = new Map();
242
+ const requestIdToOAuthInstance = new Map();
243
+ return new Promise((resolve, reject) => {
244
+ // Create initial request
245
+ const state = Math.random().toString(36).substring(7);
246
+ stateToRequestId.set(state, initialRequestId);
247
+ requestIdToOAuthInstance.set(initialRequestId, this);
248
+ const timeout = setTimeout(() => {
249
+ pendingAuthRequests.get(initialRequestId)?.reject(new Error("OAuth flow timed out"));
250
+ pendingAuthRequests.delete(initialRequestId);
251
+ }, 5 * 60 * 1000);
252
+ pendingAuthRequests.set(initialRequestId, { resolve, reject, timeout });
253
+ const server = http.createServer(async (req, res) => {
254
+ try {
255
+ const parsedUrl = url.parse(req.url || "", true);
256
+ // Handle auth requests from other MCP instances
257
+ if (parsedUrl.pathname === "/auth-request") {
258
+ const newRequestId = parsedUrl.query.requestId;
259
+ if (!newRequestId) {
260
+ res.writeHead(400, { "Content-Type": "text/plain" });
261
+ res.end("Missing requestId parameter");
262
+ return;
263
+ }
264
+ logger.info(`Received auth request from another instance: ${newRequestId}`);
265
+ // Create a new OAuth flow for this request
266
+ const newState = Math.random().toString(36).substring(7);
267
+ stateToRequestId.set(newState, newRequestId);
268
+ // Store a reference to use the same OAuth config
269
+ requestIdToOAuthInstance.set(newRequestId, this);
270
+ // Open browser for this new request
271
+ const authUrl = await this.getAuthorizationUrl(newState);
272
+ logger.info("Opening browser for new authentication request...");
273
+ logger.info(`If browser doesn't open, visit: ${authUrl}`);
274
+ open(authUrl).catch((err) => {
275
+ logger.error("Failed to open browser:", err);
276
+ logger.info(`Please manually open: ${authUrl}`);
277
+ });
278
+ // Wait for the auth to complete
279
+ const authPromise = new Promise((authResolve, authReject) => {
280
+ const authTimeout = setTimeout(() => {
281
+ authReject(new Error("OAuth flow timed out"));
282
+ pendingAuthRequests.delete(newRequestId);
283
+ }, 5 * 60 * 1000);
284
+ pendingAuthRequests.set(newRequestId, {
285
+ resolve: authResolve,
286
+ reject: authReject,
287
+ timeout: authTimeout,
288
+ });
289
+ });
290
+ try {
291
+ const tokenData = await authPromise;
292
+ res.writeHead(200, { "Content-Type": "application/json" });
293
+ res.end(JSON.stringify(tokenData));
294
+ }
295
+ catch (error) {
296
+ res.writeHead(500, { "Content-Type": "text/plain" });
297
+ res.end(`Authentication failed: ${error}`);
298
+ }
299
+ return;
300
+ }
301
+ // Handle OAuth callback
302
+ if (parsedUrl.pathname === "/callback") {
303
+ const { code, state: returnedState, error } = parsedUrl.query;
304
+ if (error) {
305
+ res.writeHead(400, { "Content-Type": "text/html" });
306
+ res.end(`
307
+ <html>
308
+ <body>
309
+ <h1>Authentication Failed</h1>
310
+ <p>Error: ${error}</p>
311
+ <p>You can close this window.</p>
312
+ </body>
313
+ </html>
314
+ `);
315
+ // Find and reject the corresponding request
316
+ const reqId = stateToRequestId.get(returnedState);
317
+ if (reqId) {
318
+ const pending = pendingAuthRequests.get(reqId);
319
+ if (pending) {
320
+ clearTimeout(pending.timeout);
321
+ pending.reject(new Error(`OAuth error: ${error}`));
322
+ pendingAuthRequests.delete(reqId);
323
+ }
324
+ }
325
+ return;
326
+ }
327
+ if (!returnedState || typeof returnedState !== "string") {
328
+ res.writeHead(400, { "Content-Type": "text/html" });
329
+ res.end(`
330
+ <html>
331
+ <body>
332
+ <h1>Authentication Failed</h1>
333
+ <p>Invalid state parameter</p>
334
+ <p>You can close this window.</p>
335
+ </body>
336
+ </html>
337
+ `);
338
+ return;
339
+ }
340
+ const reqId = stateToRequestId.get(returnedState);
341
+ if (!reqId) {
342
+ res.writeHead(400, { "Content-Type": "text/html" });
343
+ res.end(`
344
+ <html>
345
+ <body>
346
+ <h1>Authentication Failed</h1>
347
+ <p>Unknown state parameter</p>
348
+ <p>You can close this window.</p>
349
+ </body>
350
+ </html>
351
+ `);
352
+ return;
353
+ }
354
+ if (!code || typeof code !== "string") {
355
+ res.writeHead(400, { "Content-Type": "text/html" });
356
+ res.end(`
357
+ <html>
358
+ <body>
359
+ <h1>Authentication Failed</h1>
360
+ <p>No authorization code received</p>
361
+ <p>You can close this window.</p>
362
+ </body>
363
+ </html>
364
+ `);
365
+ const pending = pendingAuthRequests.get(reqId);
366
+ if (pending) {
367
+ clearTimeout(pending.timeout);
368
+ pending.reject(new Error("No authorization code received"));
369
+ pendingAuthRequests.delete(reqId);
370
+ }
371
+ return;
372
+ }
373
+ try {
374
+ const oauthInstance = requestIdToOAuthInstance.get(reqId) || this;
375
+ const tokenData = await oauthInstance.exchangeCodeForToken(code);
376
+ oauthInstance.saveToken(tokenData);
377
+ res.writeHead(200, { "Content-Type": "text/html" });
378
+ res.end(`
379
+ <html>
380
+ <body>
381
+ <h1>Authentication Successful!</h1>
382
+ <p>You can close this window and return to the terminal.</p>
383
+ </body>
384
+ </html>
385
+ `);
386
+ const pending = pendingAuthRequests.get(reqId);
387
+ if (pending) {
388
+ clearTimeout(pending.timeout);
389
+ pending.resolve(tokenData);
390
+ pendingAuthRequests.delete(reqId);
391
+ }
392
+ stateToRequestId.delete(returnedState);
393
+ requestIdToOAuthInstance.delete(reqId);
394
+ }
395
+ catch (error) {
396
+ res.writeHead(500, { "Content-Type": "text/html" });
397
+ res.end(`
398
+ <html>
399
+ <body>
400
+ <h1>Authentication Failed</h1>
401
+ <p>Failed to exchange code for token</p>
402
+ <p>You can close this window.</p>
403
+ </body>
404
+ </html>
405
+ `);
406
+ const pending = pendingAuthRequests.get(reqId);
407
+ if (pending) {
408
+ clearTimeout(pending.timeout);
409
+ pending.reject(error);
410
+ pendingAuthRequests.delete(reqId);
411
+ }
412
+ }
413
+ }
414
+ else {
415
+ res.writeHead(404, { "Content-Type": "text/plain" });
416
+ res.end("Not Found");
417
+ }
418
+ }
419
+ catch (error) {
420
+ logger.error("Error handling request:", error);
421
+ res.writeHead(500, { "Content-Type": "text/plain" });
422
+ res.end("Internal Server Error");
423
+ }
424
+ });
425
+ server.listen(callbackPort, "127.0.0.1", async () => {
426
+ logger.info(`Shared OAuth callback server listening on port ${callbackPort}`);
427
+ const authUrl = await this.getAuthorizationUrl(state);
428
+ logger.info("Opening browser for authentication...");
429
+ logger.info(`If browser doesn't open, visit: ${authUrl}`);
430
+ open(authUrl).catch((err) => {
431
+ logger.error("Failed to open browser:", err);
432
+ logger.info(`Please manually open: ${authUrl}`);
433
+ });
434
+ });
435
+ server.on("error", (error) => {
436
+ logger.error("OAuth server error:", error);
437
+ const pending = pendingAuthRequests.get(initialRequestId);
438
+ if (pending) {
439
+ clearTimeout(pending.timeout);
440
+ pending.reject(error);
441
+ pendingAuthRequests.delete(initialRequestId);
442
+ }
443
+ });
444
+ });
445
+ }
446
+ /**
447
+ * Get a valid access token, refreshing if necessary
448
+ */
449
+ async getAccessToken() {
450
+ let tokenData = this.loadToken();
451
+ // If no token or expired, start OAuth flow or refresh
452
+ if (!tokenData) {
453
+ logger.info("No stored token found. Starting OAuth flow...");
454
+ tokenData = await this.startOAuthFlow();
455
+ }
456
+ else if (this.isTokenExpired(tokenData)) {
457
+ logger.info("Token expired. Refreshing...");
458
+ if (tokenData.refresh_token) {
459
+ try {
460
+ tokenData = await this.refreshAccessToken(tokenData.refresh_token);
461
+ this.saveToken(tokenData);
462
+ }
463
+ catch (error) {
464
+ logger.error("Token refresh failed. Starting new OAuth flow...", error);
465
+ tokenData = await this.startOAuthFlow();
466
+ }
467
+ }
468
+ else {
469
+ logger.info("No refresh token available. Starting new OAuth flow...");
470
+ tokenData = await this.startOAuthFlow();
471
+ }
472
+ }
473
+ return tokenData.access_token;
474
+ }
475
+ /**
476
+ * Clear stored token
477
+ */
478
+ clearToken() {
479
+ try {
480
+ if (fs.existsSync(this.tokenStoragePath)) {
481
+ fs.unlinkSync(this.tokenStoragePath);
482
+ logger.info("Token cleared");
483
+ }
484
+ }
485
+ catch (error) {
486
+ logger.error("Failed to clear token:", error);
487
+ }
488
+ }
489
+ /**
490
+ * Check if a valid token exists
491
+ */
492
+ hasValidToken() {
493
+ const tokenData = this.loadToken();
494
+ if (!tokenData) {
495
+ return false;
496
+ }
497
+ return !this.isTokenExpired(tokenData);
498
+ }
499
+ }
500
+ /**
501
+ * Initialize OAuth authentication for GitLab MCP server
502
+ */
503
+ export async function initializeOAuth(gitlabUrl = "https://gitlab.com") {
504
+ const clientId = process.env.GITLAB_OAUTH_CLIENT_ID;
505
+ const redirectUri = process.env.GITLAB_OAUTH_REDIRECT_URI || "http://127.0.0.1:8888/callback";
506
+ const tokenStoragePath = process.env.GITLAB_OAUTH_TOKEN_PATH;
507
+ if (!clientId) {
508
+ throw new Error("GITLAB_OAUTH_CLIENT_ID environment variable is required for OAuth authentication");
509
+ }
510
+ const oauth = new GitLabOAuth({
511
+ clientId,
512
+ redirectUri,
513
+ gitlabUrl,
514
+ scopes: ["api"],
515
+ tokenStoragePath,
516
+ });
517
+ return await oauth.getAccessToken();
518
+ }
package/build/schemas.js CHANGED
@@ -852,8 +852,38 @@ export const ListIssueDiscussionsSchema = z
852
852
  export const ListMergeRequestDiscussionsSchema = ProjectParamsSchema.extend({
853
853
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
854
854
  }).merge(PaginationOptionsSchema);
855
- // Input schema for updating a merge request discussion note
855
+ export const GetMergeRequestNotesSchema = ProjectParamsSchema.extend({
856
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
857
+ sort: z.enum(["asc", "desc"]).optional().describe("The sort order of the notes"),
858
+ order_by: z.enum(["created_at", "updated_at"]).optional().describe("The field to sort the notes by"),
859
+ });
860
+ export const GetMergeRequestNoteSchema = ProjectParamsSchema.extend({
861
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
862
+ note_id: z.coerce.string().describe("The ID of a thread note"),
863
+ });
864
+ // Input schema for updating merge request notes
856
865
  export const UpdateMergeRequestNoteSchema = ProjectParamsSchema.extend({
866
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
867
+ note_id: z.coerce.string().describe("The ID of a thread note"),
868
+ body: z.string().describe("The content of the note or reply"),
869
+ });
870
+ // Input schema for adding a note to a merge request
871
+ export const CreateMergeRequestNoteSchema = ProjectParamsSchema.extend({
872
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
873
+ body: z.string().describe("The content of the note or reply"),
874
+ });
875
+ // delete a merge request note
876
+ export const DeleteMergeRequestNoteSchema = ProjectParamsSchema.extend({
877
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
878
+ note_id: z.coerce.string().describe("The ID of a thread note"),
879
+ });
880
+ export const DeleteMergeRequestDiscussionNoteSchema = ProjectParamsSchema.extend({
881
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
882
+ discussion_id: z.coerce.string().describe("The ID of a thread"),
883
+ note_id: z.coerce.string().describe("The ID of a thread note"),
884
+ });
885
+ // Input schema for updating a merge request discussion note
886
+ export const UpdateMergeRequestDiscussionNoteSchema = ProjectParamsSchema.extend({
857
887
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
858
888
  discussion_id: z.coerce.string().describe("The ID of a thread"),
859
889
  note_id: z.coerce.string().describe("The ID of a thread note"),
@@ -867,7 +897,7 @@ export const UpdateMergeRequestNoteSchema = ProjectParamsSchema.extend({
867
897
  message: "Only one of 'body' or 'resolved' can be provided, not both",
868
898
  });
869
899
  // Input schema for adding a note to an existing merge request discussion
870
- export const CreateMergeRequestNoteSchema = ProjectParamsSchema.extend({
900
+ export const CreateMergeRequestDiscussionNoteSchema = ProjectParamsSchema.extend({
871
901
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
872
902
  discussion_id: z.coerce.string().describe("The ID of a thread"),
873
903
  body: z.string().describe("The content of the note or reply"),
@@ -1382,7 +1412,7 @@ export const MergeRequestThreadPositionCreateSchema = z.object({
1382
1412
  old_path: z.string().nullable().optional().describe("File path before changes. REQUIRED for most diff comments. Use same as new_path if file wasn't renamed."),
1383
1413
  new_line: z.number().nullable().optional().describe("Line number in modified file (after changes). Use for added lines or context lines. NULL for deleted lines. For single-line comments on new lines."),
1384
1414
  old_line: z.number().nullable().optional().describe("Line number in original file (before changes). Use for deleted lines or context lines. NULL for added lines. For single-line comments on old lines."),
1385
- line_range: LineRangeSchema.optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
1415
+ 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."),
1386
1416
  width: z.number().optional().describe("IMAGE DIFFS ONLY: Width of the image (for position_type='image')."),
1387
1417
  height: z.number().optional().describe("IMAGE DIFFS ONLY: Height of the image (for position_type='image')."),
1388
1418
  x: z.number().optional().describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
@@ -1421,7 +1451,7 @@ export const MergeRequestThreadPositionSchema = z.object({
1421
1451
  .nullable()
1422
1452
  .optional()
1423
1453
  .describe("Line number in original file (before changes). Use for deleted lines or context lines. NULL for added lines. For single-line comments on old lines."),
1424
- line_range: LineRangeSchema.optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
1454
+ 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."),
1425
1455
  width: z
1426
1456
  .number()
1427
1457
  .optional()
@@ -1504,6 +1534,11 @@ export const CreateMergeRequestThreadSchema = ProjectParamsSchema.extend({
1504
1534
  position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1505
1535
  created_at: z.string().optional().describe("Date the thread was created at (ISO 8601 format)"),
1506
1536
  });
1537
+ export const ResolveMergeRequestThreadSchema = ProjectParamsSchema.extend({
1538
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
1539
+ discussion_id: z.coerce.string().describe("The ID of a thread"),
1540
+ resolved: z.boolean().describe("Whether to resolve the thread"),
1541
+ });
1507
1542
  // Milestone related schemas
1508
1543
  // Schema for listing project milestones
1509
1544
  export const ListProjectMilestonesSchema = ProjectParamsSchema.extend({