@zereight/mcp-gitlab 2.0.6 → 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 } 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;
@@ -221,6 +310,11 @@ const allTools = [
221
310
  description: "Merge a merge request in a GitLab project",
222
311
  inputSchema: toJSONSchema(MergeMergeRequestSchema),
223
312
  },
313
+ {
314
+ name: "execute_graphql",
315
+ description: "Execute a GitLab GraphQL query",
316
+ inputSchema: zodToJsonSchema(ExecuteGraphQLSchema),
317
+ },
224
318
  {
225
319
  name: "create_or_update_file",
226
320
  description: "Create or update a single file in a GitLab project",
@@ -656,10 +750,46 @@ const allTools = [
656
750
  description: "List all visible events for a specified project. Note: before/after parameters accept date format YYYY-MM-DD only",
657
751
  inputSchema: toJSONSchema(GetProjectEventsSchema),
658
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
+ },
659
788
  ];
660
789
  // Define which tools are read-only
661
790
  const readOnlyTools = [
662
791
  "search_repositories",
792
+ "execute_graphql",
663
793
  "get_file_contents",
664
794
  "get_merge_request",
665
795
  "get_merge_request_diffs",
@@ -704,6 +834,9 @@ const readOnlyTools = [
704
834
  "download_attachment",
705
835
  "list_events",
706
836
  "get_project_events",
837
+ "list_releases",
838
+ "get_release",
839
+ "download_release_asset",
707
840
  ];
708
841
  // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
709
842
  const wikiToolNames = [
@@ -765,9 +898,27 @@ const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || "");
765
898
  const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
766
899
  const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(',').map(id => id.trim()).filter(Boolean) || [];
767
900
  const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE ? parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE) : 20;
768
- if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
769
- logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
770
- 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
+ }
771
922
  }
