@zereight/mcp-gitlab 2.0.7 → 2.0.8

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";
@@ -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, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, } 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;
@@ -661,6 +750,41 @@ const allTools = [
661
750
  description: "List all visible events for a specified project. Note: before/after parameters accept date format YYYY-MM-DD only",
662
751
  inputSchema: toJSONSchema(GetProjectEventsSchema),
663
752
  },
753
+ {
754
+ name: "list_releases",
755
+ description: "List all releases for a project",
756
+ inputSchema: toJSONSchema(ListReleasesSchema),
757
+ },
758
+ {
759
+ name: "get_release",
760
+ description: "Get a release by tag name",
761
+ inputSchema: toJSONSchema(GetReleaseSchema),
762
+ },
763
+ {
764
+ name: "create_release",
765
+ description: "Create a new release in a GitLab project",
766
+ inputSchema: toJSONSchema(CreateReleaseSchema),
767
+ },
768
+ {
769
+ name: "update_release",
770
+ description: "Update an existing release in a GitLab project",
771
+ inputSchema: toJSONSchema(UpdateReleaseSchema),
772
+ },
773
+ {
774
+ name: "delete_release",
775
+ description: "Delete a release from a GitLab project (does not delete the associated tag)",
776
+ inputSchema: toJSONSchema(DeleteReleaseSchema),
777
+ },
778
+ {
779
+ name: "create_release_evidence",
780
+ description: "Create release evidence for an existing release (GitLab Premium/Ultimate only)",
781
+ inputSchema: toJSONSchema(CreateReleaseEvidenceSchema),
782
+ },
783
+ {
784
+ name: "download_release_asset",
785
+ description: "Download a release asset file by direct asset path",
786
+ inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
787
+ },
664
788
  ];
665
789
  // Define which tools are read-only
666
790
  const readOnlyTools = [
@@ -710,6 +834,9 @@ const readOnlyTools = [
710
834
  "download_attachment",
711
835
  "list_events",
712
836
  "get_project_events",
837
+ "list_releases",
838
+ "get_release",
839
+ "download_release_asset",
713
840
  ];
714
841
  // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
715
842
  const wikiToolNames = [
@@ -771,9 +898,27 @@ const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || "");
771
898
  const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
772
899
  const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(',').map(id => id.trim()).filter(Boolean) || [];
773
900
  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);
901
+ // Validate authentication configuration
902
+ if (REMOTE_AUTHORIZATION) {
903
+ // Remote authorization mode: token comes from HTTP headers
904
+ if (SSE) {
905
+ logger.error("REMOTE_AUTHORIZATION=true is not compatible with SSE transport mode");
906
+ logger.error("Please use STREAMABLE_HTTP=true instead");
907
+ process.exit(1);
908
+ }
909
+ if (!STREAMABLE_HTTP) {
910
+ logger.error("REMOTE_AUTHORIZATION=true requires STREAMABLE_HTTP=true");
911
+ logger.error("Set STREAMABLE_HTTP=true to enable remote authorization");
912
+ process.exit(1);
913
+ }
914
+ logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
915
+ }
916
+ else {
917
+ // Standard mode: token must be in environment
918
+ if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
919
+ logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
920
+ process.exit(1);
921
+ }
777
922
  }
