@zereight/mcp-gitlab 2.0.7 → 2.0.9

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,6 +4,7 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
+ import { AsyncLocalStorage } from "async_hooks";
7
8
  import express from "express";
8
9
  import fetchCookie from "fetch-cookie";
9
10
  import fs from "fs";
@@ -21,7 +22,7 @@ import { Agent } from "http";
21
22
  import { Agent as HttpsAgent } from "https";
22
23
  import { URL } from "url";
23
24
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
24
- CreateMergeRequestNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, DeleteDraftNoteSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema,
25
+ 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,
25
26
  // pipeline job schemas
26
27
  GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
27
28
  // Discussion Schemas
@@ -29,7 +30,7 @@ GitLabDiscussionNoteSchema, // Added
29
30
  GitLabDiscussionSchema,
30
31
  // Draft Notes Schemas
31
32
  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
32
- 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, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema } from "./schemas.js";
33
+ 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";
33
34
  import { randomUUID } from "crypto";
34
35
  import { pino } from "pino";
35
36
  const logger = pino({
@@ -76,6 +77,71 @@ const server = new Server({
76
77
  tools: {},
77
78
  },
78
79
  });
80
+ /**
81
+ * Validate configuration at startup
82
+ */
83
+ function validateConfiguration() {
84
+ const errors = [];
85
+ // Validate SESSION_TIMEOUT_SECONDS
86
+ const timeoutStr = process.env.SESSION_TIMEOUT_SECONDS;
87
+ if (timeoutStr) {
88
+ const timeout = parseInt(timeoutStr);
89
+ // Allow values >=1 for testing purposes, but recommend 60-86400 for production
90
+ if (isNaN(timeout) || timeout < 1 || timeout > 86400) {
91
+ errors.push(`SESSION_TIMEOUT_SECONDS must be between 1 and 86400 seconds, got: ${timeoutStr}`);
92
+ }
93
+ if (timeout < 60) {
94
+ logger.warn(`SESSION_TIMEOUT_SECONDS=${timeout} is below recommended minimum of 60 seconds. Only use low values for testing.`);
95
+ }
96
+ }
97
+ // Validate MAX_SESSIONS
98
+ const maxSessionsStr = process.env.MAX_SESSIONS;
99
+ if (maxSessionsStr) {
100
+ const maxSessions = parseInt(maxSessionsStr);
101
+ if (isNaN(maxSessions) || maxSessions < 1 || maxSessions > 10000) {
102
+ errors.push(`MAX_SESSIONS must be between 1 and 10000, got: ${maxSessionsStr}`);
103
+ }
104
+ }
105
+ // Validate MAX_REQUESTS_PER_MINUTE
106
+ const maxReqStr = process.env.MAX_REQUESTS_PER_MINUTE;
107
+ if (maxReqStr) {
108
+ const maxReq = parseInt(maxReqStr);
109
+ if (isNaN(maxReq) || maxReq < 1 || maxReq > 1000) {
110
+ errors.push(`MAX_REQUESTS_PER_MINUTE must be between 1 and 1000, got: ${maxReqStr}`);
111
+ }
112
+ }
113
+ // Validate PORT
114
+ const portStr = process.env.PORT;
115
+ if (portStr) {
116
+ const port = parseInt(portStr);
117
+ if (isNaN(port) || port < 1 || port > 65535) {
118
+ errors.push(`PORT must be between 1 and 65535, got: ${portStr}`);
119
+ }
120
+ }
121
+ // Validate GITLAB_API_URL format
122
+ const apiUrl = process.env.GITLAB_API_URL;
123
+ if (apiUrl) {
124
+ try {
125
+ new URL(apiUrl);
126
+ }
127
+ catch (error) {
128
+ errors.push(`GITLAB_API_URL must be a valid URL, got: ${apiUrl}`);
129
+ }
130
+ }
131
+ // Validate auth configuration
132
+ const remoteAuth = process.env.REMOTE_AUTHORIZATION === "true";
133
+ const hasToken = !!process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
134
+ const hasCookie = !!process.env.GITLAB_AUTH_COOKIE_PATH;
135
+ if (!remoteAuth && !hasToken && !hasCookie) {
136
+ errors.push('Either GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_AUTH_COOKIE_PATH, or REMOTE_AUTHORIZATION=true must be set');
137
+ }
138
+ if (errors.length > 0) {
139
+ logger.error('Configuration validation failed:');
140
+ errors.forEach(err => logger.error(` - ${err}`));
141
+ process.exit(1);
142
+ }
143
+ logger.info('Configuration validation passed');
144
+ }
79
145
  const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
80
146
  const GITLAB_AUTH_COOKIE_PATH = process.env.GITLAB_AUTH_COOKIE_PATH;
81
147
  const IS_OLD = process.env.GITLAB_IS_OLD === "true";
@@ -86,6 +152,8 @@ const USE_MILESTONE = process.env.USE_MILESTONE === "true";
86
152
  const USE_PIPELINE = process.env.USE_PIPELINE === "true";
87
153
  const SSE = process.env.SSE === "true";
88
154
  const STREAMABLE_HTTP = process.env.STREAMABLE_HTTP === "true";
155
+ const REMOTE_AUTHORIZATION = process.env.REMOTE_AUTHORIZATION === "true";
156
+ const SESSION_TIMEOUT_SECONDS = process.env.SESSION_TIMEOUT_SECONDS ? parseInt(process.env.SESSION_TIMEOUT_SECONDS) : 3600;
89
157
  const HOST = process.env.HOST || "0.0.0.0";
90
158
  const PORT = process.env.PORT || 3002;
91
159
  // Add proxy configuration
@@ -192,20 +260,41 @@ async function ensureSessionForRequest() {
192
260
  }
193
261
  }
194
262
  }