772
923
  /**
773
924
  * Utility function for handling GitLab API errors
@@ -2533,7 +2684,8 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
2533
2684
  const response = await fetch(url.toString(), {
2534
2685
  ...DEFAULT_FETCH_CONFIG,
2535
2686
  headers: {
2536
- ...DEFAULT_HEADERS,
2687
+ ...BASE_HEADERS,
2688
+ ...buildAuthHeaders(),
2537
2689
  Accept: "text/plain", // Override Accept header to get plain text
2538
2690
  },
2539
2691
  });
@@ -2581,7 +2733,10 @@ async function createPipeline(projectId, ref, variables) {
2581
2733
  }
2582
2734
  const response = await fetch(url.toString(), {
2583
2735
  method: "POST",
2584
- headers: DEFAULT_HEADERS,
2736
+ headers: {
2737
+ ...BASE_HEADERS,
2738
+ ...buildAuthHeaders(),
2739
+ },
2585
2740
  body: JSON.stringify(body),
2586
2741
  });
2587
2742
  await handleGitLabError(response);
@@ -2600,7 +2755,10 @@ async function retryPipeline(projectId, pipelineId) {
2600
2755
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
2601
2756
  const response = await fetch(url.toString(), {
2602
2757
  method: "POST",
2603
- headers: DEFAULT_HEADERS,
2758
+ headers: {
2759
+ ...BASE_HEADERS,
2760
+ ...buildAuthHeaders(),
2761
+ },
2604
2762
  });
2605
2763
  await handleGitLabError(response);
2606
2764
  const data = await response.json();
@@ -2618,7 +2776,10 @@ async function cancelPipeline(projectId, pipelineId) {
2618
2776
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
2619
2777
  const response = await fetch(url.toString(), {
2620
2778
  method: "POST",
2621
- headers: DEFAULT_HEADERS,
2779
+ headers: {
2780
+ ...BASE_HEADERS,
2781
+ ...buildAuthHeaders(),
2782
+ },
2622
2783
  });
2623
2784
  await handleGitLabError(response);
2624
2785
  const data = await response.json();
@@ -2710,14 +2871,9 @@ async function getRepositoryTree(options) {
2710
2871
  if (options.pagination)
2711
2872
  queryParams.append("pagination", options.pagination);
2712
2873
  const headers = {
2713
- "Content-Type": "application/json",
2874
+ ...BASE_HEADERS,
2875
+ ...buildAuthHeaders(),
2714
2876
  };
2715
- if (IS_OLD) {
2716
- headers["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`;
2717
- }
2718
- else {
2719
- headers["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`;
2720
- }
2721
2877
  const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
2722
2878
  headers,
2723
2879
  });
@@ -3183,7 +3339,8 @@ async function markdownUpload(projectId, filePath) {
3183
3339
  const response = await fetch(url.toString(), {
3184
3340
  method: "POST",
3185
3341
  headers: {
3186
- ...DEFAULT_HEADERS,
3342
+ ...BASE_HEADERS,
3343
+ ...buildAuthHeaders(),
3187
3344
  // Remove Content-Type header to let form-data set it with boundary
3188
3345
  "Content-Type": undefined,
3189
3346
  },
@@ -3200,7 +3357,10 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
3200
3357
  const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
3201
3358
  const response = await fetch(url.toString(), {
3202
3359
  method: "GET",
3203
- headers: DEFAULT_HEADERS,
3360
+ headers: {
3361
+ ...BASE_HEADERS,
3362
+ ...buildAuthHeaders(),
3363
+ },
3204
3364
  });
3205
3365
  if (!response.ok) {
3206
3366
  await handleGitLabError(response);
@@ -3228,7 +3388,10 @@ async function listEvents(options = {}) {
3228
3388
  });
3229
3389
  const response = await fetch(url.toString(), {
3230
3390
  method: "GET",
3231
- headers: DEFAULT_HEADERS,
3391
+ headers: {
3392
+ ...BASE_HEADERS,
3393
+ ...buildAuthHeaders(),
3394
+ },
3232
3395
  });
3233
3396
  if (!response.ok) {
3234
3397
  await handleGitLabError(response);
@@ -3253,7 +3416,10 @@ async function getProjectEvents(projectId, options = {}) {
3253
3416
  });
3254
3417
  const response = await fetch(url.toString(), {
3255
3418
  method: "GET",
3256
- headers: DEFAULT_HEADERS,
3419
+ headers: {
3420
+ ...BASE_HEADERS,
3421
+ ...buildAuthHeaders(),
3422
+ },
3257
3423
  });
3258
3424
  if (!response.ok) {
3259
3425
  await handleGitLabError(response);
@@ -3261,6 +3427,134 @@ async function getProjectEvents(projectId, options = {}) {
3261
3427
  const data = await response.json();
3262
3428
  return GitLabEventSchema.array().parse(data);
3263
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
+ }
3264
3558
  server.setRequestHandler(ListToolsRequestSchema, async () => {
3265
3559
  // Apply read-only filter first
3266
3560
  const tools0 = GITLAB_READ_ONLY_MODE
@@ -3308,6 +3602,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3308
3602
  }
3309
3603
  logger.info(request.params.name);
3310
3604
  switch (request.params.name) {
3605
+ case "execute_graphql": {
3606
+ const args = ExecuteGraphQLSchema.parse(request.params.arguments);
3607
+ const apiUrl = new URL(GITLAB_API_URL);
3608
+ // Build GraphQL endpoint preserving any instance subpath (e.g. /gitlab)
3609
+ const restPath = apiUrl.pathname || ""; // e.g. /api/v4 or /gitlab/api/v4
3610
+ const idx = restPath.lastIndexOf("/api/v4");
3611
+ const prefix = idx >= 0 ? restPath.slice(0, idx) : "";
3612
+ const graphqlUrl = process.env.GITLAB_GRAPHQL_URL || `${apiUrl.origin}${prefix}/api/graphql`;
3613
+ // Add timeout to avoid hanging requests
3614
+ const controller = new AbortController();
3615
+ const timeoutMs = 45000;
3616
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
3617
+ logger.info({ endpoint: graphqlUrl }, "execute_graphql request");
3618
+ try {
3619
+ const response = await fetch(graphqlUrl, {
3620
+ ...DEFAULT_FETCH_CONFIG,
3621
+ method: "POST",
3622
+ headers: {
3623
+ ...BASE_HEADERS,
3624
+ ...buildAuthHeaders(),
3625
+ },
3626
+ body: JSON.stringify({ query: args.query, variables: args.variables || {} }),
3627
+ signal: controller.signal,
3628
+ });
3629
+ if (!response.ok) {
3630
+ await handleGitLabError(response);
3631
+ }
3632
+ const json = await response.json();
3633
+ return {
3634
+ content: [{ type: "text", text: JSON.stringify(json, null, 2) }],
3635
+ };
3636
+ }
3637
+ catch (err) {
3638
+ const message = err instanceof Error ? err.message : String(err);
3639
+ return {
3640
+ content: [
3641
+ {
3642
+ type: "text",
3643
+ text: JSON.stringify({ error: `GraphQL request failed: ${message}` }, null, 2),
3644
+ },
3645
+ ],
3646
+ };
3647
+ }
3648
+ finally {
3649
+ clearTimeout(timeout);
3650
+ }
3651
+ }
3311
3652
  case "fork_repository": {
3312
3653
  if (GITLAB_PROJECT_ID) {
3313
3654
  throw new Error("Direct project ID is set. So fork_repository is not allowed");
@@ -4157,6 +4498,68 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4157
4498
  content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
4158
4499
  };
4159
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
+ }
4160
4563
  default:
4161
4564
  throw new Error(`Unknown tool: ${request.params.name}`);
4162
4565
  }
@@ -4247,48 +4650,264 @@ async function startSSEServer() {
4247
4650
  async function startStreamableHTTPServer() {
4248
4651
  const app = express();
4249
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
+ };
4250
4755
  // Configure Express middleware
4251
4756
  app.use(express.json());
4252
4757
  // Streamable HTTP endpoint - handles both session creation and message handling
4253
4758
  app.post("/mcp", async (req, res) => {
4254
4759
  const sessionId = req.headers["mcp-session-id"];
4255
- try {
4256
- let transport;
4257
- if (sessionId && streamableTransports[sessionId]) {
4258
- // Reuse existing transport for ongoing session
4259
- transport = streamableTransports[sessionId];
4260
- 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);
4261
4797
  }
4262
- else {
4263
- // Create new transport for new session
4264
- transport = new StreamableHTTPServerTransport({
4265
- sessionIdGenerator: () => randomUUID(),
4266
- onsessioninitialized: (newSessionId) => {
4267
- streamableTransports[newSessionId] = transport;
4268
- logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
4269
- },
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",
4270
4866
  });
4271
- // Set up cleanup handler when transport closes
4272
- transport.onclose = () => {
4273
- const sid = transport.sessionId;
4274
- if (sid && streamableTransports[sid]) {
4275
- logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
4276
- delete streamableTransports[sid];
4277
- }
4278
- };
4279
- // Connect transport to MCP server before handling the request
4280
- await server.connect(transport);
4281
- await transport.handleRequest(req, res, req.body);
4282
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);
4283
4879
  }
4284
- catch (error) {
4285
- logger.error("Streamable HTTP error:", error);
4286
- res.status(500).json({
4287
- error: "Internal server error",
4288
- message: error instanceof Error ? error.message : "Unknown error",
4289
- });
4880
+ else {
4881
+ // Standard execution (no remote auth or no session yet)
4882
+ await handleRequestWithAuth();
4290
4883
  }
4291
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
+ });
4292
4911
  // to delete a mcp server session explicitly
4293
4912
  app.delete("/mcp", async (req, res) => {
4294
4913
  const sessionId = req.headers["mcp-session-id"];
@@ -4301,6 +4920,11 @@ async function startStreamableHTTPServer() {
4301
4920
  try {
4302
4921
  await transport.close();
4303
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
+ }
4304
4928
  res.status(204).send();
4305
4929
  }
4306
4930
  catch (error) {
@@ -4312,20 +4936,47 @@ async function startStreamableHTTPServer() {
4312
4936
  res.status(404).json({ error: "Session not found" });
4313
4937
  }
4314
4938
  });
4315
- // Health check endpoint
4316
- app.get("/health", (_, res) => {
4317
- res.status(200).json({
4318
- status: "healthy",
4319
- version: SERVER_VERSION,
4320
- transport: TransportMode.STREAMABLE_HTTP,
4321
- activeSessions: Object.keys(streamableTransports).length,
4322
- });
4323
- });
4324
4939
  // Start server
4325
- app.listen(Number(PORT), HOST, () => {
4940
+ const httpServer = app.listen(Number(PORT), HOST, () => {
4326
4941
  logger.info(`GitLab MCP Server running with Streamable HTTP transport`);
4327
4942
  logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/mcp${colorReset}`);
4328
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'));
4329
4980
  }
4330
4981
  /**
4331
4982
  * Initialize server with specific transport mode
@@ -4358,6 +5009,8 @@ async function initializeServerByTransportMode(mode) {
4358
5009
  */
4359
5010
  async function runServer() {
4360
5011
  try {
5012
+ // Validate configuration before starting server
5013
+ validateConfiguration();
4361
5014
  const transportMode = determineTransportMode();
4362
5015
  await initializeServerByTransportMode(transportMode);
4363
5016
  }