778
923
  /**
779
924
  * Utility function for handling GitLab API errors
@@ -2539,7 +2684,8 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
2539
2684
  const response = await fetch(url.toString(), {
2540
2685
  ...DEFAULT_FETCH_CONFIG,
2541
2686
  headers: {
2542
- ...DEFAULT_HEADERS,
2687
+ ...BASE_HEADERS,
2688
+ ...buildAuthHeaders(),
2543
2689
  Accept: "text/plain", // Override Accept header to get plain text
2544
2690
  },
2545
2691
  });
@@ -2587,7 +2733,10 @@ async function createPipeline(projectId, ref, variables) {
2587
2733
  }
2588
2734
  const response = await fetch(url.toString(), {
2589
2735
  method: "POST",
2590
- headers: DEFAULT_HEADERS,
2736
+ headers: {
2737
+ ...BASE_HEADERS,
2738
+ ...buildAuthHeaders(),
2739
+ },
2591
2740
  body: JSON.stringify(body),
2592
2741
  });
2593
2742
  await handleGitLabError(response);
@@ -2606,7 +2755,10 @@ async function retryPipeline(projectId, pipelineId) {
2606
2755
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
2607
2756
  const response = await fetch(url.toString(), {
2608
2757
  method: "POST",
2609
- headers: DEFAULT_HEADERS,
2758
+ headers: {
2759
+ ...BASE_HEADERS,
2760
+ ...buildAuthHeaders(),
2761
+ },
2610
2762
  });
2611
2763
  await handleGitLabError(response);
2612
2764
  const data = await response.json();
@@ -2624,7 +2776,10 @@ async function cancelPipeline(projectId, pipelineId) {
2624
2776
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
2625
2777
  const response = await fetch(url.toString(), {
2626
2778
  method: "POST",
2627
- headers: DEFAULT_HEADERS,
2779
+ headers: {
2780
+ ...BASE_HEADERS,
2781
+ ...buildAuthHeaders(),
2782
+ },
2628
2783
  });
2629
2784
  await handleGitLabError(response);
2630
2785
  const data = await response.json();
@@ -2716,14 +2871,9 @@ async function getRepositoryTree(options) {
2716
2871
  if (options.pagination)
2717
2872
  queryParams.append("pagination", options.pagination);
2718
2873
  const headers = {
2719
- "Content-Type": "application/json",
2874
+ ...BASE_HEADERS,
2875
+ ...buildAuthHeaders(),
2720
2876
  };
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
2877
  const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
2728
2878
  headers,
2729
2879
  });
@@ -3189,7 +3339,8 @@ async function markdownUpload(projectId, filePath) {
3189
3339
  const response = await fetch(url.toString(), {
3190
3340
  method: "POST",
3191
3341
  headers: {
3192
- ...DEFAULT_HEADERS,
3342
+ ...BASE_HEADERS,
3343
+ ...buildAuthHeaders(),
3193
3344
  // Remove Content-Type header to let form-data set it with boundary
3194
3345
  "Content-Type": undefined,
3195
3346
  },
@@ -3206,7 +3357,10 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
3206
3357
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
3207
3358
  const response = await fetch(url.toString(), {
3208
3359
  method: "GET",
3209
- headers: DEFAULT_HEADERS,
3360
+ headers: {
3361
+ ...BASE_HEADERS,
3362
+ ...buildAuthHeaders(),
3363
+ },
3210
3364
  });
3211
3365
  if (!response.ok) {
3212
3366
  await handleGitLabError(response);
@@ -3234,7 +3388,10 @@ async function listEvents(options = {}) {
3234
3388
  });
3235
3389
  const response = await fetch(url.toString(), {
3236
3390
  method: "GET",
3237
- headers: DEFAULT_HEADERS,
3391
+ headers: {
3392
+ ...BASE_HEADERS,
3393
+ ...buildAuthHeaders(),
3394
+ },
3238
3395
  });
3239
3396
  if (!response.ok) {
3240
3397
  await handleGitLabError(response);
@@ -3259,7 +3416,10 @@ async function getProjectEvents(projectId, options = {}) {
3259
3416
  });
3260
3417
  const response = await fetch(url.toString(), {
3261
3418
  method: "GET",
3262
- headers: DEFAULT_HEADERS,
3419
+ headers: {
3420
+ ...BASE_HEADERS,
3421
+ ...buildAuthHeaders(),
3422
+ },
3263
3423
  });
3264
3424
  if (!response.ok) {
3265
3425
  await handleGitLabError(response);
@@ -3267,6 +3427,134 @@ async function getProjectEvents(projectId, options = {}) {
3267
3427
  const data = await response.json();
3268
3428
  return GitLabEventSchema.array().parse(data);
3269
3429
  }
3430
+ /**
3431
+ * List all releases for a project
3432
+ *
3433
+ * @param projectId The ID or URL-encoded path of the project
3434
+ * @param options Optional parameters for listing releases
3435
+ * @returns Array of GitLab releases
3436
+ */
3437
+ async function listReleases(projectId, options = {}) {
3438
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3439
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases`);
3440
+ // Add query parameters
3441
+ Object.entries(options).forEach(([key, value]) => {
3442
+ if (value !== undefined) {
3443
+ url.searchParams.append(key, value.toString());
3444
+ }
3445
+ });
3446
+ const response = await fetch(url.toString(), {
3447
+ ...DEFAULT_FETCH_CONFIG,
3448
+ });
3449
+ await handleGitLabError(response);
3450
+ const data = await response.json();
3451
+ return GitLabReleaseSchema.array().parse(data);
3452
+ }
3453
+ /**
3454
+ * Get a release by tag name
3455
+ *
3456
+ * @param projectId The ID or URL-encoded path of the project
3457
+ * @param tagName The Git tag the release is associated with
3458
+ * @param includeHtmlDescription If true, includes HTML rendered Markdown
3459
+ * @returns GitLab release
3460
+ */
3461
+ async function getRelease(projectId, tagName, includeHtmlDescription) {
3462
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3463
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`);
3464
+ if (includeHtmlDescription !== undefined) {
3465
+ url.searchParams.append("include_html_description", includeHtmlDescription.toString());
3466
+ }
3467
+ const response = await fetch(url.toString(), {
3468
+ ...DEFAULT_FETCH_CONFIG,
3469
+ });
3470
+ await handleGitLabError(response);
3471
+ const data = await response.json();
3472
+ return GitLabReleaseSchema.parse(data);
3473
+ }
3474
+ /**
3475
+ * Create a new release
3476
+ *
3477
+ * @param projectId The ID or URL-encoded path of the project
3478
+ * @param options Options for creating the release
3479
+ * @returns Created GitLab release
3480
+ */
3481
+ async function createRelease(projectId, options) {
3482
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3483
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases`, {
3484
+ ...DEFAULT_FETCH_CONFIG,
3485
+ method: "POST",
3486
+ body: JSON.stringify(options),
3487
+ });
3488
+ await handleGitLabError(response);
3489
+ const data = await response.json();
3490
+ return GitLabReleaseSchema.parse(data);
3491
+ }
3492
+ /**
3493
+ * Update an existing release
3494
+ *
3495
+ * @param projectId The ID or URL-encoded path of the project
3496
+ * @param tagName The Git tag the release is associated with
3497
+ * @param options Options for updating the release
3498
+ * @returns Updated GitLab release
3499
+ */
3500
+ async function updateRelease(projectId, tagName, options) {
3501
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3502
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`, {
3503
+ ...DEFAULT_FETCH_CONFIG,
3504
+ method: "PUT",
3505
+ body: JSON.stringify(options),
3506
+ });
3507
+ await handleGitLabError(response);
3508
+ const data = await response.json();
3509
+ return GitLabReleaseSchema.parse(data);
3510
+ }
3511
+ /**
3512
+ * Delete a release
3513
+ *
3514
+ * @param projectId The ID or URL-encoded path of the project
3515
+ * @param tagName The Git tag the release is associated with
3516
+ * @returns Deleted GitLab release
3517
+ */
3518
+ async function deleteRelease(projectId, tagName) {
3519
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3520
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`, {
3521
+ ...DEFAULT_FETCH_CONFIG,
3522
+ method: "DELETE",
3523
+ });
3524
+ await handleGitLabError(response);
3525
+ const data = await response.json();
3526
+ return GitLabReleaseSchema.parse(data);
3527
+ }
3528
+ /**
3529
+ * Create release evidence (GitLab Premium/Ultimate only)
3530
+ *
3531
+ * @param projectId The ID or URL-encoded path of the project
3532
+ * @param tagName The Git tag the release is associated with
3533
+ */
3534
+ async function createReleaseEvidence(projectId, tagName) {
3535
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3536
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}/evidence`, {
3537
+ ...DEFAULT_FETCH_CONFIG,
3538
+ method: "POST",
3539
+ });
3540
+ await handleGitLabError(response);
3541
+ }
3542
+ /**
3543
+ * Download a release asset
3544
+ *
3545
+ * @param projectId The ID or URL-encoded path of the project
3546
+ * @param tagName The Git tag the release is associated with
3547
+ * @param directAssetPath Path to the release asset file
3548
+ * @returns The asset file content
3549
+ */
3550
+ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
3551
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3552
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}/downloads/${directAssetPath}`, {
3553
+ ...DEFAULT_FETCH_CONFIG,
3554
+ });
3555
+ await handleGitLabError(response);
3556
+ return await response.text();
3557
+ }
3270
3558
  server.setRequestHandler(ListToolsRequestSchema, async () => {
3271
3559
  // Apply read-only filter first
3272
3560
  const tools0 = GITLAB_READ_ONLY_MODE
@@ -3332,9 +3620,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3332
3620
  ...DEFAULT_FETCH_CONFIG,
3333
3621
  method: "POST",
3334
3622
  headers: {
3335
- ...DEFAULT_HEADERS,
3336
- "Content-Type": "application/json",
3337
- Accept: "application/json",
3623
+ ...BASE_HEADERS,
3624
+ ...buildAuthHeaders(),
3338
3625
  },
3339
3626
  body: JSON.stringify({ query: args.query, variables: args.variables || {} }),
3340
3627
  signal: controller.signal,
@@ -4211,6 +4498,68 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4211
4498
  content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
4212
4499
  };
4213
4500
  }
4501
+ case "list_releases": {
4502
+ const args = ListReleasesSchema.parse(request.params.arguments);
4503
+ const { project_id, ...options } = args;
4504
+ const releases = await listReleases(project_id, options);
4505
+ return {
4506
+ content: [{ type: "text", text: JSON.stringify(releases, null, 2) }],
4507
+ };
4508
+ }
4509
+ case "get_release": {
4510
+ const args = GetReleaseSchema.parse(request.params.arguments);
4511
+ const release = await getRelease(args.project_id, args.tag_name, args.include_html_description);
4512
+ return {
4513
+ content: [{ type: "text", text: JSON.stringify(release, null, 2) }],
4514
+ };
4515
+ }
4516
+ case "create_release": {
4517
+ const args = CreateReleaseSchema.parse(request.params.arguments);
4518
+ const { project_id, ...options } = args;
4519
+ const release = await createRelease(project_id, options);
4520
+ return {
4521
+ content: [{ type: "text", text: JSON.stringify(release, null, 2) }],
4522
+ };
4523
+ }
4524
+ case "update_release": {
4525
+ const args = UpdateReleaseSchema.parse(request.params.arguments);
4526
+ const { project_id, tag_name, ...options } = args;
4527
+ const release = await updateRelease(project_id, tag_name, options);
4528
+ return {
4529
+ content: [{ type: "text", text: JSON.stringify(release, null, 2) }],
4530
+ };
4531
+ }
4532
+ case "delete_release": {
4533
+ const args = DeleteReleaseSchema.parse(request.params.arguments);
4534
+ const release = await deleteRelease(args.project_id, args.tag_name);
4535
+ return {
4536
+ content: [
4537
+ {
4538
+ type: "text",
4539
+ text: JSON.stringify({ status: "success", message: "Release deleted successfully", release }, null, 2),
4540
+ },
4541
+ ],
4542
+ };
4543
+ }
4544
+ case "create_release_evidence": {
4545
+ const args = CreateReleaseEvidenceSchema.parse(request.params.arguments);
4546
+ await createReleaseEvidence(args.project_id, args.tag_name);
4547
+ return {
4548
+ content: [
4549
+ {
4550
+ type: "text",
4551
+ text: JSON.stringify({ status: "success", message: "Release evidence created successfully" }, null, 2),
4552
+ },
4553
+ ],
4554
+ };
4555
+ }
4556
+ case "download_release_asset": {
4557
+ const args = DownloadReleaseAssetSchema.parse(request.params.arguments);
4558
+ const assetContent = await downloadReleaseAsset(args.project_id, args.tag_name, args.direct_asset_path);
4559
+ return {
4560
+ content: [{ type: "text", text: assetContent }],
4561
+ };
4562
+ }
4214
4563
  default:
4215
4564
  throw new Error(`Unknown tool: ${request.params.name}`);
4216
4565
  }
@@ -4301,48 +4650,264 @@ async function startSSEServer() {
4301
4650
  async function startStreamableHTTPServer() {
4302
4651
  const app = express();
4303
4652
  const streamableTransports = {};
4653
+ // Session-based auth mapping for remote authorization
4654
+ const authBySession = {};
4655
+ const authTimeouts = {};
4656
+ // Configuration and limits
4657
+ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '1000');
4658
+ const MAX_REQUESTS_PER_MINUTE = parseInt(process.env.MAX_REQUESTS_PER_MINUTE || '60');
4659
+ // Metrics tracking
4660
+ const metrics = {
4661
+ activeSessions: 0,
4662
+ totalSessions: 0,
4663
+ expiredSessions: 0,
4664
+ authFailures: 0,
4665
+ requestsProcessed: 0,
4666
+ rejectedByRateLimit: 0,
4667
+ rejectedByCapacity: 0,
4668
+ };
4669
+ // Rate limiting per session
4670
+ const sessionRequestCounts = {};
4671
+ /**
4672
+ * Validate token format and length
4673
+ */
4674
+ const validateToken = (token) => {
4675
+ // GitLab PAT format: glpat-xxxxx (min 20 chars)
4676
+ if (token.length < 20)
4677
+ return false;
4678
+ if (!/^[a-zA-Z0-9_-]+$/.test(token))
4679
+ return false;
4680
+ return true;
4681
+ };
4682
+ /**
4683
+ * Check rate limit for session
4684
+ */
4685
+ const checkRateLimit = (sessionId) => {
4686
+ const now = Date.now();
4687
+ const session = sessionRequestCounts[sessionId];
4688
+ if (!session || now > session.resetAt) {
4689
+ sessionRequestCounts[sessionId] = { count: 1, resetAt: now + 60000 };
4690
+ return true;
4691
+ }
4692
+ if (session.count >= MAX_REQUESTS_PER_MINUTE) {
4693
+ return false;
4694
+ }
4695
+ session.count++;
4696
+ return true;
4697
+ };
4698
+ /**
4699
+ * Parse authentication from request headers
4700
+ * Returns null if no auth found or invalid format
4701
+ */
4702
+ const parseAuthHeaders = (req) => {
4703
+ const authHeader = req.headers['authorization'] || '';
4704
+ const privateToken = req.headers['private-token'] || '';
4705
+ if (privateToken) {
4706
+ const token = privateToken.trim();
4707
+ if (!token || !validateToken(token))
4708
+ return null;
4709
+ return { header: 'Private-Token', token, lastUsed: Date.now() };
4710
+ }
4711
+ if (authHeader) {
4712
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
4713
+ const token = match ? match[1].trim() : '';
4714
+ if (!token || !validateToken(token))
4715
+ return null;
4716
+ return { header: 'Authorization', token, lastUsed: Date.now() };
4717
+ }
4718
+ return null;
4719
+ };
4720
+ /**
4721
+ * Set or reset timeout for session auth
4722
+ * After SESSION_TIMEOUT_SECONDS of inactivity, the auth token is removed
4723
+ * but the transport session remains active
4724
+ */
4725
+ const setAuthTimeout = (sessionId) => {
4726
+ // Clear existing timeout if any
4727
+ clearAuthTimeout(sessionId);
4728
+ // Set new timeout
4729
+ authTimeouts[sessionId] = setTimeout(() => {
4730
+ if (authBySession[sessionId]) {
4731
+ logger.info(`Session ${sessionId}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`);
4732
+ delete authBySession[sessionId];
4733
+ delete authTimeouts[sessionId];
4734
+ metrics.expiredSessions++;
4735
+ }
4736
+ }, SESSION_TIMEOUT_SECONDS * 1000);
4737
+ };
4738
+ /**
4739
+ * Clear timeout for session auth
4740
+ */
4741
+ const clearAuthTimeout = (sessionId) => {
4742
+ const timeout = authTimeouts[sessionId];
4743
+ if (timeout) {
4744
+ clearTimeout(timeout);
4745
+ delete authTimeouts[sessionId];
4746
+ }
4747
+ };
4748
+ /**
4749
+ * Clean up session auth data
4750
+ */
4751
+ const cleanupSessionAuth = (sessionId) => {
4752
+ delete authBySession[sessionId];
4753
+ clearAuthTimeout(sessionId);
4754
+ };
4304
4755
  // Configure Express middleware
4305
4756
  app.use(express.json());
4306
4757
  // Streamable HTTP endpoint - handles both session creation and message handling
4307
4758
  app.post("/mcp", async (req, res) => {
4308
4759
  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);
4760
+ // Track request
4761
+ metrics.requestsProcessed++;
4762
+ // Rate limiting check for existing sessions
4763
+ if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
4764
+ metrics.rejectedByRateLimit++;
4765
+ res.status(429).json({
4766
+ error: 'Rate limit exceeded',
4767
+ message: `Maximum ${MAX_REQUESTS_PER_MINUTE} requests per minute allowed`
4768
+ });
4769
+ return;
4770
+ }
4771
+ // Capacity check for new sessions
4772
+ if (!sessionId && Object.keys(streamableTransports).length >= MAX_SESSIONS) {
4773
+ metrics.rejectedByCapacity++;
4774
+ res.status(503).json({
4775
+ error: 'Server capacity reached',
4776
+ message: `Maximum ${MAX_SESSIONS} concurrent sessions allowed. Please try again later.`
4777
+ });
4778
+ return;
4779
+ }
4780
+ // Handle remote authorization: extract and store auth headers per session
4781
+ if (REMOTE_AUTHORIZATION) {
4782
+ const authData = parseAuthHeaders(req);
4783
+ if (sessionId && !authBySession[sessionId]) {
4784
+ // New session: require auth headers
4785
+ if (!authData) {
4786
+ metrics.authFailures++;
4787
+ res.status(401).json({
4788
+ error: 'Missing Authorization or Private-Token header',
4789
+ message: 'Remote authorization is enabled. Please provide Authorization or Private-Token header.'
4790
+ });
4791
+ return;
4792
+ }
4793
+ // Store auth for this session
4794
+ authBySession[sessionId] = authData;
4795
+ logger.info(`Session ${sessionId}: stored ${authData.header} header`);
4796
+ setAuthTimeout(sessionId);
4315
4797
  }
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
- },
4798
+ else if (sessionId && authData) {
4799
+ // Existing session: allow auth rotation/update
4800
+ authBySession[sessionId] = authData;
4801
+ logger.debug(`Session ${sessionId}: updated ${authData.header} header`);
4802
+ setAuthTimeout(sessionId);
4803
+ }
4804
+ else if (sessionId && authBySession[sessionId]) {
4805
+ // Existing session with stored auth: update last used time and reset timeout
4806
+ authBySession[sessionId].lastUsed = Date.now();
4807
+ setAuthTimeout(sessionId);
4808
+ }
4809
+ else if (!sessionId && !authData) {
4810
+ // First request without session - will fail in initialization
4811
+ }
4812
+ }
4813
+ // Wrap request handling in AsyncLocalStorage context
4814
+ const handleRequestWithAuth = async () => {
4815
+ try {
4816
+ let transport;
4817
+ if (sessionId && streamableTransports[sessionId]) {
4818
+ // Reuse existing transport for ongoing session
4819
+ transport = streamableTransports[sessionId];
4820
+ await transport.handleRequest(req, res, req.body);
4821
+ }
4822
+ else {
4823
+ // Create new transport for new session
4824
+ transport = new StreamableHTTPServerTransport({
4825
+ sessionIdGenerator: () => randomUUID(),
4826
+ onsessioninitialized: (newSessionId) => {
4827
+ streamableTransports[newSessionId] = transport;
4828
+ metrics.totalSessions++;
4829
+ metrics.activeSessions++;
4830
+ logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
4831
+ // Store auth for newly created session in remote mode
4832
+ if (REMOTE_AUTHORIZATION && !authBySession[newSessionId]) {
4833
+ const authData = parseAuthHeaders(req);
4834
+ if (authData) {
4835
+ authBySession[newSessionId] = authData;
4836
+ logger.info(`Session ${newSessionId}: stored ${authData.header} header`);
4837
+ setAuthTimeout(newSessionId);
4838
+ }
4839
+ }
4840
+ },
4841
+ });
4842
+ // Set up cleanup handler when transport closes
4843
+ transport.onclose = () => {
4844
+ const sid = transport.sessionId;
4845
+ if (sid && streamableTransports[sid]) {
4846
+ logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
4847
+ delete streamableTransports[sid];
4848
+ metrics.activeSessions--;
4849
+ if (REMOTE_AUTHORIZATION) {
4850
+ cleanupSessionAuth(sid);
4851
+ delete sessionRequestCounts[sid];
4852
+ logger.info(`Session ${sid}: cleaned up auth mapping`);
4853
+ }
4854
+ }
4855
+ };
4856
+ // Connect transport to MCP server before handling the request
4857
+ await server.connect(transport);
4858
+ await transport.handleRequest(req, res, req.body);
4859
+ }
4860
+ }
4861
+ catch (error) {
4862
+ logger.error("Streamable HTTP error:", error);
4863
+ res.status(500).json({
4864
+ error: "Internal server error",
4865
+ message: error instanceof Error ? error.message : "Unknown error",
4324
4866
  });
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
4867
  }
4868
+ };
4869
+ // Execute with auth context in remote mode
4870
+ if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
4871
+ const authData = authBySession[sessionId];
4872
+ const ctx = {
4873
+ sessionId,
4874
+ header: authData.header,
4875
+ token: authData.token,
4876
+ lastUsed: authData.lastUsed
4877
+ };
4878
+ await sessionAuthStore.run(ctx, handleRequestWithAuth);
4337
4879
  }
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
- });
4880
+ else {
4881
+ // Standard execution (no remote auth or no session yet)
4882
+ await handleRequestWithAuth();
4344
4883
  }
4345
4884
  });
4885
+ // Metrics endpoint
4886
+ app.get("/metrics", (_req, res) => {
4887
+ res.json({
4888
+ ...metrics,
4889
+ activeSessions: Object.keys(streamableTransports).length,
4890
+ authenticatedSessions: Object.keys(authBySession).length,
4891
+ uptime: process.uptime(),
4892
+ memoryUsage: process.memoryUsage(),
4893
+ config: {
4894
+ maxSessions: MAX_SESSIONS,
4895
+ maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
4896
+ sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
4897
+ remoteAuthEnabled: REMOTE_AUTHORIZATION,
4898
+ }
4899
+ });
4900
+ });
4901
+ // Health check endpoint
4902
+ app.get("/health", (_req, res) => {
4903
+ const isHealthy = Object.keys(streamableTransports).length < MAX_SESSIONS;
4904
+ res.status(isHealthy ? 200 : 503).json({
4905
+ status: isHealthy ? 'healthy' : 'degraded',
4906
+ activeSessions: Object.keys(streamableTransports).length,
4907
+ maxSessions: MAX_SESSIONS,
4908
+ uptime: process.uptime(),
4909
+ });
4910
+ });
4346
4911
  // to delete a mcp server session explicitly
4347
4912
  app.delete("/mcp", async (req, res) => {
4348
4913
  const sessionId = req.headers["mcp-session-id"];
@@ -4355,6 +4920,11 @@ async function startStreamableHTTPServer() {
4355
4920
  try {
4356
4921
  await transport.close();
4357
4922
  logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
4923
+ if (REMOTE_AUTHORIZATION) {
4924
+ cleanupSessionAuth(sessionId);
4925
+ delete sessionRequestCounts[sessionId];
4926
+ logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
4927
+ }
4358
4928
  res.status(204).send();
4359
4929
  }
4360
4930
  catch (error) {
@@ -4366,20 +4936,47 @@ async function startStreamableHTTPServer() {
4366
4936
  res.status(404).json({ error: "Session not found" });
4367
4937
  }
4368
4938
  });
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
4939
  // Start server
4379
- app.listen(Number(PORT), HOST, () => {
4940
+ const httpServer = app.listen(Number(PORT), HOST, () => {
4380
4941
  logger.info(`GitLab MCP Server running with Streamable HTTP transport`);
4381
4942
  logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/mcp${colorReset}`);