195
- // Modify DEFAULT_HEADERS to include agent configuration
196
- const DEFAULT_HEADERS = {
263
+ const sessionAuthStore = new AsyncLocalStorage();
264
+ // Base headers without authentication
265
+ const BASE_HEADERS = {
197
266
  Accept: "application/json",
198
267
  "Content-Type": "application/json",
199
268
  };
200
- if (IS_OLD) {
201
- DEFAULT_HEADERS["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`;
202
- }
203
- else {
204
- DEFAULT_HEADERS["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`;
269
+ /**
270
+ * Build authentication headers dynamically based on context
271
+ * In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
272
+ * Otherwise, uses environment token
273
+ */
274
+ function buildAuthHeaders() {
275
+ if (REMOTE_AUTHORIZATION) {
276
+ const ctx = sessionAuthStore.getStore();
277
+ if (ctx && ctx.token) {
278
+ return {
279
+ [ctx.header]: ctx.header === 'Authorization' ? `Bearer ${ctx.token}` : ctx.token
280
+ };
281
+ }
282
+ return {}; // No auth headers if no session context
283
+ }
284
+ // Standard mode: use environment token
285
+ if (IS_OLD && GITLAB_PERSONAL_ACCESS_TOKEN) {
286
+ return { 'Private-Token': String(GITLAB_PERSONAL_ACCESS_TOKEN) };
287
+ }
288
+ if (GITLAB_PERSONAL_ACCESS_TOKEN) {
289
+ return { Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}` };
290
+ }
291
+ return {};
205
292
  }
206
293
  // Create a default fetch configuration object that includes proxy agents if set
207
294
  const DEFAULT_FETCH_CONFIG = {
208
- headers: DEFAULT_HEADERS,
295
+ get headers() {
296
+ return { ...BASE_HEADERS, ...buildAuthHeaders() };
297
+ },
209
298
  agent: (parsedUrl) => {
210
299
  if (parsedUrl.protocol === "https:") {
211
300
  return httpsAgent;
@@ -306,21 +395,56 @@ const allTools = [
306
395
  description: "Create a new thread on a merge request",
307
396
  inputSchema: toJSONSchema(CreateMergeRequestThreadSchema),
308
397
  },
398
+ {
399
+ name: 'resolve_merge_request_thread',
400
+ description: "Resolve a thread on a merge request",
401
+ inputSchema: toJSONSchema(ResolveMergeRequestThreadSchema),
402
+ },
309
403
  {
310
404
  name: "mr_discussions",
311
405
  description: "List discussion items for a merge request",
312
406
  inputSchema: toJSONSchema(ListMergeRequestDiscussionsSchema),
313
407
  },
314
408
  {
315
- name: "update_merge_request_note",
316
- description: "Modify an existing merge request thread note",
317
- inputSchema: toJSONSchema(UpdateMergeRequestNoteSchema),
409
+ name: "delete_merge_request_discussion_note",
410
+ description: "Delete a discussion note on a merge request",
411
+ inputSchema: toJSONSchema(DeleteMergeRequestDiscussionNoteSchema),
412
+ },
413
+ {
414
+ name: "update_merge_request_discussion_note",
415
+ description: "Update a discussion note on a merge request",
416
+ inputSchema: toJSONSchema(UpdateMergeRequestDiscussionNoteSchema),
417
+ },
418
+ {
419
+ name: "create_merge_request_discussion_note",
420
+ description: "Add a new discussion note to an existing merge request thread",
421
+ inputSchema: toJSONSchema(CreateMergeRequestDiscussionNoteSchema),
318
422
  },
319
423
  {
320
424
  name: "create_merge_request_note",
321
- description: "Add a new note to an existing merge request thread",
425
+ description: "Add a new note to a merge request",
322
426
  inputSchema: toJSONSchema(CreateMergeRequestNoteSchema),
323
427
  },
428
+ {
429
+ name: "delete_merge_request_note",
430
+ description: "Delete an existing merge request note",
431
+ inputSchema: toJSONSchema(DeleteMergeRequestNoteSchema),
432
+ },
433
+ {
434
+ name: "get_merge_request_note",
435
+ description: "Get a specific note for a merge request",
436
+ inputSchema: toJSONSchema(GetMergeRequestNoteSchema),
437
+ },
438
+ {
439
+ name: 'get_merge_request_notes',
440
+ description: "List notes for a merge request",
441
+ inputSchema: toJSONSchema(GetMergeRequestNotesSchema),
442
+ },
443
+ {
444
+ name: "update_merge_request_note",
445
+ description: "Modify an existing merge request note",
446
+ inputSchema: toJSONSchema(UpdateMergeRequestNoteSchema),
447
+ },
324
448
  {
325
449
  name: "get_draft_note",
326
450
  description: "Get a single draft note from a merge request",
@@ -661,6 +785,41 @@ const allTools = [
661
785
  description: "List all visible events for a specified project. Note: before/after parameters accept date format YYYY-MM-DD only",
662
786
  inputSchema: toJSONSchema(GetProjectEventsSchema),
663
787
  },
788
+ {
789
+ name: "list_releases",
790
+ description: "List all releases for a project",
791
+ inputSchema: toJSONSchema(ListReleasesSchema),
792
+ },
793
+ {
794
+ name: "get_release",
795
+ description: "Get a release by tag name",
796
+ inputSchema: toJSONSchema(GetReleaseSchema),
797
+ },
798
+ {
799
+ name: "create_release",
800
+ description: "Create a new release in a GitLab project",
801
+ inputSchema: toJSONSchema(CreateReleaseSchema),
802
+ },
803
+ {
804
+ name: "update_release",
805
+ description: "Update an existing release in a GitLab project",
806
+ inputSchema: toJSONSchema(UpdateReleaseSchema),
807
+ },
808
+ {
809
+ name: "delete_release",
810
+ description: "Delete a release from a GitLab project (does not delete the associated tag)",
811
+ inputSchema: toJSONSchema(DeleteReleaseSchema),
812
+ },
813
+ {
814
+ name: "create_release_evidence",
815
+ description: "Create release evidence for an existing release (GitLab Premium/Ultimate only)",
816
+ inputSchema: toJSONSchema(CreateReleaseEvidenceSchema),
817
+ },
818
+ {
819
+ name: "download_release_asset",
820
+ description: "Download a release asset file by direct asset path",
821
+ inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
822
+ },
664
823
  ];
665
824
  // Define which tools are read-only
666
825
  const readOnlyTools = [
@@ -710,6 +869,9 @@ const readOnlyTools = [
710
869
  "download_attachment",
711
870
  "list_events",
712
871
  "get_project_events",
872
+ "list_releases",
873
+ "get_release",
874
+ "download_release_asset",
713
875
  ];
714
876
  // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
715
877
  const wikiToolNames = [
@@ -771,9 +933,27 @@ const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || "");
771
933
  const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
772
934
  const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(',').map(id => id.trim()).filter(Boolean) || [];
773
935
  const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE ? parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE) : 20;
774
- if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
775
- logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
776
- process.exit(1);
936
+ // Validate authentication configuration
937
+ if (REMOTE_AUTHORIZATION) {
938
+ // Remote authorization mode: token comes from HTTP headers
939
+ if (SSE) {
940
+ logger.error("REMOTE_AUTHORIZATION=true is not compatible with SSE transport mode");
941
+ logger.error("Please use STREAMABLE_HTTP=true instead");
942
+ process.exit(1);
943
+ }
944
+ if (!STREAMABLE_HTTP) {
945
+ logger.error("REMOTE_AUTHORIZATION=true requires STREAMABLE_HTTP=true");
946
+ logger.error("Set STREAMABLE_HTTP=true to enable remote authorization");
947
+ process.exit(1);
948
+ }
949
+ logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
950
+ }
951
+ else {
952
+ // Standard mode: token must be in environment
953
+ if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
954
+ logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
955
+ process.exit(1);
956
+ }
777
957
  }
778
958
  /**
779
959
  * Utility function for handling GitLab API errors
@@ -1284,6 +1464,18 @@ async function listMergeRequestDiscussions(projectId, mergeRequestIid, options =
1284
1464
  async function listIssueDiscussions(projectId, issueIid, options = {}) {
1285
1465
  return listDiscussions(projectId, "issues", issueIid, options);
1286
1466
  }
1467
+ async function deleteMergeRequestDiscussionNote(projectId, mergeRequestIid, discussionId, noteId) {
1468
+ projectId = decodeURIComponent(projectId); // Decode project ID
1469
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}`);
1470
+ const response = await fetch(url.toString(), {
1471
+ ...DEFAULT_FETCH_CONFIG,
1472
+ method: "DELETE",
1473
+ });
1474
+ if (!response.ok) {
1475
+ const errorText = await response.text();
1476
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1477
+ }
1478
+ }
1287
1479
  /**
1288
1480
  * Modify an existing merge request thread note
1289
1481
  * 병합 요청 토론 노트 수정
@@ -1296,7 +1488,7 @@ async function listIssueDiscussions(projectId, issueIid, options = {}) {
1296
1488
  * @param {boolean} [resolved] - Resolve/unresolve state
1297
1489
  * @returns {Promise<GitLabDiscussionNote>} The updated note
1298
1490
  */
1299
- async function updateMergeRequestNote(projectId, mergeRequestIid, discussionId, noteId, body, resolved) {
1491
+ async function updateMergeRequestDiscussionNote(projectId, mergeRequestIid, discussionId, noteId, body, resolved) {
1300
1492
  projectId = decodeURIComponent(projectId); // Decode project ID
1301
1493
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}`);
1302
1494
  // Only one of body or resolved can be sent according to GitLab API
@@ -1364,7 +1556,7 @@ async function createIssueNote(projectId, issueIid, discussionId, body, createdA
1364
1556
  return GitLabDiscussionNoteSchema.parse(data);
1365
1557
  }
1366
1558
  /**
1367
- * Add a new note to an existing merge request thread
1559
+ * Add a new discussion note to an existing merge request thread
1368
1560
  * 기존 병합 요청 스레드에 새 노트 추가
1369
1561
  *
1370
1562
  * @param {string} projectId - The ID or URL-encoded path of the project
@@ -1374,7 +1566,7 @@ async function createIssueNote(projectId, issueIid, discussionId, body, createdA
1374
1566
  * @param {string} [createdAt] - The creation date of the note (ISO 8601 format)
1375
1567
  * @returns {Promise<GitLabDiscussionNote>} The created note
1376
1568
  */
1377
- async function createMergeRequestNote(projectId, mergeRequestIid, discussionId, body, createdAt) {
1569
+ async function createMergeRequestDiscussionNote(projectId, mergeRequestIid, discussionId, body, createdAt) {
1378
1570
  projectId = decodeURIComponent(projectId); // Decode project ID
1379
1571
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes`);
1380
1572
  const payload = { body };
@@ -1390,6 +1582,81 @@ async function createMergeRequestNote(projectId, mergeRequestIid, discussionId,
1390
1582
  const data = await response.json();
1391
1583
  return GitLabDiscussionNoteSchema.parse(data);
1392
1584
  }
1585
+ async function createMergeRequestNote(projectId, mergeRequestIid, body) {
1586
+ projectId = decodeURIComponent(projectId); // Decode project ID
1587
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes`);
1588
+ const payload = {
1589
+ id: projectId,
1590
+ merge_request_iid: mergeRequestIid,
1591
+ body,
1592
+ };
1593
+ const response = await fetch(url.toString(), {
1594
+ ...DEFAULT_FETCH_CONFIG,
1595
+ method: "POST",
1596
+ body: JSON.stringify(payload),
1597
+ });
1598
+ await handleGitLabError(response);
1599
+ const data = await response.json();
1600
+ return GitLabDiscussionNoteSchema.parse(data);
1601
+ }
1602
+ async function deleteMergeRequestNote(projectId, mergeRequestIid, noteId) {
1603
+ projectId = decodeURIComponent(projectId); // Decode project ID
1604
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes/${noteId}`);
1605
+ const response = await fetch(url.toString(), {
1606
+ ...DEFAULT_FETCH_CONFIG,
1607
+ method: "DELETE",
1608
+ });
1609
+ if (!response.ok) {
1610
+ const errorText = await response.text();
1611
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1612
+ }
1613
+ }
1614
+ async function getMergeRequestNote(projectId, mergeRequestIid, noteId) {
1615
+ projectId = decodeURIComponent(projectId); // Decode project ID
1616
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes/${noteId}`);
1617
+ const response = await fetch(url.toString(), {
1618
+ ...DEFAULT_FETCH_CONFIG,
1619
+ method: "GET",
1620
+ });
1621
+ await handleGitLabError(response);
1622
+ const data = await response.json();
1623
+ return GitLabDiscussionNoteSchema.parse(data);
1624
+ }
1625
+ async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by) {
1626
+ projectId = decodeURIComponent(projectId); // Decode project ID
1627
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes`);
1628
+ if (sort) {
1629
+ url.searchParams.append("sort", sort);
1630
+ }
1631
+ if (order_by) {
1632
+ url.searchParams.append("order_by", order_by);
1633
+ }
1634
+ const response = await fetch(url.toString(), {
1635
+ ...DEFAULT_FETCH_CONFIG,
1636
+ method: "GET",
1637
+ });
1638
+ await handleGitLabError(response);
1639
+ const data = await response.json();
1640
+ return z.array(GitLabDiscussionNoteSchema).parse(data);
1641
+ }
1642
+ async function updateMergeRequestNote(projectId, mergeRequestIid, noteId, body) {
1643
+ projectId = decodeURIComponent(projectId); // Decode project ID
1644
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes/${noteId}`);
1645
+ const payload = {
1646
+ id: projectId,
1647
+ merge_request_iid: mergeRequestIid,
1648
+ note_id: noteId,
1649
+ body,
1650
+ };
1651
+ const response = await fetch(url.toString(), {
1652
+ ...DEFAULT_FETCH_CONFIG,
1653
+ method: "PUT",
1654
+ body: JSON.stringify(payload),
1655
+ });
1656
+ await handleGitLabError(response);
1657
+ const data = await response.json();
1658
+ return GitLabDiscussionNoteSchema.parse(data);
1659
+ }
1393
1660
  /**
1394
1661
  * Create or update a file in a GitLab project
1395
1662
  * 파일 생성 또는 업데이트
@@ -2000,6 +2267,21 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
2000
2267
  return [];
2001
2268
  }
2002
2269
  }
2270
+ async function resolveMergeRequestThread(projectId, mergeRequestIid, discussionId, resolved) {
2271
+ projectId = decodeURIComponent(projectId);
2272
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions/${discussionId}`);
2273
+ if (resolved !== undefined) {
2274
+ url.searchParams.append("resolved", resolved ? "true" : "false");
2275
+ }
2276
+ const response = await fetch(url.toString(), {
2277
+ ...DEFAULT_FETCH_CONFIG,
2278
+ method: "PUT",
2279
+ });
2280
+ if (!response.ok) {
2281
+ const errorText = await response.text();
2282
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
2283
+ }
2284
+ }
2003
2285
  /**
2004
2286
  * Create a new thread on a merge request
2005
2287
  * 📦 새로운 함수: createMergeRequestThread - 병합 요청에 새로운 스레드(토론)를 생성하는 함수
@@ -2539,7 +2821,8 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
2539
2821
  const response = await fetch(url.toString(), {
2540
2822
  ...DEFAULT_FETCH_CONFIG,
2541
2823
  headers: {
2542
- ...DEFAULT_HEADERS,
2824
+ ...BASE_HEADERS,
2825
+ ...buildAuthHeaders(),
2543
2826
  Accept: "text/plain", // Override Accept header to get plain text
2544
2827
  },
2545
2828
  });
@@ -2587,7 +2870,10 @@ async function createPipeline(projectId, ref, variables) {
2587
2870
  }
2588
2871
  const response = await fetch(url.toString(), {
2589
2872
  method: "POST",
2590
- headers: DEFAULT_HEADERS,
2873
+ headers: {
2874
+ ...BASE_HEADERS,
2875
+ ...buildAuthHeaders(),
2876
+ },
2591
2877
  body: JSON.stringify(body),
2592
2878
  });
2593
2879
  await handleGitLabError(response);
@@ -2606,7 +2892,10 @@ async function retryPipeline(projectId, pipelineId) {
2606
2892
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
2607
2893
  const response = await fetch(url.toString(), {
2608
2894
  method: "POST",
2609
- headers: DEFAULT_HEADERS,
2895
+ headers: {
2896
+ ...BASE_HEADERS,
2897
+ ...buildAuthHeaders(),
2898
+ },
2610
2899
  });
2611
2900
  await handleGitLabError(response);
2612
2901
  const data = await response.json();
@@ -2624,7 +2913,10 @@ async function cancelPipeline(projectId, pipelineId) {
2624
2913
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
2625
2914
  const response = await fetch(url.toString(), {
2626
2915
  method: "POST",
2627
- headers: DEFAULT_HEADERS,
2916
+ headers: {
2917
+ ...BASE_HEADERS,
2918
+ ...buildAuthHeaders(),
2919
+ },
2628
2920
  });
2629
2921
  await handleGitLabError(response);
2630
2922
  const data = await response.json();
@@ -2716,14 +3008,9 @@ async function getRepositoryTree(options) {
2716
3008
  if (options.pagination)
2717
3009
  queryParams.append("pagination", options.pagination);
2718
3010
  const headers = {
2719
- "Content-Type": "application/json",
3011
+ ...BASE_HEADERS,
3012
+ ...buildAuthHeaders(),
2720
3013
  };
2721
- if (IS_OLD) {
2722
- headers["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`;
2723
- }
2724
- else {
2725
- headers["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`;
2726
- }
2727
3014
  const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
2728
3015
  headers,
2729
3016
  });
@@ -3189,7 +3476,8 @@ async function markdownUpload(projectId, filePath) {
3189
3476
  const response = await fetch(url.toString(), {
3190
3477
  method: "POST",
3191
3478
  headers: {
3192
- ...DEFAULT_HEADERS,
3479
+ ...BASE_HEADERS,
3480
+ ...buildAuthHeaders(),
3193
3481
  // Remove Content-Type header to let form-data set it with boundary
3194
3482
  "Content-Type": undefined,
3195
3483
  },
@@ -3206,7 +3494,10 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
3206
3494
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
3207
3495
  const response = await fetch(url.toString(), {
3208
3496
  method: "GET",
3209
- headers: DEFAULT_HEADERS,
3497
+ headers: {
3498
+ ...BASE_HEADERS,
3499
+ ...buildAuthHeaders(),
3500
+ },
3210
3501
  });
3211
3502
  if (!response.ok) {
3212
3503
  await handleGitLabError(response);
@@ -3234,7 +3525,10 @@ async function listEvents(options = {}) {
3234
3525
  });
3235
3526
  const response = await fetch(url.toString(), {
3236
3527
  method: "GET",
3237
- headers: DEFAULT_HEADERS,
3528
+ headers: {
3529
+ ...BASE_HEADERS,
3530
+ ...buildAuthHeaders(),
3531
+ },
3238
3532
  });
3239
3533
  if (!response.ok) {
3240
3534
  await handleGitLabError(response);
@@ -3259,7 +3553,10 @@ async function getProjectEvents(projectId, options = {}) {
3259
3553
  });
3260
3554
  const response = await fetch(url.toString(), {
3261
3555
  method: "GET",
3262
- headers: DEFAULT_HEADERS,
3556
+ headers: {
3557
+ ...BASE_HEADERS,
3558
+ ...buildAuthHeaders(),
3559
+ },
3263
3560
  });
3264
3561
  if (!response.ok) {
3265
3562
  await handleGitLabError(response);
@@ -3267,6 +3564,134 @@ async function getProjectEvents(projectId, options = {}) {
3267
3564
  const data = await response.json();
3268
3565
  return GitLabEventSchema.array().parse(data);
3269
3566
  }
3567
+ /**
3568
+ * List all releases for a project
3569
+ *
3570
+ * @param projectId The ID or URL-encoded path of the project
3571
+ * @param options Optional parameters for listing releases
3572
+ * @returns Array of GitLab releases
3573
+ */
3574
+ async function listReleases(projectId, options = {}) {
3575
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3576
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases`);
3577
+ // Add query parameters
3578
+ Object.entries(options).forEach(([key, value]) => {
3579
+ if (value !== undefined) {
3580
+ url.searchParams.append(key, value.toString());
3581
+ }
3582
+ });
3583
+ const response = await fetch(url.toString(), {
3584
+ ...DEFAULT_FETCH_CONFIG,
3585
+ });
3586
+ await handleGitLabError(response);
3587
+ const data = await response.json();
3588
+ return GitLabReleaseSchema.array().parse(data);
3589
+ }
3590
+ /**
3591
+ * Get a release by tag name
3592
+ *
3593
+ * @param projectId The ID or URL-encoded path of the project
3594
+ * @param tagName The Git tag the release is associated with
3595
+ * @param includeHtmlDescription If true, includes HTML rendered Markdown
3596
+ * @returns GitLab release
3597
+ */
3598
+ async function getRelease(projectId, tagName, includeHtmlDescription) {
3599
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3600
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`);
3601
+ if (includeHtmlDescription !== undefined) {
3602
+ url.searchParams.append("include_html_description", includeHtmlDescription.toString());
3603
+ }
3604
+ const response = await fetch(url.toString(), {
3605
+ ...DEFAULT_FETCH_CONFIG,
3606
+ });
3607
+ await handleGitLabError(response);
3608
+ const data = await response.json();
3609
+ return GitLabReleaseSchema.parse(data);
3610
+ }
3611
+ /**
3612
+ * Create a new release
3613
+ *
3614
+ * @param projectId The ID or URL-encoded path of the project
3615
+ * @param options Options for creating the release
3616
+ * @returns Created GitLab release
3617
+ */
3618
+ async function createRelease(projectId, options) {
3619
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3620
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases`, {
3621
+ ...DEFAULT_FETCH_CONFIG,
3622
+ method: "POST",
3623
+ body: JSON.stringify(options),
3624
+ });
3625
+ await handleGitLabError(response);
3626
+ const data = await response.json();
3627
+ return GitLabReleaseSchema.parse(data);
3628
+ }
3629
+ /**
3630
+ * Update an existing release
3631
+ *
3632
+ * @param projectId The ID or URL-encoded path of the project
3633
+ * @param tagName The Git tag the release is associated with
3634
+ * @param options Options for updating the release
3635
+ * @returns Updated GitLab release
3636
+ */
3637
+ async function updateRelease(projectId, tagName, options) {
3638
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3639
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`, {
3640
+ ...DEFAULT_FETCH_CONFIG,
3641
+ method: "PUT",
3642
+ body: JSON.stringify(options),
3643
+ });
3644
+ await handleGitLabError(response);
3645
+ const data = await response.json();
3646
+ return GitLabReleaseSchema.parse(data);
3647
+ }
3648
+ /**
3649
+ * Delete a release
3650
+ *
3651
+ * @param projectId The ID or URL-encoded path of the project
3652
+ * @param tagName The Git tag the release is associated with
3653
+ * @returns Deleted GitLab release
3654
+ */
3655
+ async function deleteRelease(projectId, tagName) {
3656
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3657
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`, {
3658
+ ...DEFAULT_FETCH_CONFIG,
3659
+ method: "DELETE",
3660
+ });
3661
+ await handleGitLabError(response);
3662
+ const data = await response.json();
3663
+ return GitLabReleaseSchema.parse(data);
3664
+ }
3665
+ /**
3666
+ * Create release evidence (GitLab Premium/Ultimate only)
3667
+ *
3668
+ * @param projectId The ID or URL-encoded path of the project
3669
+ * @param tagName The Git tag the release is associated with
3670
+ */
3671
+ async function createReleaseEvidence(projectId, tagName) {
3672
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3673
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}/evidence`, {
3674
+ ...DEFAULT_FETCH_CONFIG,
3675
+ method: "POST",
3676
+ });
3677
+ await handleGitLabError(response);
3678
+ }
3679
+ /**
3680
+ * Download a release asset
3681
+ *
3682
+ * @param projectId The ID or URL-encoded path of the project
3683
+ * @param tagName The Git tag the release is associated with
3684
+ * @param directAssetPath Path to the release asset file
3685
+ * @returns The asset file content
3686
+ */
3687
+ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
3688
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3689
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}/downloads/${directAssetPath}`, {
3690
+ ...DEFAULT_FETCH_CONFIG,
3691
+ });
3692
+ await handleGitLabError(response);
3693
+ return await response.text();
3694
+ }
3270
3695
  server.setRequestHandler(ListToolsRequestSchema, async () => {
3271
3696
  // Apply read-only filter first
3272
3697
  const tools0 = GITLAB_READ_ONLY_MODE
@@ -3332,9 +3757,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3332
3757
  ...DEFAULT_FETCH_CONFIG,
3333
3758
  method: "POST",
3334
3759
  headers: {
3335
- ...DEFAULT_HEADERS,
3336
- "Content-Type": "application/json",
3337
- Accept: "application/json",
3760
+ ...BASE_HEADERS,
3761
+ ...buildAuthHeaders(),
3338
3762
  },
3339
3763
  body: JSON.stringify({ query: args.query, variables: args.variables || {} }),
3340
3764
  signal: controller.signal,
@@ -3475,18 +3899,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3475
3899
  content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
3476
3900
  };
3477
3901
  }
3478
- case "update_merge_request_note": {
3479
- const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments);
3480
- const note = await updateMergeRequestNote(args.project_id, args.merge_request_iid, args.discussion_id, args.note_id, args.body, // Now optional
3902
+ case "delete_merge_request_discussion_note": {
3903
+ const args = DeleteMergeRequestDiscussionNoteSchema.parse(request.params.arguments);
3904
+ const { project_id, merge_request_iid, discussion_id, note_id } = args;
3905
+ await deleteMergeRequestDiscussionNote(project_id, merge_request_iid, discussion_id, note_id);
3906
+ return {
3907
+ content: [{ type: "text", text: "Merge request discussion note deleted successfully" }],
3908
+ };
3909
+ }
3910
+ case "update_merge_request_discussion_note": {
3911
+ const args = UpdateMergeRequestDiscussionNoteSchema.parse(request.params.arguments);
3912
+ const note = await updateMergeRequestDiscussionNote(args.project_id, args.merge_request_iid, args.discussion_id, args.note_id, args.body, // Now optional
3481
3913
  args.resolved // Now one of body or resolved must be provided, not both
3482
3914
  );
3483
3915
  return {
3484
3916
  content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3485
3917
  };
3486
3918
  }
3919
+ case "create_merge_request_discussion_note": {
3920
+ const args = CreateMergeRequestDiscussionNoteSchema.parse(request.params.arguments);
3921
+ const note = await createMergeRequestDiscussionNote(args.project_id, args.merge_request_iid, args.discussion_id, args.body, args.created_at);
3922
+ return {
3923
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3924
+ };
3925
+ }
3487
3926
  case "create_merge_request_note": {
3488
3927
  const args = CreateMergeRequestNoteSchema.parse(request.params.arguments);
3489
- const note = await createMergeRequestNote(args.project_id, args.merge_request_iid, args.discussion_id, args.body, args.created_at);
3928
+ const note = await createMergeRequestNote(args.project_id, args.merge_request_iid, args.body);
3929
+ return {
3930
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3931
+ };
3932
+ }
3933
+ case "delete_merge_request_note": {
3934
+ const args = DeleteMergeRequestNoteSchema.parse(request.params.arguments);
3935
+ await deleteMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id);
3936
+ return {
3937
+ content: [{ type: "text", text: "Merge request note deleted successfully" }],
3938
+ };
3939
+ }
3940
+ case 'get_merge_request_note': {
3941
+ const args = GetMergeRequestNoteSchema.parse(request.params.arguments);
3942
+ const note = await getMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id);
3943
+ return {
3944
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3945
+ };
3946
+ }
3947
+ case 'get_merge_request_notes': {
3948
+ const args = GetMergeRequestNotesSchema.parse(request.params.arguments);
3949
+ const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
3950
+ return {
3951
+ content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
3952
+ };
3953
+ }
3954
+ case 'update_merge_request_note': {
3955
+ const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments);
3956
+ const note = await updateMergeRequestNote(args.project_id, args.merge_request_iid, args.note_id, args.body);
3490
3957
  return {
3491
3958
  content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3492
3959
  };
@@ -3708,6 +4175,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3708
4175
  content: [{ type: "text", text: JSON.stringify(thread, null, 2) }],
3709
4176
  };
3710
4177
  }
4178
+ case "resolve_merge_request_thread": {
4179
+ const args = ResolveMergeRequestThreadSchema.parse(request.params.arguments);
4180
+ const { project_id, merge_request_iid, discussion_id, resolved } = args;
4181
+ await resolveMergeRequestThread(project_id, merge_request_iid, discussion_id, resolved);
4182
+ return {
4183
+ content: [{ type: "text", text: "Thread resolved successfully" }],
4184
+ };
4185
+ }
3711
4186
  case "list_issues": {
3712
4187
  const args = ListIssuesSchema.parse(request.params.arguments);
3713
4188
  const { project_id, ...options } = args;
@@ -4211,6 +4686,68 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4211
4686
  content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
4212
4687
  };
4213
4688
  }
4689
+ case "list_releases": {
4690
+ const args = ListReleasesSchema.parse(request.params.arguments);
4691
+ const { project_id, ...options } = args;
4692
+ const releases = await listReleases(project_id, options);
4693
+ return {
4694
+ content: [{ type: "text", text: JSON.stringify(releases, null, 2) }],
4695
+ };
4696
+ }
4697
+ case "get_release": {
4698
+ const args = GetReleaseSchema.parse(request.params.arguments);
4699
+ const release = await getRelease(args.project_id, args.tag_name, args.include_html_description);
4700
+ return {
4701
+ content: [{ type: "text", text: JSON.stringify(release, null, 2) }],
4702
+ };
4703
+ }
4704
+ case "create_release": {
4705
+ const args = CreateReleaseSchema.parse(request.params.arguments);
4706
+ const { project_id, ...options } = args;
4707
+ const release = await createRelease(project_id, options);
4708
+ return {
4709
+ content: [{ type: "text", text: JSON.stringify(release, null, 2) }],
4710
+ };
4711
+ }
4712
+ case "update_release": {
4713
+ const args = UpdateReleaseSchema.parse(request.params.arguments);
4714
+ const { project_id, tag_name, ...options } = args;
4715
+ const release = await updateRelease(project_id, tag_name, options);
4716
+ return {
4717
+ content: [{ type: "text", text: JSON.stringify(release, null, 2) }],
4718
+ };
4719
+ }
4720
+ case "delete_release": {
4721
+ const args = DeleteReleaseSchema.parse(request.params.arguments);
4722
+ const release = await deleteRelease(args.project_id, args.tag_name);
4723
+ return {
4724
+ content: [
4725
+ {
4726
+ type: "text",
4727
+ text: JSON.stringify({ status: "success", message: "Release deleted successfully", release }, null, 2),
4728
+ },
4729
+ ],
4730
+ };
4731
+ }
4732
+ case "create_release_evidence": {
4733
+ const args = CreateReleaseEvidenceSchema.parse(request.params.arguments);
4734
+ await createReleaseEvidence(args.project_id, args.tag_name);
4735
+ return {
4736
+ content: [
4737
+ {
4738
+ type: "text",
4739
+ text: JSON.stringify({ status: "success", message: "Release evidence created successfully" }, null, 2),
4740
+ },
4741
+ ],
4742
+ };
4743
+ }
4744
+ case "download_release_asset": {
4745
+ const args = DownloadReleaseAssetSchema.parse(request.params.arguments);
4746
+ const assetContent = await downloadReleaseAsset(args.project_id, args.tag_name, args.direct_asset_path);
4747
+ return {
4748
+ content: [{ type: "text", text: assetContent }],
4749
+ };
4750
+ }
4214
4751
  default:
4215
4752
  throw new Error(`Unknown tool: ${request.params.name}`);
4216
4753
  }
@@ -4301,48 +4838,264 @@ async function startSSEServer() {
4301
4838
  async function startStreamableHTTPServer() {
4302
4839
  const app = express();
4303
4840
  const streamableTransports = {};
4841
+ // Session-based auth mapping for remote authorization
4842
+ const authBySession = {};
4843
+ const authTimeouts = {};
4844
+ // Configuration and limits
4845
+ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '1000');
4846
+ const MAX_REQUESTS_PER_MINUTE = parseInt(process.env.MAX_REQUESTS_PER_MINUTE || '60');
4847
+ // Metrics tracking
4848
+ const metrics = {
4849
+ activeSessions: 0,
4850
+ totalSessions: 0,
4851
+ expiredSessions: 0,
4852
+ authFailures: 0,
4853
+ requestsProcessed: 0,
4854
+ rejectedByRateLimit: 0,
4855
+ rejectedByCapacity: 0,
4856
+ };
4857
+ // Rate limiting per session
4858
+ const sessionRequestCounts = {};
4859
+ /**
4860
+ * Validate token format and length
4861
+ */
4862
+ const validateToken = (token) => {
4863
+ // GitLab PAT format: glpat-xxxxx (min 20 chars)
4864
+ if (token.length < 20)
4865
+ return false;
4866
+ if (!/^[a-zA-Z0-9_\.-]+$/.test(token))
4867
+ return false;
4868
+ return true;
4869
+ };
4870
+ /**
4871
+ * Check rate limit for session
4872
+ */
4873
+ const checkRateLimit = (sessionId) => {
4874
+ const now = Date.now();
4875
+ const session = sessionRequestCounts[sessionId];
4876
+ if (!session || now > session.resetAt) {
4877
+ sessionRequestCounts[sessionId] = { count: 1, resetAt: now + 60000 };
4878
+ return true;
4879
+ }
4880
+ if (session.count >= MAX_REQUESTS_PER_MINUTE) {
4881
+ return false;
4882
+ }
4883
+ session.count++;
4884
+ return true;
4885
+ };
4886
+ /**
4887
+ * Parse authentication from request headers
4888
+ * Returns null if no auth found or invalid format
4889
+ */
4890
+ const parseAuthHeaders = (req) => {
4891
+ const authHeader = req.headers['authorization'] || '';
4892
+ const privateToken = req.headers['private-token'] || '';
4893
+ if (privateToken) {
4894
+ const token = privateToken.trim();
4895
+ if (!token || !validateToken(token))
4896
+ return null;
4897
+ return { header: 'Private-Token', token, lastUsed: Date.now() };
4898
+ }
4899
+ if (authHeader) {
4900
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
4901
+ const token = match ? match[1].trim() : '';
4902
+ if (!token || !validateToken(token))
4903
+ return null;
4904
+ return { header: 'Authorization', token, lastUsed: Date.now() };
4905
+ }
4906
+ return null;
4907
+ };
4908
+ /**
4909
+ * Set or reset timeout for session auth
4910
+ * After SESSION_TIMEOUT_SECONDS of inactivity, the auth token is removed
4911
+ * but the transport session remains active
4912
+ */
4913
+ const setAuthTimeout = (sessionId) => {
4914
+ // Clear existing timeout if any
4915
+ clearAuthTimeout(sessionId);
4916
+ // Set new timeout
4917
+ authTimeouts[sessionId] = setTimeout(() => {
4918
+ if (authBySession[sessionId]) {
4919
+ logger.info(`Session ${sessionId}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`);
4920
+ delete authBySession[sessionId];
4921
+ delete authTimeouts[sessionId];
4922
+ metrics.expiredSessions++;
4923
+ }
4924
+ }, SESSION_TIMEOUT_SECONDS * 1000);
4925
+ };
4926
+ /**
4927
+ * Clear timeout for session auth
4928
+ */
4929
+ const clearAuthTimeout = (sessionId) => {
4930
+ const timeout = authTimeouts[sessionId];
4931
+ if (timeout) {
4932
+ clearTimeout(timeout);
4933
+ delete authTimeouts[sessionId];
4934
+ }
4935
+ };
4936
+ /**
4937
+ * Clean up session auth data
4938
+ */
4939
+ const cleanupSessionAuth = (sessionId) => {
4940
+ delete authBySession[sessionId];
4941
+ clearAuthTimeout(sessionId);
4942
+ };
4304
4943
  // Configure Express middleware
4305
4944
  app.use(express.json());
4306
4945
  // Streamable HTTP endpoint - handles both session creation and message handling
4307
4946
  app.post("/mcp", async (req, res) => {
4308
4947
  const sessionId = req.headers["mcp-session-id"];
4309
- try {
4310
- let transport;
4311
- if (sessionId && streamableTransports[sessionId]) {
4312
- // Reuse existing transport for ongoing session
4313
- transport = streamableTransports[sessionId];
4314
- await transport.handleRequest(req, res, req.body);
4948
+ // Track request
4949
+ metrics.requestsProcessed++;
4950
+ // Rate limiting check for existing sessions
4951
+ if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
4952
+ metrics.rejectedByRateLimit++;
4953
+ res.status(429).json({
4954
+ error: 'Rate limit exceeded',
4955
+ message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed`
4956
+ });
4957
+ return;
4958
+ }
4959
+ // Capacity check for new sessions
4960
+ if (!sessionId && Object.keys(streamableTransports).length >= MAX_SESSIONS) {
4961
+ metrics.rejectedByCapacity++;
4962
+ res.status(503).json({
4963
+ error: 'Server capacity reached',
4964
+ message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later.`
4965
+ });
4966
+ return;
4967
+ }
4968
+ // Handle remote authorization: extract and store auth headers per session
4969
+ if (REMOTE_AUTHORIZATION) {
4970
+ const authData = parseAuthHeaders(req);
4971
+ if (sessionId && !authBySession[sessionId]) {
4972
+ // New session: require auth headers
4973
+ if (!authData) {
4974
+ metrics.authFailures++;
4975
+ res.status(401).json({
4976
+ error: 'Missing Authorization or Private-Token header',
4977
+ message: 'Remote authorization is enabled. Please provide Authorization or Private-Token header.'
4978
+ });
4979
+ return;
4980
+ }
4981
+ // Store auth for this session
4982
+ authBySession[sessionId] = authData;
4983
+ logger.info(`Session ${sessionId}: stored ${authData.header} header`);
4984
+ setAuthTimeout(sessionId);
4315
4985
  }
4316
- else {
4317
- // Create new transport for new session
4318
- transport = new StreamableHTTPServerTransport({
4319
- sessionIdGenerator: () => randomUUID(),
4320
- onsessioninitialized: (newSessionId) => {
4321
- streamableTransports[newSessionId] = transport;
4322
- logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
4323
- },
4986
+ else if (sessionId && authData) {
4987
+ // Existing session: allow auth rotation/update
4988
+ authBySession[sessionId] = authData;
4989
+ logger.debug(`Session ${sessionId}: updated ${authData.header} header`);
4990
+ setAuthTimeout(sessionId);
4991
+ }
4992
+ else if (sessionId && authBySession[sessionId]) {
4993
+ // Existing session with stored auth: update last used time and reset timeout
4994
+ authBySession[sessionId].lastUsed = Date.now();
4995
+ setAuthTimeout(sessionId);
4996
+ }
4997
+ else if (!sessionId && !authData) {
4998
+ // First request without session - will fail in initialization
4999
+ }
5000
+ }
5001
+ // Wrap request handling in AsyncLocalStorage context
5002
+ const handleRequestWithAuth = async () => {
5003
+ try {
5004
+ let transport;
5005
+ if (sessionId && streamableTransports[sessionId]) {
5006
+ // Reuse existing transport for ongoing session
5007
+ transport = streamableTransports[sessionId];
5008
+ await transport.handleRequest(req, res, req.body);
5009
+ }
5010
+ else {
5011
+ // Create new transport for new session
5012
+ transport = new StreamableHTTPServerTransport({
5013
+ sessionIdGenerator: () => randomUUID(),
5014
+ onsessioninitialized: (newSessionId) => {
5015
+ streamableTransports[newSessionId] = transport;
5016
+ metrics.totalSessions++;
5017
+ metrics.activeSessions++;
5018
+ logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
5019
+ // Store auth for newly created session in remote mode
5020
+ if (REMOTE_AUTHORIZATION && !authBySession[newSessionId]) {
5021
+ const authData = parseAuthHeaders(req);
5022
+ if (authData) {
5023
+ authBySession[newSessionId] = authData;
5024
+ logger.info(`Session ${newSessionId}: stored ${authData.header} header`);
5025
+ setAuthTimeout(newSessionId);
5026
+ }
5027
+ }
5028
+ },
5029
+ });
5030
+ // Set up cleanup handler when transport closes
5031
+ transport.onclose = () => {
5032
+ const sid = transport.sessionId;
5033
+ if (sid && streamableTransports[sid]) {
5034
+ logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
5035
+ delete streamableTransports[sid];
5036
+ metrics.activeSessions--;
5037
+ if (REMOTE_AUTHORIZATION) {
5038
+ cleanupSessionAuth(sid);
5039
+ delete sessionRequestCounts[sid];
5040
+ logger.info(`Session ${sid}: cleaned up auth mapping`);
5041
+ }
5042
+ }
5043
+ };
5044
+ // Connect transport to MCP server before handling the request
5045
+ await server.connect(transport);
5046
+ await transport.handleRequest(req, res, req.body);
5047
+ }
5048
+ }
5049
+ catch (error) {
5050
+ logger.error("Streamable HTTP error:", error);
5051
+ res.status(500).json({
5052
+ error: "Internal server error",
5053
+ message: error instanceof Error ? error.message : "Unknown error",
4324
5054
  });
4325
- // Set up cleanup handler when transport closes
4326
- transport.onclose = () => {
4327
- const sid = transport.sessionId;
4328
- if (sid && streamableTransports[sid]) {
4329
- logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
4330
- delete streamableTransports[sid];
4331
- }
4332
- };
4333
- // Connect transport to MCP server before handling the request
4334
- await server.connect(transport);
4335
- await transport.handleRequest(req, res, req.body);
4336
5055
  }
5056
+ };
5057
+ // Execute with auth context in remote mode
5058
+ if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
5059
+ const authData = authBySession[sessionId];
5060
+ const ctx = {
5061
+ sessionId,
5062
+ header: authData.header,
5063
+ token: authData.token,
5064
+ lastUsed: authData.lastUsed
5065
+ };
5066
+ await sessionAuthStore.run(ctx, handleRequestWithAuth);
4337
5067
  }
4338
- catch (error) {
4339
- logger.error("Streamable HTTP error:", error);
4340
- res.status(500).json({
4341
- error: "Internal server error",
4342
- message: error instanceof Error ? error.message : "Unknown error",
4343
- });
5068
+ else {
5069
+ // Standard execution (no remote auth or no session yet)
5070
+ await handleRequestWithAuth();
4344
5071
  }
4345
5072
  });
5073
+ // Metrics endpoint
5074
+ app.get("/metrics", (_req, res) => {
5075
+ res.json({
5076
+ ...metrics,
5077
+ activeSessions: Object.keys(streamableTransports).length,
5078
+ authenticatedSessions: Object.keys(authBySession).length,
5079
+ uptime: process.uptime(),
5080
+ memoryUsage: process.memoryUsage(),
5081
+ config: {
5082
+ maxSessions: MAX_SESSIONS,
5083
+ maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
5084
+ sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
5085
+ remoteAuthEnabled: REMOTE_AUTHORIZATION,
5086
+ }
5087
+ });
5088
+ });
5089
+ // Health check endpoint
5090
+ app.get("/health", (_req, res) => {
5091
+ const isHealthy = Object.keys(streamableTransports).length < MAX_SESSIONS;
5092
+ res.status(isHealthy ? 200 : 503).json({
5093
+ status: isHealthy ? 'healthy' : 'degraded',
5094
+ activeSessions: Object.keys(streamableTransports).length,
5095
+ maxSessions: MAX_SESSIONS,
5096
+ uptime: process.uptime(),
5097
+ });
5098
+ });
4346
5099
  // to delete a mcp server session explicitly
4347
5100
  app.delete("/mcp", async (req, res) => {
4348
5101
  const sessionId = req.headers["mcp-session-id"];
@@ -4355,6 +5108,11 @@ async function startStreamableHTTPServer() {
4355
5108
  try {
4356
5109
  await transport.close();
4357
5110
  logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
5111
+ if (REMOTE_AUTHORIZATION) {
5112
+ cleanupSessionAuth(sessionId);
5113
+ delete sessionRequestCounts[sessionId];
5114
+ logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
5115
+ }
4358
5116
  res.status(204).send();
4359
5117
  }
4360
5118
  catch (error) {
@@ -4366,20 +5124,47 @@ async function startStreamableHTTPServer() {
4366
5124
  res.status(404).json({ error: "Session not found" });
4367
5125
  }
4368
5126
  });
4369
- // Health check endpoint
4370
- app.get("/health", (_, res) => {
4371
- res.status(200).json({
4372
- status: "healthy",
4373
- version: SERVER_VERSION,
4374
- transport: TransportMode.STREAMABLE_HTTP,
4375
- activeSessions: Object.keys(streamableTransports).length,
4376
- });
4377
- });
4378
5127
  // Start server
4379
- app.listen(Number(PORT), HOST, () => {
5128
+ const httpServer = app.listen(Number(PORT), HOST, () => {
4380
5129
  logger.info(`GitLab MCP Server running with Streamable HTTP transport`);
4381
5130
  logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/mcp${colorReset}`);
4382
5131
  });
