@zereight/mcp-gitlab 2.0.32 → 2.0.34
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 +12 -1
- package/build/gitlab-client-pool.js +108 -6
- package/build/index.js +274 -64
- package/build/oauth.js +11 -6
- package/build/schemas.js +69 -0
- package/build/test/no-proxy-integration-test.js +183 -0
- package/build/test/no-proxy-test.js +138 -0
- package/build/test/remote-auth-simple-test.js +12 -1
- package/build/test/test-geteffectiveprojectid.js +236 -0
- package/build/test/test-upload-markdown.js +148 -0
- package/build/test/utils/mock-gitlab-server.js +5 -1
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -50,7 +50,7 @@ GitLabDiscussionNoteSchema, // Added
|
|
|
50
50
|
GitLabDiscussionSchema,
|
|
51
51
|
// Draft Notes Schemas
|
|
52
52
|
GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
53
|
-
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, 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";
|
|
53
|
+
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, 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, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
|
|
54
54
|
import { randomUUID } from "node:crypto";
|
|
55
55
|
import { pino } from "pino";
|
|
56
56
|
const logger = pino({
|
|
@@ -226,9 +226,10 @@ function validateConfiguration() {
|
|
|
226
226
|
const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
227
227
|
const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
|
|
228
228
|
const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
|
|
229
|
+
const hasJobToken = !!getConfig("job-token", "GITLAB_JOB_TOKEN");
|
|
229
230
|
const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
|
|
230
|
-
if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
|
|
231
|
-
errors.push("Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)");
|
|
231
|
+
if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie) {
|
|
232
|
+
errors.push("Either --token, --job-token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)");
|
|
232
233
|
}
|
|
233
234
|
const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
234
235
|
if (enableDynamicApiUrl && !remoteAuth) {
|
|
@@ -242,6 +243,7 @@ function validateConfiguration() {
|
|
|
242
243
|
logger.info("Configuration validation passed");
|
|
243
244
|
}
|
|
244
245
|
const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
|
|
246
|
+
const GITLAB_JOB_TOKEN = getConfig("job-token", "GITLAB_JOB_TOKEN");
|
|
245
247
|
let OAUTH_ACCESS_TOKEN = null;
|
|
246
248
|
let oauthClient = null;
|
|
247
249
|
/**
|
|
@@ -312,6 +314,7 @@ const PORT = Number.parseInt(getConfig("port", "PORT", "3002"), 10);
|
|
|
312
314
|
// Add proxy configuration
|
|
313
315
|
const HTTP_PROXY = getConfig("http-proxy", "HTTP_PROXY");
|
|
314
316
|
const HTTPS_PROXY = getConfig("https-proxy", "HTTPS_PROXY");
|
|
317
|
+
const NO_PROXY = getConfig("no-proxy", "NO_PROXY");
|
|
315
318
|
const NODE_TLS_REJECT_UNAUTHORIZED = getConfig("tls-reject-unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED");
|
|
316
319
|
const GITLAB_CA_CERT_PATH = getConfig("ca-cert-path", "GITLAB_CA_CERT_PATH");
|
|
317
320
|
const GITLAB_POOL_MAX_SIZE = getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE")
|
|
@@ -353,6 +356,7 @@ const clientPool = new GitLabClientPool({
|
|
|
353
356
|
.map(normalizeGitLabApiUrl),
|
|
354
357
|
httpProxy: HTTP_PROXY,
|
|
355
358
|
httpsProxy: HTTPS_PROXY,
|
|
359
|
+
noProxy: NO_PROXY,
|
|
356
360
|
rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
|
|
357
361
|
caCertPath: GITLAB_CA_CERT_PATH,
|
|
358
362
|
poolMaxSize: GITLAB_POOL_MAX_SIZE,
|
|
@@ -492,6 +496,10 @@ function buildAuthHeaders() {
|
|
|
492
496
|
}
|
|
493
497
|
return {}; // No auth headers if no session context
|
|
494
498
|
}
|
|
499
|
+
// CI job tokens use a dedicated header (not Bearer/Private-Token)
|
|
500
|
+
if (GITLAB_JOB_TOKEN) {
|
|
501
|
+
return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) };
|
|
502
|
+
}
|
|
495
503
|
// Standard mode: prioritize OAuth token, then fall back to environment token
|
|
496
504
|
const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
497
505
|
if (IS_OLD && token) {
|
|
@@ -529,7 +537,7 @@ function getEffectiveApiUrl() {
|
|
|
529
537
|
*/
|
|
530
538
|
const getFetchConfig = () => {
|
|
531
539
|
const effectiveApiUrl = getEffectiveApiUrl();
|
|
532
|
-
const agent = clientPool.
|
|
540
|
+
const agent = clientPool.getAgentFunctionForUrl(effectiveApiUrl);
|
|
533
541
|
return {
|
|
534
542
|
headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
|
|
535
543
|
agent: agent,
|
|
@@ -597,6 +605,11 @@ const allTools = [
|
|
|
597
605
|
description: "Get merge request approval details including approvers (uses approval_state when available, falls back to approvals endpoint)",
|
|
598
606
|
inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
|
|
599
607
|
},
|
|
608
|
+
{
|
|
609
|
+
name: "get_merge_request_conflicts",
|
|
610
|
+
description: "Get the conflicts of a merge request in a GitLab project",
|
|
611
|
+
inputSchema: toJSONSchema(GetMergeRequestConflictsSchema),
|
|
612
|
+
},
|
|
600
613
|
{
|
|
601
614
|
name: "execute_graphql",
|
|
602
615
|
description: "Execute a GitLab GraphQL query",
|
|
@@ -1152,6 +1165,21 @@ const allTools = [
|
|
|
1152
1165
|
description: "Download a release asset file by direct asset path",
|
|
1153
1166
|
inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
|
|
1154
1167
|
},
|
|
1168
|
+
{
|
|
1169
|
+
name: "list_webhooks",
|
|
1170
|
+
description: "List all configured webhooks for a GitLab project or group. Provide either project_id or group_id.",
|
|
1171
|
+
inputSchema: toJSONSchema(ListWebhooksSchema),
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
name: "list_webhook_events",
|
|
1175
|
+
description: "List recent webhook events (past 7 days) for a project or group webhook. Use summary mode for overview, then get_webhook_event for full details.",
|
|
1176
|
+
inputSchema: toJSONSchema(ListWebhookEventsSchema),
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
name: "get_webhook_event",
|
|
1180
|
+
description: "Get full details of a specific webhook event by ID, including request/response payloads. Searches up to 500 most recent events.",
|
|
1181
|
+
inputSchema: toJSONSchema(GetWebhookEventSchema),
|
|
1182
|
+
},
|
|
1155
1183
|
];
|
|
1156
1184
|
// Define which tools are read-only
|
|
1157
1185
|
const readOnlyTools = new Set([
|
|
@@ -1214,6 +1242,10 @@ const readOnlyTools = new Set([
|
|
|
1214
1242
|
"get_release",
|
|
1215
1243
|
"download_release_asset",
|
|
1216
1244
|
"get_merge_request_approval_state",
|
|
1245
|
+
"get_merge_request_conflicts",
|
|
1246
|
+
"list_webhooks",
|
|
1247
|
+
"list_webhook_events",
|
|
1248
|
+
"get_webhook_event",
|
|
1217
1249
|
]);
|
|
1218
1250
|
// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
|
|
1219
1251
|
const wikiToolNames = new Set([
|
|
@@ -1267,6 +1299,7 @@ const TOOLSET_DEFINITIONS = [
|
|
|
1267
1299
|
"approve_merge_request",
|
|
1268
1300
|
"unapprove_merge_request",
|
|
1269
1301
|
"get_merge_request_approval_state",
|
|
1302
|
+
"get_merge_request_conflicts",
|
|
1270
1303
|
"get_merge_request",
|
|
1271
1304
|
"get_merge_request_diffs",
|
|
1272
1305
|
"list_merge_request_diffs",
|
|
@@ -1439,6 +1472,15 @@ const TOOLSET_DEFINITIONS = [
|
|
|
1439
1472
|
"download_attachment",
|
|
1440
1473
|
]),
|
|
1441
1474
|
},
|
|
1475
|
+
{
|
|
1476
|
+
id: "webhooks",
|
|
1477
|
+
isDefault: false,
|
|
1478
|
+
tools: new Set([
|
|
1479
|
+
"list_webhooks",
|
|
1480
|
+
"list_webhook_events",
|
|
1481
|
+
"get_webhook_event",
|
|
1482
|
+
]),
|
|
1483
|
+
},
|
|
1442
1484
|
];
|
|
1443
1485
|
// Derived lookup: tool name → toolset ID
|
|
1444
1486
|
const TOOLSET_BY_TOOL_NAME = new Map();
|
|
@@ -1550,6 +1592,9 @@ const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split
|
|
|
1550
1592
|
const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE
|
|
1551
1593
|
? Number.parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE, 10)
|
|
1552
1594
|
: 20;
|
|
1595
|
+
const GITLAB_REPO_FILE_ENCODING = getConfig("repo-file-encoding", "GITLAB_REPO_FILE_ENCODING", "text") === "base64"
|
|
1596
|
+
? "base64"
|
|
1597
|
+
: "text";
|
|
1553
1598
|
// Validate authentication configuration
|
|
1554
1599
|
if (REMOTE_AUTHORIZATION) {
|
|
1555
1600
|
// Remote authorization mode: token comes from HTTP headers
|
|
@@ -1565,7 +1610,7 @@ if (REMOTE_AUTHORIZATION) {
|
|
|
1565
1610
|
}
|
|
1566
1611
|
logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
|
|
1567
1612
|
}
|
|
1568
|
-
else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
|
|
1613
|
+
else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
|
|
1569
1614
|
// Standard mode: token must be in environment (unless using OAuth)
|
|
1570
1615
|
logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
|
|
1571
1616
|
logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
|
|
@@ -1614,7 +1659,14 @@ function getEffectiveProjectId(projectId) {
|
|
|
1614
1659
|
}
|
|
1615
1660
|
return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
|
|
1616
1661
|
}
|
|
1617
|
-
|
|
1662
|
+
// Prioritize the passed projectId over GITLAB_PROJECT_ID to allow querying different projects
|
|
1663
|
+
if (projectId) {
|
|
1664
|
+
return projectId;
|
|
1665
|
+
}
|
|
1666
|
+
if (GITLAB_PROJECT_ID) {
|
|
1667
|
+
return GITLAB_PROJECT_ID;
|
|
1668
|
+
}
|
|
1669
|
+
throw new Error("No project ID provided and GITLAB_PROJECT_ID is not set");
|
|
1618
1670
|
}
|
|
1619
1671
|
/**
|
|
1620
1672
|
* Create a fork of a GitLab project
|
|
@@ -2311,6 +2363,12 @@ async function updateMergeRequestNote(projectId, mergeRequestIid, noteId, body)
|
|
|
2311
2363
|
const data = await response.json();
|
|
2312
2364
|
return GitLabDiscussionNoteSchema.parse(data);
|
|
2313
2365
|
}
|
|
2366
|
+
function encodeRepoFilePayloadContent(content) {
|
|
2367
|
+
if (GITLAB_REPO_FILE_ENCODING === "base64") {
|
|
2368
|
+
return Buffer.from(content).toString("base64");
|
|
2369
|
+
}
|
|
2370
|
+
return content;
|
|
2371
|
+
}
|
|
2314
2372
|
/**
|
|
2315
2373
|
* Create or update a file in a GitLab project
|
|
2316
2374
|
* 파일 생성 또는 업데이트
|
|
@@ -2329,9 +2387,9 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
|
|
|
2329
2387
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}`);
|
|
2330
2388
|
const body = {
|
|
2331
2389
|
branch,
|
|
2332
|
-
content,
|
|
2390
|
+
content: encodeRepoFilePayloadContent(content),
|
|
2333
2391
|
commit_message: commitMessage,
|
|
2334
|
-
encoding:
|
|
2392
|
+
encoding: GITLAB_REPO_FILE_ENCODING,
|
|
2335
2393
|
...(previousPath ? { previous_path: previousPath } : {}),
|
|
2336
2394
|
};
|
|
2337
2395
|
// Check if file exists
|
|
@@ -2403,8 +2461,8 @@ async function createTree(projectId, files, ref) {
|
|
|
2403
2461
|
body: JSON.stringify({
|
|
2404
2462
|
files: files.map(file => ({
|
|
2405
2463
|
file_path: file.path,
|
|
2406
|
-
content: file.content,
|
|
2407
|
-
encoding:
|
|
2464
|
+
content: encodeRepoFilePayloadContent(file.content),
|
|
2465
|
+
encoding: GITLAB_REPO_FILE_ENCODING,
|
|
2408
2466
|
})),
|
|
2409
2467
|
}),
|
|
2410
2468
|
});
|
|
@@ -2441,8 +2499,8 @@ async function createCommit(projectId, message, branch, actions) {
|
|
|
2441
2499
|
actions: actions.map(action => ({
|
|
2442
2500
|
action: "create",
|
|
2443
2501
|
file_path: action.path,
|
|
2444
|
-
content: action.content,
|
|
2445
|
-
encoding:
|
|
2502
|
+
content: encodeRepoFilePayloadContent(action.content),
|
|
2503
|
+
encoding: GITLAB_REPO_FILE_ENCODING,
|
|
2446
2504
|
})),
|
|
2447
2505
|
}),
|
|
2448
2506
|
});
|
|
@@ -2926,7 +2984,7 @@ async function approveMergeRequest(projectId, mergeRequestIid, sha, approvalPass
|
|
|
2926
2984
|
body: JSON.stringify(body),
|
|
2927
2985
|
});
|
|
2928
2986
|
await handleGitLabError(response);
|
|
2929
|
-
return
|
|
2987
|
+
return parseApprovalsResponse(await response.json());
|
|
2930
2988
|
}
|
|
2931
2989
|
/**
|
|
2932
2990
|
* Unapprove a previously approved merge request
|
|
@@ -2944,7 +3002,7 @@ async function unapproveMergeRequest(projectId, mergeRequestIid) {
|
|
|
2944
3002
|
body: JSON.stringify({}),
|
|
2945
3003
|
});
|
|
2946
3004
|
await handleGitLabError(response);
|
|
2947
|
-
return
|
|
3005
|
+
return parseApprovalsResponse(await response.json());
|
|
2948
3006
|
}
|
|
2949
3007
|
/**
|
|
2950
3008
|
* Get the approval state of a merge request
|
|
@@ -2974,6 +3032,23 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
|
2974
3032
|
source_endpoint: "approval_state",
|
|
2975
3033
|
};
|
|
2976
3034
|
}
|
|
3035
|
+
/**
|
|
3036
|
+
* Get the conflicts of a merge request
|
|
3037
|
+
*
|
|
3038
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
3039
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
3040
|
+
* @returns {Promise<Record<string, unknown>>} The merge request conflicts
|
|
3041
|
+
*/
|
|
3042
|
+
async function getMergeRequestConflicts(projectId, mergeRequestIid) {
|
|
3043
|
+
projectId = decodeURIComponent(projectId);
|
|
3044
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/conflicts`);
|
|
3045
|
+
const response = await fetch(url.toString(), {
|
|
3046
|
+
...getFetchConfig(),
|
|
3047
|
+
method: "GET",
|
|
3048
|
+
});
|
|
3049
|
+
await handleGitLabError(response);
|
|
3050
|
+
return (await response.json());
|
|
3051
|
+
}
|
|
2977
3052
|
async function getMergeRequestApprovalsFallback(projectId, mergeRequestIid) {
|
|
2978
3053
|
const approvalsUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approvals`);
|
|
2979
3054
|
const approvalsResponse = await fetch(approvalsUrl.toString(), {
|
|
@@ -2981,7 +3056,16 @@ async function getMergeRequestApprovalsFallback(projectId, mergeRequestIid) {
|
|
|
2981
3056
|
method: "GET",
|
|
2982
3057
|
});
|
|
2983
3058
|
await handleGitLabError(approvalsResponse);
|
|
2984
|
-
|
|
3059
|
+
return parseApprovalsResponse(await approvalsResponse.json());
|
|
3060
|
+
}
|
|
3061
|
+
/**
|
|
3062
|
+
* Parse the response from POST /approve and POST /unapprove endpoints.
|
|
3063
|
+
* These endpoints return the approvals format (approved_by contains nested
|
|
3064
|
+
* { user: {...} } objects), which must be converted to the flat format
|
|
3065
|
+
* used by GitLabMergeRequestApprovalStateSchema.
|
|
3066
|
+
*/
|
|
3067
|
+
function parseApprovalsResponse(responseJson) {
|
|
3068
|
+
const parsedApprovals = GitLabMergeRequestApprovalsResponseSchema.parse(responseJson);
|
|
2985
3069
|
const approvedByUsers = getUniqueApprovalUsers((parsedApprovals.approved_by || []).map(approvedByEntry => approvedByEntry.user));
|
|
2986
3070
|
const approvedByUsernames = approvedByUsers.map(user => user.username);
|
|
2987
3071
|
return GitLabMergeRequestApprovalStateSchema.parse({
|
|
@@ -3046,7 +3130,7 @@ noteableIid, body) {
|
|
|
3046
3130
|
* @returns {Promise<GitLabDraftNote[]>} Array of draft notes
|
|
3047
3131
|
*/
|
|
3048
3132
|
async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
|
|
3049
|
-
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}
|
|
3133
|
+
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}`, { ...getFetchConfig() });
|
|
3050
3134
|
if (!response.ok) {
|
|
3051
3135
|
const errorText = await response.text();
|
|
3052
3136
|
throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
@@ -3624,6 +3708,89 @@ async function listGroupProjects(options) {
|
|
|
3624
3708
|
const projects = await response.json();
|
|
3625
3709
|
return GitLabProjectSchema.array().parse(projects);
|
|
3626
3710
|
}
|
|
3711
|
+
// Webhook API helper functions
|
|
3712
|
+
/**
|
|
3713
|
+
* Build the base URL for webhooks (project or group)
|
|
3714
|
+
*/
|
|
3715
|
+
function buildWebhookBaseUrl(projectId, groupId) {
|
|
3716
|
+
if (projectId) {
|
|
3717
|
+
projectId = decodeURIComponent(projectId);
|
|
3718
|
+
return `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/hooks`;
|
|
3719
|
+
}
|
|
3720
|
+
const decodedGroupId = decodeURIComponent(groupId);
|
|
3721
|
+
return `${getEffectiveApiUrl()}/groups/${encodeURIComponent(decodedGroupId)}/hooks`;
|
|
3722
|
+
}
|
|
3723
|
+
/**
|
|
3724
|
+
* List webhooks for a project or group
|
|
3725
|
+
*/
|
|
3726
|
+
async function listWebhooks(options) {
|
|
3727
|
+
const url = new URL(buildWebhookBaseUrl(options.project_id, options.group_id));
|
|
3728
|
+
if (options.page)
|
|
3729
|
+
url.searchParams.append("page", options.page.toString());
|
|
3730
|
+
if (options.per_page)
|
|
3731
|
+
url.searchParams.append("per_page", options.per_page.toString());
|
|
3732
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
3733
|
+
await handleGitLabError(response);
|
|
3734
|
+
return (await response.json());
|
|
3735
|
+
}
|
|
3736
|
+
/**
|
|
3737
|
+
* Summarize webhook events by stripping heavy payload fields
|
|
3738
|
+
*/
|
|
3739
|
+
function summarizeWebhookEvents(events) {
|
|
3740
|
+
return events.map(event => ({
|
|
3741
|
+
id: event.id,
|
|
3742
|
+
url: event.url,
|
|
3743
|
+
trigger: event.trigger,
|
|
3744
|
+
response_status: event.response_status,
|
|
3745
|
+
execution_duration: event.execution_duration,
|
|
3746
|
+
}));
|
|
3747
|
+
}
|
|
3748
|
+
/**
|
|
3749
|
+
* Fetch a single page of webhook events
|
|
3750
|
+
*/
|
|
3751
|
+
async function fetchWebhookEventsPage(baseUrl, page, perPage, status) {
|
|
3752
|
+
const url = new URL(baseUrl);
|
|
3753
|
+
url.searchParams.set("page", page.toString());
|
|
3754
|
+
url.searchParams.set("per_page", perPage.toString());
|
|
3755
|
+
if (status !== undefined)
|
|
3756
|
+
url.searchParams.append("status", String(status));
|
|
3757
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
3758
|
+
await handleGitLabError(response);
|
|
3759
|
+
return (await response.json());
|
|
3760
|
+
}
|
|
3761
|
+
/**
|
|
3762
|
+
* List webhook events for a project or group webhook
|
|
3763
|
+
*/
|
|
3764
|
+
async function listWebhookEvents(options) {
|
|
3765
|
+
const eventsUrl = `${buildWebhookBaseUrl(options.project_id, options.group_id)}/${options.hook_id}/events`;
|
|
3766
|
+
const events = await fetchWebhookEventsPage(eventsUrl, options.page ?? 1, options.per_page ?? 20, options.status);
|
|
3767
|
+
return options.summary ? summarizeWebhookEvents(events) : events;
|
|
3768
|
+
}
|
|
3769
|
+
/**
|
|
3770
|
+
* Get a specific webhook event by ID (searches up to 500 recent events)
|
|
3771
|
+
*/
|
|
3772
|
+
async function getWebhookEvent(options) {
|
|
3773
|
+
const eventsUrl = `${buildWebhookBaseUrl(options.project_id, options.group_id)}/${options.hook_id}/events`;
|
|
3774
|
+
// GitLab enforces max per_page=20 for webhook events
|
|
3775
|
+
const perPage = 20;
|
|
3776
|
+
if (options.page) {
|
|
3777
|
+
// Direct page lookup — single API call
|
|
3778
|
+
const events = await fetchWebhookEventsPage(eventsUrl, options.page, perPage);
|
|
3779
|
+
const match = events.find(e => e.id === options.event_id);
|
|
3780
|
+
return match ?? null;
|
|
3781
|
+
}
|
|
3782
|
+
// Auto-paginate up to 500 events
|
|
3783
|
+
const maxPages = 25;
|
|
3784
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
3785
|
+
const events = await fetchWebhookEventsPage(eventsUrl, page, perPage);
|
|
3786
|
+
const match = events.find(e => e.id === options.event_id);
|
|
3787
|
+
if (match)
|
|
3788
|
+
return match;
|
|
3789
|
+
if (events.length < perPage)
|
|
3790
|
+
break;
|
|
3791
|
+
}
|
|
3792
|
+
return null;
|
|
3793
|
+
}
|
|
3627
3794
|
// Wiki API helper functions
|
|
3628
3795
|
/**
|
|
3629
3796
|
* List wiki pages in a project
|
|
@@ -4056,11 +4223,8 @@ async function createPipeline(projectId, ref, variables, inputs) {
|
|
|
4056
4223
|
body.inputs = inputs;
|
|
4057
4224
|
}
|
|
4058
4225
|
const response = await fetch(url.toString(), {
|
|
4226
|
+
...getFetchConfig(),
|
|
4059
4227
|
method: "POST",
|
|
4060
|
-
headers: {
|
|
4061
|
-
...BASE_HEADERS,
|
|
4062
|
-
...buildAuthHeaders(),
|
|
4063
|
-
},
|
|
4064
4228
|
body: JSON.stringify(body),
|
|
4065
4229
|
});
|
|
4066
4230
|
await handleGitLabError(response);
|
|
@@ -4078,11 +4242,8 @@ async function retryPipeline(projectId, pipelineId) {
|
|
|
4078
4242
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
4079
4243
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
|
|
4080
4244
|
const response = await fetch(url.toString(), {
|
|
4245
|
+
...getFetchConfig(),
|
|
4081
4246
|
method: "POST",
|
|
4082
|
-
headers: {
|
|
4083
|
-
...BASE_HEADERS,
|
|
4084
|
-
...buildAuthHeaders(),
|
|
4085
|
-
},
|
|
4086
4247
|
});
|
|
4087
4248
|
await handleGitLabError(response);
|
|
4088
4249
|
const data = await response.json();
|
|
@@ -4099,11 +4260,8 @@ async function cancelPipeline(projectId, pipelineId) {
|
|
|
4099
4260
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
4100
4261
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
|
|
4101
4262
|
const response = await fetch(url.toString(), {
|
|
4263
|
+
...getFetchConfig(),
|
|
4102
4264
|
method: "POST",
|
|
4103
|
-
headers: {
|
|
4104
|
-
...BASE_HEADERS,
|
|
4105
|
-
...buildAuthHeaders(),
|
|
4106
|
-
},
|
|
4107
4265
|
});
|
|
4108
4266
|
await handleGitLabError(response);
|
|
4109
4267
|
const data = await response.json();
|
|
@@ -4194,13 +4352,7 @@ async function getRepositoryTree(options) {
|
|
|
4194
4352
|
queryParams.append("page_token", options.page_token);
|
|
4195
4353
|
if (options.pagination)
|
|
4196
4354
|
queryParams.append("pagination", options.pagination);
|
|
4197
|
-
const
|
|
4198
|
-
...BASE_HEADERS,
|
|
4199
|
-
...buildAuthHeaders(),
|
|
4200
|
-
};
|
|
4201
|
-
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
|
|
4202
|
-
headers,
|
|
4203
|
-
});
|
|
4355
|
+
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
|
|
4204
4356
|
if (response.status === 404) {
|
|
4205
4357
|
throw new Error("Repository or path not found");
|
|
4206
4358
|
}
|
|
@@ -4661,15 +4813,12 @@ async function markdownUpload(projectId, filePath) {
|
|
|
4661
4813
|
contentType: "application/octet-stream",
|
|
4662
4814
|
});
|
|
4663
4815
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads`);
|
|
4816
|
+
const defaultFetchConfig = getFetchConfig();
|
|
4817
|
+
delete defaultFetchConfig.headers["Content-Type"]; // Let form-data set the correct Content-Type with boundary
|
|
4664
4818
|
const response = await fetch(url.toString(), {
|
|
4819
|
+
...defaultFetchConfig,
|
|
4665
4820
|
method: "POST",
|
|
4666
|
-
|
|
4667
|
-
...BASE_HEADERS,
|
|
4668
|
-
...buildAuthHeaders(),
|
|
4669
|
-
// Remove Content-Type header to let form-data set it with boundary
|
|
4670
|
-
"Content-Type": undefined,
|
|
4671
|
-
},
|
|
4672
|
-
body: form,
|
|
4821
|
+
body: form
|
|
4673
4822
|
});
|
|
4674
4823
|
if (!response.ok) {
|
|
4675
4824
|
await handleGitLabError(response);
|
|
@@ -4695,11 +4844,8 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
|
4695
4844
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
4696
4845
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
|
|
4697
4846
|
const response = await fetch(url.toString(), {
|
|
4847
|
+
...getFetchConfig(),
|
|
4698
4848
|
method: "GET",
|
|
4699
|
-
headers: {
|
|
4700
|
-
...BASE_HEADERS,
|
|
4701
|
-
...buildAuthHeaders(),
|
|
4702
|
-
},
|
|
4703
4849
|
});
|
|
4704
4850
|
if (!response.ok) {
|
|
4705
4851
|
await handleGitLabError(response);
|
|
@@ -4746,13 +4892,7 @@ async function listEvents(options = {}) {
|
|
|
4746
4892
|
url.searchParams.append(key, value.toString());
|
|
4747
4893
|
}
|
|
4748
4894
|
});
|
|
4749
|
-
const response = await fetch(url.toString(), {
|
|
4750
|
-
method: "GET",
|
|
4751
|
-
headers: {
|
|
4752
|
-
...BASE_HEADERS,
|
|
4753
|
-
...buildAuthHeaders(),
|
|
4754
|
-
},
|
|
4755
|
-
});
|
|
4895
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
4756
4896
|
if (!response.ok) {
|
|
4757
4897
|
await handleGitLabError(response);
|
|
4758
4898
|
}
|
|
@@ -4774,13 +4914,7 @@ async function getProjectEvents(projectId, options = {}) {
|
|
|
4774
4914
|
url.searchParams.append(key, value.toString());
|
|
4775
4915
|
}
|
|
4776
4916
|
});
|
|
4777
|
-
const response = await fetch(url.toString(), {
|
|
4778
|
-
method: "GET",
|
|
4779
|
-
headers: {
|
|
4780
|
-
...BASE_HEADERS,
|
|
4781
|
-
...buildAuthHeaders(),
|
|
4782
|
-
},
|
|
4783
|
-
});
|
|
4917
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
4784
4918
|
if (!response.ok) {
|
|
4785
4919
|
await handleGitLabError(response);
|
|
4786
4920
|
}
|
|
@@ -5271,6 +5405,13 @@ async function handleToolCall(params) {
|
|
|
5271
5405
|
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
5272
5406
|
};
|
|
5273
5407
|
}
|
|
5408
|
+
case "get_merge_request_conflicts": {
|
|
5409
|
+
const args = GetMergeRequestConflictsSchema.parse(params.arguments);
|
|
5410
|
+
const conflicts = await getMergeRequestConflicts(args.project_id, args.merge_request_iid);
|
|
5411
|
+
return {
|
|
5412
|
+
content: [{ type: "text", text: JSON.stringify(conflicts, null, 2) }],
|
|
5413
|
+
};
|
|
5414
|
+
}
|
|
5274
5415
|
case "mr_discussions": {
|
|
5275
5416
|
const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
|
|
5276
5417
|
const { project_id, merge_request_iid, ...options } = args;
|
|
@@ -6102,6 +6243,40 @@ async function handleToolCall(params) {
|
|
|
6102
6243
|
content: [{ type: "text", text: assetContent }],
|
|
6103
6244
|
};
|
|
6104
6245
|
}
|
|
6246
|
+
case "list_webhooks": {
|
|
6247
|
+
const args = ListWebhooksSchema.parse(params.arguments);
|
|
6248
|
+
const webhooks = await listWebhooks(args);
|
|
6249
|
+
return {
|
|
6250
|
+
content: [{ type: "text", text: JSON.stringify(webhooks, null, 2) }],
|
|
6251
|
+
};
|
|
6252
|
+
}
|
|
6253
|
+
case "list_webhook_events": {
|
|
6254
|
+
const args = ListWebhookEventsSchema.parse(params.arguments);
|
|
6255
|
+
const events = await listWebhookEvents(args);
|
|
6256
|
+
return {
|
|
6257
|
+
content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
|
|
6258
|
+
};
|
|
6259
|
+
}
|
|
6260
|
+
case "get_webhook_event": {
|
|
6261
|
+
const args = GetWebhookEventSchema.parse(params.arguments);
|
|
6262
|
+
const event = await getWebhookEvent(args);
|
|
6263
|
+
if (!event) {
|
|
6264
|
+
const searchScope = args.page
|
|
6265
|
+
? `on page ${args.page}`
|
|
6266
|
+
: "in the 500 most recent events";
|
|
6267
|
+
return {
|
|
6268
|
+
content: [
|
|
6269
|
+
{
|
|
6270
|
+
type: "text",
|
|
6271
|
+
text: JSON.stringify({ error: `Webhook event ${args.event_id} not found ${searchScope}` }, null, 2),
|
|
6272
|
+
},
|
|
6273
|
+
],
|
|
6274
|
+
};
|
|
6275
|
+
}
|
|
6276
|
+
return {
|
|
6277
|
+
content: [{ type: "text", text: JSON.stringify(event, null, 2) }],
|
|
6278
|
+
};
|
|
6279
|
+
}
|
|
6105
6280
|
default:
|
|
6106
6281
|
throw new Error(`Unknown tool: ${params.name}`);
|
|
6107
6282
|
}
|
|
@@ -6147,6 +6322,10 @@ function determineTransportMode() {
|
|
|
6147
6322
|
async function startStdioServer() {
|
|
6148
6323
|
const serverInstance = createServer();
|
|
6149
6324
|
const transport = new StdioServerTransport();
|
|
6325
|
+
transport.onclose = () => {
|
|
6326
|
+
logger.info("Stdio transport closed, releasing client pool");
|
|
6327
|
+
clientPool.closeAll();
|
|
6328
|
+
};
|
|
6150
6329
|
await serverInstance.connect(transport);
|
|
6151
6330
|
}
|
|
6152
6331
|
/**
|
|
@@ -6155,6 +6334,7 @@ async function startStdioServer() {
|
|
|
6155
6334
|
async function startSSEServer() {
|
|
6156
6335
|
const app = express();
|
|
6157
6336
|
const transports = {};
|
|
6337
|
+
let shuttingDown = false;
|
|
6158
6338
|
app.get("/sse", async (_, res) => {
|
|
6159
6339
|
const serverInstance = createServer();
|
|
6160
6340
|
const transport = new SSEServerTransport("/messages", res);
|
|
@@ -6181,12 +6361,35 @@ async function startSSEServer() {
|
|
|
6181
6361
|
transport: TransportMode.SSE,
|
|
6182
6362
|
});
|
|
6183
6363
|
});
|
|
6184
|
-
app.listen(Number(PORT), HOST, () => {
|
|
6364
|
+
const httpServer = app.listen(Number(PORT), HOST, () => {
|
|
6185
6365
|
logger.info(`GitLab MCP Server running with SSE transport`);
|
|
6186
6366
|
const colorGreen = "\x1b[32m";
|
|
6187
6367
|
const colorReset = "\x1b[0m";
|
|
6188
6368
|
logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/sse${colorReset}`);
|
|
6189
6369
|
});
|
|
6370
|
+
const shutdown = async (signal) => {
|
|
6371
|
+
if (shuttingDown)
|
|
6372
|
+
return;
|
|
6373
|
+
shuttingDown = true;
|
|
6374
|
+
logger.info(`${signal} received, shutting down SSE server...`);
|
|
6375
|
+
httpServer.close(() => logger.info("SSE HTTP server closed"));
|
|
6376
|
+
await Promise.allSettled(Object.values(transports).map(async (transport) => {
|
|
6377
|
+
try {
|
|
6378
|
+
await transport.close();
|
|
6379
|
+
}
|
|
6380
|
+
catch (error) {
|
|
6381
|
+
logger.error("Error closing SSE transport:", error);
|
|
6382
|
+
}
|
|
6383
|
+
}));
|
|
6384
|
+
clientPool.closeAll();
|
|
6385
|
+
process.exit(0);
|
|
6386
|
+
};
|
|
6387
|
+
process.on("SIGTERM", () => {
|
|
6388
|
+
void shutdown("SIGTERM");
|
|
6389
|
+
});
|
|
6390
|
+
process.on("SIGINT", () => {
|
|
6391
|
+
void shutdown("SIGINT");
|
|
6392
|
+
});
|
|
6190
6393
|
}
|
|
6191
6394
|
/**
|
|
6192
6395
|
* Start server with Streamable HTTP transport
|
|
@@ -6240,10 +6443,12 @@ async function startStreamableHTTPServer() {
|
|
|
6240
6443
|
/**
|
|
6241
6444
|
* Parse authentication from request headers
|
|
6242
6445
|
* Returns null if no auth found or invalid format
|
|
6446
|
+
* Supports: JOB-TOKEN header, Private-Token header, Authorization Bearer header
|
|
6243
6447
|
*/
|
|
6244
6448
|
const parseAuthHeaders = (req) => {
|
|
6245
6449
|
const authHeader = req.headers["authorization"] || "";
|
|
6246
6450
|
const privateToken = req.headers["private-token"] || "";
|
|
6451
|
+
const jobToken = req.headers["job-token"] || "";
|
|
6247
6452
|
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
|
|
6248
6453
|
let apiUrl = GITLAB_API_URL; // Default API URL
|
|
6249
6454
|
// Only process dynamic URL if the feature is enabled
|
|
@@ -6260,7 +6465,11 @@ async function startStreamableHTTPServer() {
|
|
|
6260
6465
|
// Extract token
|
|
6261
6466
|
let token = null;
|
|
6262
6467
|
let header = null;
|
|
6263
|
-
if (
|
|
6468
|
+
if (jobToken) {
|
|
6469
|
+
token = jobToken.trim();
|
|
6470
|
+
header = "JOB-TOKEN";
|
|
6471
|
+
}
|
|
6472
|
+
else if (privateToken) {
|
|
6264
6473
|
token = privateToken.trim();
|
|
6265
6474
|
header = "Private-Token";
|
|
6266
6475
|
}
|
|
@@ -6348,8 +6557,8 @@ async function startStreamableHTTPServer() {
|
|
|
6348
6557
|
if (!authData) {
|
|
6349
6558
|
metrics.authFailures++;
|
|
6350
6559
|
res.status(401).json({
|
|
6351
|
-
error: "Missing Authorization
|
|
6352
|
-
message: "Remote authorization is enabled. Please provide Authorization
|
|
6560
|
+
error: "Missing Authorization, Private-Token, or JOB-TOKEN header",
|
|
6561
|
+
message: "Remote authorization is enabled. Please provide Authorization, Private-Token, or JOB-TOKEN header.",
|
|
6353
6562
|
});
|
|
6354
6563
|
return;
|
|
6355
6564
|
}
|
|
@@ -6547,6 +6756,7 @@ async function startStreamableHTTPServer() {
|
|
|
6547
6756
|
Object.keys(authTimeouts).forEach(sessionId => {
|
|
6548
6757
|
clearAuthTimeout(sessionId);
|
|
6549
6758
|
});
|
|
6759
|
+
clientPool.closeAll();
|
|
6550
6760
|
logger.info("Graceful shutdown complete");
|
|
6551
6761
|
process.exit(0);
|
|
6552
6762
|
};
|