4382
4943
  });
4944
+ // Graceful shutdown handler
4945
+ const gracefulShutdown = async (signal) => {
4946
+ logger.info(`${signal} received, starting graceful shutdown...`);
4947
+ // Stop accepting new connections
4948
+ httpServer.close(() => {
4949
+ logger.info('HTTP server closed');
4950
+ });
4951
+ // Close all active sessions
4952
+ const sessionIds = Object.keys(streamableTransports);
4953
+ logger.info(`Closing ${sessionIds.length} active sessions...`);
4954
+ const closePromises = sessionIds.map(async (sessionId) => {
4955
+ try {
4956
+ const transport = streamableTransports[sessionId];
4957
+ if (transport) {
4958
+ await transport.close();
4959
+ if (REMOTE_AUTHORIZATION) {
4960
+ cleanupSessionAuth(sessionId);
4961
+ delete sessionRequestCounts[sessionId];
4962
+ }
4963
+ }
4964
+ }
4965
+ catch (error) {
4966
+ logger.error(`Error closing session ${sessionId}:`, error);
4967
+ }
4968
+ });
4969
+ await Promise.allSettled(closePromises);
4970
+ // Clear all timeouts
4971
+ Object.keys(authTimeouts).forEach(sessionId => {
4972
+ clearAuthTimeout(sessionId);
4973
+ });
4974
+ logger.info('Graceful shutdown complete');
4975
+ process.exit(0);
4976
+ };
4977
+ // Register signal handlers
4978
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
4979
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
4383
4980
  }
4384
4981
  /**
4385
4982
  * Initialize server with specific transport mode
@@ -4412,6 +5009,8 @@ async function initializeServerByTransportMode(mode) {
4412
5009
  */
4413
5010
  async function runServer() {
4414
5011
  try {
5012
+ // Validate configuration before starting server
5013
+ validateConfiguration();
4415
5014
  const transportMode = determineTransportMode();
4416
5015
  await initializeServerByTransportMode(transportMode);
4417
5016
  }