@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/README.md +119 -1
- package/build/index.js +721 -68
- package/build/schemas.js +165 -2
- 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/test/comprehensive-mcp-tests.js +0 -378
- package/build/test/simple-mcp-tests.js +0 -190
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
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
|
-
//
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
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
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
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
|
}
|