@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/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.getOrCreateAgentForUrl(effectiveApiUrl);
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
- return GITLAB_PROJECT_ID || projectId;
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: "text",
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: "text",
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: "text",
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 GitLabMergeRequestApprovalStateSchema.parse(await response.json());
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 GitLabMergeRequestApprovalStateSchema.parse(await response.json());
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
- const parsedApprovals = GitLabMergeRequestApprovalsResponseSchema.parse(await approvalsResponse.json());
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 headers = {
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
- headers: {
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 (privateToken) {
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 or Private-Token header",
6352
- message: "Remote authorization is enabled. Please provide Authorization or Private-Token header.",
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
  };