5132
+ // Graceful shutdown handler
5133
+ const gracefulShutdown = async (signal) => {
5134
+ logger.info(`${signal} received, starting graceful shutdown...`);
5135
+ // Stop accepting new connections
5136
+ httpServer.close(() => {
5137
+ logger.info('HTTP server closed');
5138
+ });
5139
+ // Close all active sessions
5140
+ const sessionIds = Object.keys(streamableTransports);
5141
+ logger.info(`Closing ${sessionIds.length} active sessions...`);
5142
+ const closePromises = sessionIds.map(async (sessionId) => {
5143
+ try {
5144
+ const transport = streamableTransports[sessionId];
5145
+ if (transport) {
5146
+ await transport.close();
5147
+ if (REMOTE_AUTHORIZATION) {
5148
+ cleanupSessionAuth(sessionId);
5149
+ delete sessionRequestCounts[sessionId];
5150
+ }
5151
+ }
5152
+ }
5153
+ catch (error) {
5154
+ logger.error(`Error closing session ${sessionId}:`, error);
5155
+ }
5156
+ });
5157
+ await Promise.allSettled(closePromises);
5158
+ // Clear all timeouts
5159
+ Object.keys(authTimeouts).forEach(sessionId => {
5160
+ clearAuthTimeout(sessionId);
5161
+ });
5162
+ logger.info('Graceful shutdown complete');
5163
+ process.exit(0);
5164
+ };
5165
+ // Register signal handlers
5166
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
5167
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
4383
5168
  }
4384
5169
  /**
4385
5170
  * Initialize server with specific transport mode
@@ -4412,6 +5197,8 @@ async function initializeServerByTransportMode(mode) {
4412
5197
  */
4413
5198
  async function runServer() {
4414
5199
  try {
5200
+ // Validate configuration before starting server
5201
+ validateConfiguration();
4415
5202
  const transportMode = determineTransportMode();
4416
5203
  await initializeServerByTransportMode(transportMode);
4417
5204
  }