@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/README.md +119 -1
- package/build/index.js +670 -71
- package/build/schemas.js +156 -0
- package/build/test/clients/custom-header-client.js +122 -0
- package/build/test/remote-auth-simple-test.js +215 -0
- package/build/test/remote-auth-tests.js +315 -0
- package/build/test/utils/mock-gitlab-server.js +275 -0
- package/build/test/utils/server-launcher.js +10 -4
- package/package.json +3 -2
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
...
|
|
3336
|
-
|
|
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
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
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
|
-
//
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
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
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
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
|
}
|