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