@zereight/mcp-gitlab 2.0.33 → 2.0.35

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
@@ -37,20 +37,23 @@ import { fileURLToPath, URL } from "node:url";
37
37
  import { z } from "zod";
38
38
  import { zodToJsonSchema } from "zod-to-json-schema";
39
39
  import { initializeOAuthClient } from "./oauth.js";
40
+ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
41
+ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
42
+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
40
43
  import { GitLabClientPool } from "./gitlab-client-pool.js";
41
44
  // Add type imports for proxy agents
42
45
  import { Agent } from "node:http";
43
46
  import { Agent as HttpsAgent } from "node:https";
44
47
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
45
- 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, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema,
48
+ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema,
46
49
  // pipeline job schemas
47
50
  GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
48
51
  // Discussion Schemas
49
52
  GitLabDiscussionNoteSchema, // Added
50
53
  GitLabDiscussionSchema,
51
54
  // Draft Notes Schemas
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, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
55
+ GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
56
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, 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, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
54
57
  import { randomUUID } from "node:crypto";
55
58
  import { pino } from "pino";
56
59
  const logger = pino({
@@ -228,8 +231,37 @@ function validateConfiguration() {
228
231
  const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
229
232
  const hasJobToken = !!getConfig("job-token", "GITLAB_JOB_TOKEN");
230
233
  const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
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)");
234
+ const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
235
+ const mcpServerUrl = getConfig("mcp-server-url", "MCP_SERVER_URL");
236
+ if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie && !mcpOAuth) {
237
+ errors.push("Either --token, --job-token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables)");
238
+ }
239
+ if (mcpOAuth) {
240
+ if (!mcpServerUrl) {
241
+ errors.push("MCP_SERVER_URL is required when GITLAB_MCP_OAUTH=true (e.g. https://mcp.example.com)");
242
+ }
243
+ else {
244
+ try {
245
+ const u = new URL(mcpServerUrl);
246
+ const isInsecure = u.protocol !== "https:";
247
+ const isLocalhost = u.hostname === "localhost" || u.hostname === "127.0.0.1";
248
+ const allowInsecure = process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === "true";
249
+ if (isInsecure && !isLocalhost && !allowInsecure) {
250
+ errors.push("MCP_SERVER_URL must use HTTPS in production " +
251
+ "(set MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true for local dev)");
252
+ }
253
+ }
254
+ catch {
255
+ errors.push(`MCP_SERVER_URL is not a valid URL: ${mcpServerUrl}`);
256
+ }
257
+ }
258
+ if (!getConfig("api-url", "GITLAB_API_URL")) {
259
+ errors.push("GITLAB_API_URL is required when GITLAB_MCP_OAUTH=true");
260
+ }
261
+ if (!getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID")) {
262
+ errors.push("GITLAB_OAUTH_APP_ID is required when GITLAB_MCP_OAUTH=true " +
263
+ "(create an OAuth application in GitLab Admin with the required scopes)");
264
+ }
233
265
  }
234
266
  const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
235
267
  if (enableDynamicApiUrl && !remoteAuth) {
@@ -283,7 +315,8 @@ const GITLAB_DENIED_TOOLS_REGEX = (() => {
283
315
  }
284
316
  // Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
285
317
  // e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
286
- const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]|\(\?[^:)]/;
318
+ // Note: lookahead (?!), (?=), lookbehind (?<), and named groups (?<name>) are safe and allowed
319
+ const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]/;
287
320
  if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
288
321
  logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
289
322
  return undefined;
@@ -307,6 +340,9 @@ const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
307
340
  const SSE = getConfig("sse", "SSE") === "true";
308
341
  const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
309
342
  const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
343
+ const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
344
+ const MCP_SERVER_URL = getConfig("mcp-server-url", "MCP_SERVER_URL");
345
+ const GITLAB_OAUTH_APP_ID = getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID");
310
346
  const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
311
347
  const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig("session-timeout", "SESSION_TIMEOUT_SECONDS", "3600"), 10);
312
348
  const HOST = getConfig("host", "HOST") || "127.0.0.1";
@@ -314,6 +350,7 @@ const PORT = Number.parseInt(getConfig("port", "PORT", "3002"), 10);
314
350
  // Add proxy configuration
315
351
  const HTTP_PROXY = getConfig("http-proxy", "HTTP_PROXY");
316
352
  const HTTPS_PROXY = getConfig("https-proxy", "HTTPS_PROXY");
353
+ const NO_PROXY = getConfig("no-proxy", "NO_PROXY");
317
354
  const NODE_TLS_REJECT_UNAUTHORIZED = getConfig("tls-reject-unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED");
318
355
  const GITLAB_CA_CERT_PATH = getConfig("ca-cert-path", "GITLAB_CA_CERT_PATH");
319
356
  const GITLAB_POOL_MAX_SIZE = getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE")
@@ -355,6 +392,7 @@ const clientPool = new GitLabClientPool({
355
392
  .map(normalizeGitLabApiUrl),
356
393
  httpProxy: HTTP_PROXY,
357
394
  httpsProxy: HTTPS_PROXY,
395
+ noProxy: NO_PROXY,
358
396
  rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
359
397
  caCertPath: GITLAB_CA_CERT_PATH,
360
398
  poolMaxSize: GITLAB_POOL_MAX_SIZE,
@@ -480,11 +518,11 @@ const BASE_HEADERS = {
480
518
  };
481
519
  /**
482
520
  * Build authentication headers dynamically based on context
483
- * In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
521
+ * In REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH mode, reads from AsyncLocalStorage session context
484
522
  * Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
485
523
  */
486
524
  function buildAuthHeaders() {
487
- if (REMOTE_AUTHORIZATION) {
525
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
488
526
  const ctx = sessionAuthStore.getStore();
489
527
  logger.debug({ context: ctx }, "buildAuthHeaders: session context");
490
528
  if (ctx?.token) {
@@ -535,7 +573,7 @@ function getEffectiveApiUrl() {
535
573
  */
536
574
  const getFetchConfig = () => {
537
575
  const effectiveApiUrl = getEffectiveApiUrl();
538
- const agent = clientPool.getOrCreateAgentForUrl(effectiveApiUrl);
576
+ const agent = clientPool.getAgentFunctionForUrl(effectiveApiUrl);
539
577
  return {
540
578
  headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
541
579
  agent: agent,
@@ -603,6 +641,11 @@ const allTools = [
603
641
  description: "Get merge request approval details including approvers (uses approval_state when available, falls back to approvals endpoint)",
604
642
  inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
605
643
  },
644
+ {
645
+ name: "get_merge_request_conflicts",
646
+ description: "Get the conflicts of a merge request in a GitLab project",
647
+ inputSchema: toJSONSchema(GetMergeRequestConflictsSchema),
648
+ },
606
649
  {
607
650
  name: "execute_graphql",
608
651
  description: "Execute a GitLab GraphQL query",
@@ -663,11 +706,32 @@ const allTools = [
663
706
  description: "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)",
664
707
  inputSchema: toJSONSchema(GetMergeRequestDiffsSchema),
665
708
  },
709
+ {
710
+ name: "list_merge_request_changed_files",
711
+ description: "STEP 1 of code review workflow. " +
712
+ "Returns ONLY the list of changed file paths in a merge request — WITHOUT diff content. " +
713
+ "Call this first to get file paths, then call get_merge_request_file_diff with multiple files in a single batched call (recommended 3-5 files per call). " +
714
+ "This avoids loading the entire diff payload at once and reduces API calls. " +
715
+ "Supports excluded_file_patterns filtering using regex. " +
716
+ "Returns: new_path, old_path, new_file, deleted_file, renamed_file flags for each file. " +
717
+ "(Either mergeRequestIid or branchName must be provided)",
718
+ inputSchema: toJSONSchema(ListMergeRequestChangedFilesSchema),
719
+ },
666
720
  {
667
721
  name: "list_merge_request_diffs",
668
722
  description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
669
723
  inputSchema: toJSONSchema(ListMergeRequestDiffsSchema),
670
724
  },
725
+ {
726
+ name: "get_merge_request_file_diff",
727
+ description: "STEP 2 of code review workflow. " +
728
+ "Get diffs for one or more files from a merge request. " +
729
+ "Call list_merge_request_changed_files first to get file paths, then pass them as an array to fetch their diffs efficiently. " +
730
+ "Batching multiple files (recommended 3-5) is supported and preferred over individual requests. " +
731
+ "Returns an array of results - one per requested file path. Files not found are returned with error messages. " +
732
+ "(Either mergeRequestIid or branchName must be provided)",
733
+ inputSchema: toJSONSchema(GetMergeRequestFileDiffSchema),
734
+ },
671
735
  {
672
736
  name: "list_merge_request_versions",
673
737
  description: "List all versions of a merge request",
@@ -928,6 +992,31 @@ const allTools = [
928
992
  description: "Delete a wiki page from a GitLab project",
929
993
  inputSchema: toJSONSchema(DeleteWikiPageSchema),
930
994
  },
995
+ {
996
+ name: "list_group_wiki_pages",
997
+ description: "List wiki pages in a GitLab group",
998
+ inputSchema: toJSONSchema(ListGroupWikiPagesSchema),
999
+ },
1000
+ {
1001
+ name: "get_group_wiki_page",
1002
+ description: "Get details of a specific group wiki page",
1003
+ inputSchema: toJSONSchema(GetGroupWikiPageSchema),
1004
+ },
1005
+ {
1006
+ name: "create_group_wiki_page",
1007
+ description: "Create a new wiki page in a GitLab group",
1008
+ inputSchema: toJSONSchema(CreateGroupWikiPageSchema),
1009
+ },
1010
+ {
1011
+ name: "update_group_wiki_page",
1012
+ description: "Update an existing wiki page in a GitLab group",
1013
+ inputSchema: toJSONSchema(UpdateGroupWikiPageSchema),
1014
+ },
1015
+ {
1016
+ name: "delete_group_wiki_page",
1017
+ description: "Delete a wiki page from a GitLab group",
1018
+ inputSchema: toJSONSchema(DeleteGroupWikiPageSchema),
1019
+ },
931
1020
  {
932
1021
  name: "get_repository_tree",
933
1022
  description: "Get the repository tree for a GitLab project (list files and directories)",
@@ -1158,6 +1247,68 @@ const allTools = [
1158
1247
  description: "Download a release asset file by direct asset path",
1159
1248
  inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
1160
1249
  },
1250
+ // --- Work item tools (GraphQL-based) ---
1251
+ {
1252
+ name: "get_work_item",
1253
+ description: "Get a single work item with full details including status, hierarchy (parent/children), type, labels, assignees, and all widgets.",
1254
+ inputSchema: toJSONSchema(GetWorkItemSchema),
1255
+ },
1256
+ {
1257
+ name: "list_work_items",
1258
+ description: "List work items in a project with filters (type, state, search, assignees, labels). Returns items with status and hierarchy info.",
1259
+ inputSchema: toJSONSchema(ListWorkItemsSchema),
1260
+ },
1261
+ {
1262
+ name: "create_work_item",
1263
+ description: "Create a new work item (issue, task, incident, test_case, epic, key_result, objective, requirement, ticket). Supports setting title, description, labels, assignees, weight, parent, health status, start/due dates, milestone, and confidentiality.",
1264
+ inputSchema: toJSONSchema(CreateWorkItemSchema),
1265
+ },
1266
+ {
1267
+ name: "update_work_item",
1268
+ description: "Update a work item. Can modify title, description, labels, assignees, weight, state, status, parent hierarchy, children, health status, start/due dates, milestone, confidentiality, linked items, and custom fields.",
1269
+ inputSchema: toJSONSchema(UpdateWorkItemSchema),
1270
+ },
1271
+ {
1272
+ name: "convert_work_item_type",
1273
+ description: "Convert a work item to a different type (e.g. issue to task, task to incident).",
1274
+ inputSchema: toJSONSchema(ConvertWorkItemTypeSchema),
1275
+ },
1276
+ {
1277
+ name: "list_work_item_statuses",
1278
+ description: "List available statuses for a work item type in a project. Requires GitLab Premium/Ultimate with configurable statuses.",
1279
+ inputSchema: toJSONSchema(ListWorkItemStatusesSchema),
1280
+ },
1281
+ {
1282
+ name: "list_custom_field_definitions",
1283
+ description: "List available custom field definitions for a work item type in a project. Returns field names, types, and IDs needed for setting custom fields via update_work_item.",
1284
+ inputSchema: toJSONSchema(ListCustomFieldDefinitionsSchema),
1285
+ },
1286
+ {
1287
+ name: "move_work_item",
1288
+ description: "Move a work item (issue, task, etc.) to a different project. Uses GitLab GraphQL issueMove mutation.",
1289
+ inputSchema: toJSONSchema(MoveWorkItemSchema),
1290
+ },
1291
+ {
1292
+ name: "list_work_item_notes",
1293
+ description: "List notes and discussions on a work item. Returns threaded discussions with author, body, timestamps, and system/internal flags.",
1294
+ inputSchema: toJSONSchema(ListWorkItemNotesSchema),
1295
+ },
1296
+ {
1297
+ name: "create_work_item_note",
1298
+ description: "Add a note/comment to a work item. Supports Markdown, internal notes, and threaded replies.",
1299
+ inputSchema: toJSONSchema(CreateWorkItemNoteSchema),
1300
+ },
1301
+ // --- Incident timeline event tools ---
1302
+ {
1303
+ name: "get_timeline_events",
1304
+ description: "List timeline events for an incident. Returns chronological events with notes, timestamps, and tags (Start time, End time, Impact detected, etc.).",
1305
+ inputSchema: toJSONSchema(GetTimelineEventsSchema),
1306
+ },
1307
+ {
1308
+ name: "create_timeline_event",
1309
+ description: "Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'.",
1310
+ inputSchema: toJSONSchema(CreateTimelineEventSchema),
1311
+ },
1161
1312
  {
1162
1313
  name: "list_webhooks",
1163
1314
  description: "List all configured webhooks for a GitLab project or group. Provide either project_id or group_id.",
@@ -1173,17 +1324,42 @@ const allTools = [
1173
1324
  description: "Get full details of a specific webhook event by ID, including request/response payloads. Searches up to 500 most recent events.",
1174
1325
  inputSchema: toJSONSchema(GetWebhookEventSchema),
1175
1326
  },
1327
+ {
1328
+ name: "search_code",
1329
+ description: "Search for code across all projects on the GitLab instance (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
1330
+ inputSchema: toJSONSchema(SearchCodeSchema),
1331
+ },
1332
+ {
1333
+ name: "search_project_code",
1334
+ description: "Search for code within a specific GitLab project (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
1335
+ inputSchema: toJSONSchema(SearchProjectCodeSchema),
1336
+ },
1337
+ {
1338
+ name: "search_group_code",
1339
+ description: "Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
1340
+ inputSchema: toJSONSchema(SearchGroupCodeSchema),
1341
+ },
1176
1342
  ];
1177
1343
  // Define which tools are read-only
1178
1344
  const readOnlyTools = new Set([
1179
1345
  "search_repositories",
1346
+ "search_code",
1347
+ "search_project_code",
1348
+ "search_group_code",
1180
1349
  "execute_graphql",
1181
1350
  "get_file_contents",
1182
1351
  "get_merge_request",
1183
1352
  "get_merge_request_diffs",
1353
+ "list_merge_request_changed_files",
1354
+ "list_merge_request_diffs",
1355
+ "get_merge_request_file_diff",
1184
1356
  "list_merge_request_versions",
1185
1357
  "get_merge_request_version",
1186
1358
  "get_branch_diffs",
1359
+ "get_merge_request_note",
1360
+ "get_merge_request_notes",
1361
+ "get_draft_note",
1362
+ "list_draft_notes",
1187
1363
  "mr_discussions",
1188
1364
  "list_issues",
1189
1365
  "my_issues",
@@ -1222,6 +1398,8 @@ const readOnlyTools = new Set([
1222
1398
  "get_milestone_burndown_events",
1223
1399
  "list_wiki_pages",
1224
1400
  "get_wiki_page",
1401
+ "list_group_wiki_pages",
1402
+ "get_group_wiki_page",
1225
1403
  "get_users",
1226
1404
  "list_commits",
1227
1405
  "get_commit",
@@ -1235,6 +1413,13 @@ const readOnlyTools = new Set([
1235
1413
  "get_release",
1236
1414
  "download_release_asset",
1237
1415
  "get_merge_request_approval_state",
1416
+ "get_work_item",
1417
+ "list_work_items",
1418
+ "list_work_item_statuses",
1419
+ "list_custom_field_definitions",
1420
+ "list_work_item_notes",
1421
+ "get_timeline_events",
1422
+ "get_merge_request_conflicts",
1238
1423
  "list_webhooks",
1239
1424
  "list_webhook_events",
1240
1425
  "get_webhook_event",
@@ -1246,6 +1431,11 @@ const wikiToolNames = new Set([
1246
1431
  "create_wiki_page",
1247
1432
  "update_wiki_page",
1248
1433
  "delete_wiki_page",
1434
+ "list_group_wiki_pages",
1435
+ "get_group_wiki_page",
1436
+ "create_group_wiki_page",
1437
+ "update_group_wiki_page",
1438
+ "delete_group_wiki_page",
1249
1439
  "upload_wiki_attachment",
1250
1440
  ]);
1251
1441
  // Define which tools are related to milestones and can be toggled by USE_MILESTONE
@@ -1291,9 +1481,12 @@ const TOOLSET_DEFINITIONS = [
1291
1481
  "approve_merge_request",
1292
1482
  "unapprove_merge_request",
1293
1483
  "get_merge_request_approval_state",
1484
+ "get_merge_request_conflicts",
1294
1485
  "get_merge_request",
1295
1486
  "get_merge_request_diffs",
1487
+ "list_merge_request_changed_files",
1296
1488
  "list_merge_request_diffs",
1489
+ "get_merge_request_file_diff",
1297
1490
  "list_merge_request_versions",
1298
1491
  "get_merge_request_version",
1299
1492
  "update_merge_request",
@@ -1437,6 +1630,11 @@ const TOOLSET_DEFINITIONS = [
1437
1630
  "create_wiki_page",
1438
1631
  "update_wiki_page",
1439
1632
  "delete_wiki_page",
1633
+ "list_group_wiki_pages",
1634
+ "get_group_wiki_page",
1635
+ "create_group_wiki_page",
1636
+ "update_group_wiki_page",
1637
+ "delete_group_wiki_page",
1440
1638
  ]),
1441
1639
  },
1442
1640
  {
@@ -1463,6 +1661,24 @@ const TOOLSET_DEFINITIONS = [
1463
1661
  "download_attachment",
1464
1662
  ]),
1465
1663
  },
1664
+ {
1665
+ id: "workitems",
1666
+ isDefault: false,
1667
+ tools: new Set([
1668
+ "get_work_item",
1669
+ "list_work_items",
1670
+ "create_work_item",
1671
+ "update_work_item",
1672
+ "convert_work_item_type",
1673
+ "list_work_item_statuses",
1674
+ "list_custom_field_definitions",
1675
+ "move_work_item",
1676
+ "list_work_item_notes",
1677
+ "create_work_item_note",
1678
+ "get_timeline_events",
1679
+ "create_timeline_event",
1680
+ ]),
1681
+ },
1466
1682
  {
1467
1683
  id: "webhooks",
1468
1684
  isDefault: false,
@@ -1472,6 +1688,11 @@ const TOOLSET_DEFINITIONS = [
1472
1688
  "get_webhook_event",
1473
1689
  ]),
1474
1690
  },
1691
+ {
1692
+ id: "search",
1693
+ isDefault: false,
1694
+ tools: new Set(["search_code", "search_project_code", "search_group_code"]),
1695
+ },
1475
1696
  ];
1476
1697
  // Derived lookup: tool name → toolset ID
1477
1698
  const TOOLSET_BY_TOOL_NAME = new Map();
@@ -1583,6 +1804,9 @@ const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split
1583
1804
  const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE
1584
1805
  ? Number.parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE, 10)
1585
1806
  : 20;
1807
+ const GITLAB_REPO_FILE_ENCODING = getConfig("repo-file-encoding", "GITLAB_REPO_FILE_ENCODING", "text") === "base64"
1808
+ ? "base64"
1809
+ : "text";
1586
1810
  // Validate authentication configuration
1587
1811
  if (REMOTE_AUTHORIZATION) {
1588
1812
  // Remote authorization mode: token comes from HTTP headers
@@ -1598,7 +1822,20 @@ if (REMOTE_AUTHORIZATION) {
1598
1822
  }
1599
1823
  logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
1600
1824
  }
1601
- else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1825
+ if (GITLAB_MCP_OAUTH) {
1826
+ if (SSE) {
1827
+ logger.error("GITLAB_MCP_OAUTH=true is not compatible with SSE transport mode");
1828
+ logger.error("Please use STREAMABLE_HTTP=true instead");
1829
+ process.exit(1);
1830
+ }
1831
+ if (!STREAMABLE_HTTP) {
1832
+ logger.error("GITLAB_MCP_OAUTH=true requires STREAMABLE_HTTP=true");
1833
+ logger.error("Set STREAMABLE_HTTP=true to enable MCP OAuth");
1834
+ process.exit(1);
1835
+ }
1836
+ logger.info("MCP OAuth enabled: GitLab OAuth proxy active");
1837
+ }
1838
+ if (!REMOTE_AUTHORIZATION && !GITLAB_MCP_OAUTH && !USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1602
1839
  // Standard mode: token must be in environment (unless using OAuth)
1603
1840
  logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
1604
1841
  logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
@@ -1647,7 +1884,14 @@ function getEffectiveProjectId(projectId) {
1647
1884
  }
1648
1885
  return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
1649
1886
  }
1650
- return GITLAB_PROJECT_ID || projectId;
1887
+ // Prioritize the passed projectId over GITLAB_PROJECT_ID to allow querying different projects
1888
+ if (projectId) {
1889
+ return projectId;
1890
+ }
1891
+ if (GITLAB_PROJECT_ID) {
1892
+ return GITLAB_PROJECT_ID;
1893
+ }
1894
+ throw new Error("No project ID provided and GITLAB_PROJECT_ID is not set");
1651
1895
  }
1652
1896
  /**
1653
1897
  * Create a fork of a GitLab project
@@ -1753,28 +1997,19 @@ async function getFileContents(projectId, filePath, ref) {
1753
1997
  }
1754
1998
  return parsedData;
1755
1999
  }
1756
- /**
1757
- * Create a new issue in a GitLab project
1758
- * 이슈 생성 (Create an issue)
1759
- *
1760
- * @param {string} projectId - The ID or URL-encoded path of the project
1761
- * @param {z.infer<typeof CreateIssueOptionsSchema>} options - Issue creation options
1762
- * @returns {Promise<GitLabIssue>} The created issue
1763
- */
1764
2000
  async function createIssue(projectId, options) {
1765
2001
  projectId = decodeURIComponent(projectId); // Decode project ID
1766
2002
  const effectiveProjectId = getEffectiveProjectId(projectId);
1767
2003
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/issues`);
2004
+ // Build request body, converting labels array to comma-separated string
2005
+ const body = { ...options };
2006
+ if (body.labels && Array.isArray(body.labels)) {
2007
+ body.labels = body.labels.join(",");
2008
+ }
1768
2009
  const response = await fetch(url.toString(), {
1769
2010
  ...getFetchConfig(),
1770
2011
  method: "POST",
1771
- body: JSON.stringify({
1772
- title: options.title,
1773
- description: options.description,
1774
- assignee_ids: options.assignee_ids,
1775
- milestone_id: options.milestone_id,
1776
- labels: options.labels?.join(","),
1777
- }),
2012
+ body: JSON.stringify(body),
1778
2013
  });
1779
2014
  // Handle bad request
1780
2015
  if (response.status === 400) {
@@ -1924,6 +2159,1392 @@ async function deleteIssue(projectId, issueIid) {
1924
2159
  });
1925
2160
  await handleGitLabError(response);
1926
2161
  }
2162
+ // --- GraphQL helper ---
2163
+ /**
2164
+ * Execute a GraphQL query against the GitLab instance.
2165
+ * Reusable helper for work item operations.
2166
+ */
2167
+ async function executeGraphQL(query, variables = {}) {
2168
+ const apiUrl = new URL(getEffectiveApiUrl());
2169
+ const restPath = apiUrl.pathname || "";
2170
+ const idx = restPath.lastIndexOf("/api/v4");
2171
+ const prefix = idx >= 0 ? restPath.slice(0, idx) : "";
2172
+ const graphqlUrl = process.env.GITLAB_GRAPHQL_URL || `${apiUrl.origin}${prefix}/api/graphql`;
2173
+ const response = await fetch(graphqlUrl, {
2174
+ ...getFetchConfig(),
2175
+ method: "POST",
2176
+ headers: {
2177
+ ...BASE_HEADERS,
2178
+ ...buildAuthHeaders(),
2179
+ },
2180
+ body: JSON.stringify({ query, variables }),
2181
+ });
2182
+ if (!response.ok) {
2183
+ const errorBody = await response.text();
2184
+ throw new Error(`GraphQL request failed (${response.status}): ${errorBody}`);
2185
+ }
2186
+ const json = await response.json();
2187
+ if (json.errors && json.errors.length > 0) {
2188
+ throw new Error(`GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`);
2189
+ }
2190
+ return json.data;
2191
+ }
2192
+ /**
2193
+ * Resolve a project path and issue IID to a work item GraphQL GID.
2194
+ */
2195
+ async function resolveWorkItemGID(projectId, issueIid) {
2196
+ projectId = decodeURIComponent(projectId);
2197
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2198
+ // First get the project path via REST (needed for GraphQL namespace query)
2199
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2200
+ const projectResponse = await fetch(projectUrl.toString(), {
2201
+ ...getFetchConfig(),
2202
+ });
2203
+ await handleGitLabError(projectResponse);
2204
+ const project = await projectResponse.json();
2205
+ const projectPath = project.path_with_namespace;
2206
+ // Resolve work item GID via GraphQL
2207
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2208
+ namespace(fullPath: $path) {
2209
+ workItem(iid: $iid) {
2210
+ id
2211
+ }
2212
+ }
2213
+ }`, { path: projectPath, iid: String(issueIid) });
2214
+ if (!data.namespace?.workItem?.id) {
2215
+ throw new Error(`Work item #${issueIid} not found in project ${projectPath}`);
2216
+ }
2217
+ return { workItemGID: data.namespace.workItem.id, projectPath };
2218
+ }
2219
+ /**
2220
+ * Resolve label names and usernames to GitLab GIDs in a single GraphQL call.
2221
+ */
2222
+ async function resolveNamesToIds(projectPath, labelNames, usernames) {
2223
+ if (!labelNames?.length && !usernames?.length) {
2224
+ return { labelIds: [], userIds: [] };
2225
+ }
2226
+ const data = await executeGraphQL(`query($path: ID!, $usernames: [String!]!) {
2227
+ project(fullPath: $path) { labels(includeAncestorGroups: true, first: 250) { nodes { id title } } }
2228
+ users(usernames: $usernames) { nodes { id username } }
2229
+ }`, { path: projectPath, usernames: usernames || [] });
2230
+ const labelIds = (labelNames || []).map(name => {
2231
+ const label = data.project.labels.nodes.find(l => l.title === name);
2232
+ if (!label)
2233
+ throw new Error(`Label '${name}' not found in project`);
2234
+ return label.id;
2235
+ });
2236
+ const userIds = (usernames || []).map(name => {
2237
+ const user = data.users.nodes.find(u => u.username === name);
2238
+ if (!user)
2239
+ throw new Error(`User '${name}' not found`);
2240
+ return user.id;
2241
+ });
2242
+ return { labelIds, userIds };
2243
+ }
2244
+ // --- Work item type conversion ---
2245
+ /**
2246
+ * Map user-facing type names to GitLab WorkItemType names for GraphQL queries.
2247
+ */
2248
+ const WORK_ITEM_TYPE_NAMES = {
2249
+ issue: "Issue",
2250
+ task: "Task",
2251
+ incident: "Incident",
2252
+ test_case: "Test Case",
2253
+ epic: "Epic",
2254
+ key_result: "Key Result",
2255
+ objective: "Objective",
2256
+ requirement: "Requirement",
2257
+ ticket: "Ticket",
2258
+ };
2259
+ /**
2260
+ * Get the GraphQL GID for a work item type by querying the project's available types.
2261
+ */
2262
+ async function resolveWorkItemTypeGID(projectPath, typeName) {
2263
+ const targetName = WORK_ITEM_TYPE_NAMES[typeName];
2264
+ if (!targetName) {
2265
+ throw new Error(`Unknown work item type: ${typeName}`);
2266
+ }
2267
+ const data = await executeGraphQL(`query($path: ID!) {
2268
+ namespace(fullPath: $path) {
2269
+ workItemTypes {
2270
+ nodes {
2271
+ id
2272
+ name
2273
+ }
2274
+ }
2275
+ }
2276
+ }`, { path: projectPath });
2277
+ const typeNode = data.namespace?.workItemTypes?.nodes?.find((n) => n.name === targetName);
2278
+ if (!typeNode) {
2279
+ throw new Error(`Work item type '${targetName}' not found in project ${projectPath}`);
2280
+ }
2281
+ return typeNode.id;
2282
+ }
2283
+ /**
2284
+ * Convert an issue to a different work item type using GraphQL.
2285
+ */
2286
+ async function convertIssueType(projectId, issueIid, newType) {
2287
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, issueIid);
2288
+ const workItemTypeGID = await resolveWorkItemTypeGID(projectPath, newType);
2289
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $typeId: WorkItemsTypeID!) {
2290
+ workItemConvert(input: { id: $id, workItemTypeId: $typeId }) {
2291
+ workItem {
2292
+ id
2293
+ workItemType { name }
2294
+ }
2295
+ errors
2296
+ }
2297
+ }`, { id: workItemGID, typeId: workItemTypeGID });
2298
+ if (data.workItemConvert.errors?.length > 0) {
2299
+ throw new Error(`Conversion failed: ${data.workItemConvert.errors.join(", ")}`);
2300
+ }
2301
+ return {
2302
+ id: data.workItemConvert.workItem.id,
2303
+ type: data.workItemConvert.workItem.workItemType.name,
2304
+ };
2305
+ }
2306
+ // --- Work item hierarchy ---
2307
+ /**
2308
+ * Set a parent for a work item (issue hierarchy).
2309
+ */
2310
+ async function setIssueParent(projectId, issueIid, parentProjectId, parentIssueIid) {
2311
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2312
+ const { workItemGID: parentGID } = await resolveWorkItemGID(parentProjectId, parentIssueIid);
2313
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $parentId: WorkItemID!) {
2314
+ workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) {
2315
+ workItem { id }
2316
+ errors
2317
+ }
2318
+ }`, { id: workItemGID, parentId: parentGID });
2319
+ if (data.workItemUpdate.errors?.length > 0) {
2320
+ throw new Error(`Failed to set parent: ${data.workItemUpdate.errors.join(", ")}`);
2321
+ }
2322
+ return { id: workItemGID, parentId: parentGID };
2323
+ }
2324
+ /**
2325
+ * Remove the parent from a work item.
2326
+ */
2327
+ async function removeIssueParent(projectId, issueIid) {
2328
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2329
+ const data = await executeGraphQL(`mutation($id: WorkItemID!) {
2330
+ workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: null } }) {
2331
+ workItem { id }
2332
+ errors
2333
+ }
2334
+ }`, { id: workItemGID });
2335
+ if (data.workItemUpdate.errors?.length > 0) {
2336
+ throw new Error(`Failed to remove parent: ${data.workItemUpdate.errors.join(", ")}`);
2337
+ }
2338
+ }
2339
+ /**
2340
+ * List children of a work item (hierarchy widget).
2341
+ */
2342
+ async function listIssueChildren(projectId, issueIid) {
2343
+ projectId = decodeURIComponent(projectId);
2344
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2345
+ // Get project path
2346
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2347
+ const projectResponse = await fetch(projectUrl.toString(), {
2348
+ ...getFetchConfig(),
2349
+ });
2350
+ await handleGitLabError(projectResponse);
2351
+ const project = await projectResponse.json();
2352
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2353
+ namespace(fullPath: $path) {
2354
+ workItem(iid: $iid) {
2355
+ id
2356
+ title
2357
+ widgets {
2358
+ __typename
2359
+ ... on WorkItemWidgetHierarchy {
2360
+ parent {
2361
+ id
2362
+ title
2363
+ webUrl
2364
+ workItemType { name }
2365
+ }
2366
+ children {
2367
+ nodes {
2368
+ id
2369
+ title
2370
+ state
2371
+ webUrl
2372
+ workItemType { name }
2373
+ }
2374
+ }
2375
+ }
2376
+ }
2377
+ }
2378
+ }
2379
+ }`, { path: project.path_with_namespace, iid: String(issueIid) });
2380
+ if (!data.namespace?.workItem) {
2381
+ throw new Error(`Work item #${issueIid} not found`);
2382
+ }
2383
+ // Extract hierarchy widget
2384
+ const hierarchyWidget = data.namespace.workItem.widgets?.find((w) => w.__typename === "WorkItemWidgetHierarchy");
2385
+ return {
2386
+ id: data.namespace.workItem.id,
2387
+ title: data.namespace.workItem.title,
2388
+ parent: hierarchyWidget?.parent || null,
2389
+ children: hierarchyWidget?.children?.nodes || [],
2390
+ };
2391
+ }
2392
+ /**
2393
+ * Add a child to a parent work item.
2394
+ */
2395
+ async function addIssueChild(projectId, issueIid, childProjectId, childIssueIid) {
2396
+ const { workItemGID: parentGID } = await resolveWorkItemGID(projectId, issueIid);
2397
+ const { workItemGID: childGID } = await resolveWorkItemGID(childProjectId, childIssueIid);
2398
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $childId: WorkItemID!) {
2399
+ workItemUpdate(input: { id: $id, hierarchyWidget: { childrenIds: [$childId] } }) {
2400
+ workItem { id }
2401
+ errors
2402
+ }
2403
+ }`, { id: parentGID, childId: childGID });
2404
+ if (data.workItemUpdate.errors?.length > 0) {
2405
+ throw new Error(`Failed to add child: ${data.workItemUpdate.errors.join(", ")}`);
2406
+ }
2407
+ return { parentId: parentGID, childId: childGID };
2408
+ }
2409
+ /**
2410
+ * Remove a child from a parent work item by setting the child's parent to null.
2411
+ */
2412
+ async function removeIssueChild(projectId, issueIid, childProjectId, childIssueIid) {
2413
+ // Removing a child is done by removing the parent from the child
2414
+ await removeIssueParent(childProjectId, childIssueIid);
2415
+ }
2416
+ // --- Work item status ---
2417
+ /**
2418
+ * List available statuses for a work item type in a project.
2419
+ * Requires Premium/Ultimate with configurable statuses enabled.
2420
+ */
2421
+ async function listIssueStatuses(projectId, workItemType = "issue") {
2422
+ projectId = decodeURIComponent(projectId);
2423
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2424
+ // Get project path
2425
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2426
+ const projectResponse = await fetch(projectUrl.toString(), {
2427
+ ...getFetchConfig(),
2428
+ });
2429
+ await handleGitLabError(projectResponse);
2430
+ const project = await projectResponse.json();
2431
+ const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
2432
+ const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
2433
+ namespace(fullPath: $path) {
2434
+ workItemTypes(name: $typeName) {
2435
+ nodes {
2436
+ id
2437
+ name
2438
+ supportedConversionTypes { id name }
2439
+ widgetDefinitions {
2440
+ __typename
2441
+ ... on WorkItemWidgetDefinitionStatus {
2442
+ allowedStatuses {
2443
+ id
2444
+ name
2445
+ iconName
2446
+ color
2447
+ position
2448
+ }
2449
+ }
2450
+ ... on WorkItemWidgetDefinitionHierarchy {
2451
+ allowedChildTypes { nodes { id name } }
2452
+ allowedParentTypes { nodes { id name } }
2453
+ }
2454
+ }
2455
+ }
2456
+ }
2457
+ }
2458
+ }`, { path: project.path_with_namespace, typeName: typeName.replace(/ /g, "_").toUpperCase() });
2459
+ const typeNodes = data.namespace?.workItemTypes?.nodes;
2460
+ if (!typeNodes || typeNodes.length === 0) {
2461
+ throw new Error(`Work item type '${typeName}' not found in project`);
2462
+ }
2463
+ const typeNode = typeNodes[0];
2464
+ // Extract statuses from the status widget definition
2465
+ const statusWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionStatus");
2466
+ const statuses = statusWidget?.allowedStatuses || [];
2467
+ // Extract hierarchy info
2468
+ const hierarchyWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionHierarchy");
2469
+ const result = {
2470
+ work_item_type: typeNode.name,
2471
+ statuses_available: statuses.length > 0,
2472
+ statuses,
2473
+ };
2474
+ // Add supported conversion types
2475
+ const conversionTypes = typeNode.supportedConversionTypes || [];
2476
+ if (conversionTypes.length > 0) {
2477
+ result.supported_conversion_types = conversionTypes.map((t) => t.name);
2478
+ }
2479
+ // Add allowed child/parent types
2480
+ const childTypes = hierarchyWidget?.allowedChildTypes?.nodes || [];
2481
+ const parentTypes = hierarchyWidget?.allowedParentTypes?.nodes || [];
2482
+ if (childTypes.length > 0) {
2483
+ result.allowed_child_types = childTypes.map((t) => t.name);
2484
+ }
2485
+ if (parentTypes.length > 0) {
2486
+ result.allowed_parent_types = parentTypes.map((t) => t.name);
2487
+ }
2488
+ return result;
2489
+ }
2490
+ /**
2491
+ * List available custom field definitions for a work item type.
2492
+ */
2493
+ async function listCustomFieldDefinitions(projectId, workItemType = "issue") {
2494
+ const projectPath = await resolveProjectPath(projectId);
2495
+ const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
2496
+ const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
2497
+ namespace(fullPath: $path) {
2498
+ workItemTypes(name: $typeName) {
2499
+ nodes {
2500
+ id
2501
+ name
2502
+ widgetDefinitions {
2503
+ __typename
2504
+ ... on WorkItemWidgetDefinitionCustomFields {
2505
+ customFieldValues {
2506
+ customField {
2507
+ id
2508
+ name
2509
+ fieldType
2510
+ selectOptions { id value }
2511
+ workItemTypes { id name }
2512
+ }
2513
+ }
2514
+ }
2515
+ }
2516
+ }
2517
+ }
2518
+ }
2519
+ }`, { path: projectPath, typeName: typeName.replace(/ /g, "_").toUpperCase() });
2520
+ const typeNodes = data.namespace?.workItemTypes?.nodes;
2521
+ if (!typeNodes || typeNodes.length === 0) {
2522
+ throw new Error(`Work item type '${typeName}' not found in project`);
2523
+ }
2524
+ const typeNode = typeNodes[0];
2525
+ const customFieldsWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionCustomFields");
2526
+ const fields = (customFieldsWidget?.customFieldValues || []).map((cfv) => {
2527
+ const cf = cfv.customField;
2528
+ const field = {
2529
+ id: cf?.id,
2530
+ name: cf?.name,
2531
+ type: cf?.fieldType,
2532
+ };
2533
+ const options = cf?.selectOptions || [];
2534
+ if (options.length > 0)
2535
+ field.selectOptions = options;
2536
+ const types = (cf?.workItemTypes || []).map((t) => t.name);
2537
+ if (types.length > 0)
2538
+ field.workItemTypes = types;
2539
+ return field;
2540
+ });
2541
+ return {
2542
+ work_item_type: typeNode.name,
2543
+ custom_fields: fields,
2544
+ };
2545
+ }
2546
+ /**
2547
+ * Move a work item to a different project.
2548
+ */
2549
+ async function moveWorkItem(projectId, iid, targetProjectId) {
2550
+ const projectPath = await resolveProjectPath(projectId);
2551
+ const targetPath = await resolveProjectPath(targetProjectId);
2552
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $iid: String!, $targetProjectPath: ID!) {
2553
+ issueMove(input: { projectPath: $projectPath, iid: $iid, targetProjectPath: $targetProjectPath }) {
2554
+ issue { id iid webUrl }
2555
+ errors
2556
+ }
2557
+ }`, { projectPath: projectPath, iid: String(iid), targetProjectPath: targetPath });
2558
+ if (data.issueMove.errors?.length > 0) {
2559
+ throw new Error(`Failed to move work item: ${data.issueMove.errors.join(", ")}`);
2560
+ }
2561
+ return data.issueMove.issue;
2562
+ }
2563
+ /**
2564
+ * List notes/discussions on a work item.
2565
+ */
2566
+ async function listWorkItemNotes(projectId, iid, options = {}) {
2567
+ const projectPath = await resolveProjectPath(projectId);
2568
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!, $pageSize: Int, $after: String, $sort: WorkItemDiscussionsSort) {
2569
+ namespace(fullPath: $path) {
2570
+ workItem(iid: $iid) {
2571
+ id
2572
+ widgets(onlyTypes: [NOTES]) {
2573
+ ... on WorkItemWidgetNotes {
2574
+ discussionLocked
2575
+ discussions(first: $pageSize, after: $after, filter: ALL_NOTES, sort: $sort) {
2576
+ pageInfo { hasNextPage endCursor }
2577
+ nodes {
2578
+ id
2579
+ resolved
2580
+ resolvable
2581
+ notes {
2582
+ nodes {
2583
+ id
2584
+ body
2585
+ system
2586
+ internal
2587
+ createdAt
2588
+ lastEditedAt
2589
+ author { username }
2590
+ }
2591
+ }
2592
+ }
2593
+ }
2594
+ }
2595
+ }
2596
+ }
2597
+ }
2598
+ }`, {
2599
+ path: projectPath,
2600
+ iid: String(iid),
2601
+ pageSize: options.page_size || 20,
2602
+ after: options.after || null,
2603
+ sort: options.sort || "CREATED_ASC",
2604
+ });
2605
+ const workItem = data.namespace?.workItem;
2606
+ if (!workItem) {
2607
+ throw new Error(`Work item #${iid} not found in project ${projectPath}`);
2608
+ }
2609
+ const notesWidget = workItem.widgets?.find((w) => w.discussions);
2610
+ const discussions = notesWidget?.discussions;
2611
+ // Flatten to lean output
2612
+ const items = (discussions?.nodes || []).map((d) => {
2613
+ const notes = (d.notes?.nodes || []).map((n) => {
2614
+ const note = {
2615
+ id: n.id,
2616
+ author: n.author?.username,
2617
+ body: n.body,
2618
+ createdAt: n.createdAt,
2619
+ };
2620
+ if (n.system)
2621
+ note.system = true;
2622
+ if (n.internal)
2623
+ note.internal = true;
2624
+ if (n.lastEditedAt)
2625
+ note.lastEditedAt = n.lastEditedAt;
2626
+ return note;
2627
+ });
2628
+ const discussion = { id: d.id, notes };
2629
+ if (d.resolved)
2630
+ discussion.resolved = true;
2631
+ if (d.resolvable)
2632
+ discussion.resolvable = true;
2633
+ return discussion;
2634
+ });
2635
+ return {
2636
+ discussions: items,
2637
+ pageInfo: discussions?.pageInfo || {},
2638
+ };
2639
+ }
2640
+ /**
2641
+ * Create a note on a work item.
2642
+ */
2643
+ async function createWorkItemNote(projectId, iid, body, options = {}) {
2644
+ const { workItemGID } = await resolveWorkItemGID(projectId, iid);
2645
+ const varDefs = ["$noteableId: NoteableID!", "$body: String!"];
2646
+ const inputParts = ["noteableId: $noteableId", "body: $body"];
2647
+ const variables = { noteableId: workItemGID, body };
2648
+ if (options.internal) {
2649
+ varDefs.push("$internal: Boolean");
2650
+ inputParts.push("internal: $internal");
2651
+ variables.internal = true;
2652
+ }
2653
+ if (options.discussion_id) {
2654
+ varDefs.push("$discussionId: DiscussionID");
2655
+ inputParts.push("discussionId: $discussionId");
2656
+ variables.discussionId = options.discussion_id;
2657
+ }
2658
+ const data = await executeGraphQL(`mutation(${varDefs.join(", ")}) {
2659
+ createNote(input: { ${inputParts.join(", ")} }) {
2660
+ note {
2661
+ id
2662
+ body
2663
+ discussion { id }
2664
+ }
2665
+ errors
2666
+ }
2667
+ }`, variables);
2668
+ if (data.createNote.errors?.length > 0) {
2669
+ throw new Error(`Failed to create note: ${data.createNote.errors.join(", ")}`);
2670
+ }
2671
+ return data.createNote.note;
2672
+ }
2673
+ // --- Incident Timeline Events ---
2674
+ /**
2675
+ * List timeline events for an incident.
2676
+ */
2677
+ async function getTimelineEvents(projectId, incidentIid) {
2678
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, incidentIid);
2679
+ // Timeline events expect gid://gitlab/Issue/... not gid://gitlab/WorkItem/...
2680
+ const incidentGID = workItemGID.replace("/WorkItem/", "/Issue/");
2681
+ const data = await executeGraphQL(`query($fullPath: ID!, $incidentId: IssueID!) {
2682
+ project(fullPath: $fullPath) {
2683
+ incidentManagementTimelineEvents(incidentId: $incidentId) {
2684
+ nodes {
2685
+ id
2686
+ note
2687
+ noteHtml
2688
+ action
2689
+ occurredAt
2690
+ createdAt
2691
+ timelineEventTags {
2692
+ nodes {
2693
+ id
2694
+ name
2695
+ }
2696
+ }
2697
+ }
2698
+ }
2699
+ }
2700
+ }`, { fullPath: projectPath, incidentId: incidentGID });
2701
+ const events = data.project?.incidentManagementTimelineEvents?.nodes || [];
2702
+ return events.map((e) => {
2703
+ const event = {
2704
+ id: e.id,
2705
+ note: e.note,
2706
+ action: e.action,
2707
+ occurredAt: e.occurredAt,
2708
+ createdAt: e.createdAt,
2709
+ };
2710
+ if (e.noteHtml)
2711
+ event.noteHtml = e.noteHtml;
2712
+ const tags = (e.timelineEventTags?.nodes || []).map((t) => t.name);
2713
+ if (tags.length > 0)
2714
+ event.tags = tags;
2715
+ return event;
2716
+ });
2717
+ }
2718
+ /**
2719
+ * Create a timeline event on an incident.
2720
+ */
2721
+ async function createTimelineEvent(projectId, incidentIid, note, occurredAt, tagNames) {
2722
+ const { workItemGID } = await resolveWorkItemGID(projectId, incidentIid);
2723
+ // Timeline events expect gid://gitlab/Issue/... not gid://gitlab/WorkItem/...
2724
+ const incidentGID = workItemGID.replace("/WorkItem/", "/Issue/");
2725
+ const variables = {
2726
+ input: {
2727
+ incidentId: incidentGID,
2728
+ note,
2729
+ occurredAt,
2730
+ },
2731
+ };
2732
+ if (tagNames && tagNames.length > 0) {
2733
+ variables.input.timelineEventTagNames = tagNames;
2734
+ }
2735
+ const data = await executeGraphQL(`mutation CreateTimelineEvent($input: TimelineEventCreateInput!) {
2736
+ timelineEventCreate(input: $input) {
2737
+ timelineEvent {
2738
+ id
2739
+ note
2740
+ noteHtml
2741
+ action
2742
+ occurredAt
2743
+ createdAt
2744
+ timelineEventTags {
2745
+ nodes {
2746
+ id
2747
+ name
2748
+ }
2749
+ }
2750
+ }
2751
+ errors
2752
+ }
2753
+ }`, variables);
2754
+ if (data.timelineEventCreate.errors?.length > 0) {
2755
+ throw new Error(`Failed to create timeline event: ${data.timelineEventCreate.errors.join(", ")}`);
2756
+ }
2757
+ const e = data.timelineEventCreate.timelineEvent;
2758
+ const result = {
2759
+ id: e.id,
2760
+ note: e.note,
2761
+ action: e.action,
2762
+ occurredAt: e.occurredAt,
2763
+ createdAt: e.createdAt,
2764
+ };
2765
+ if (e.noteHtml)
2766
+ result.noteHtml = e.noteHtml;
2767
+ const tags = (e.timelineEventTags?.nodes || []).map((t) => t.name);
2768
+ if (tags.length > 0)
2769
+ result.tags = tags;
2770
+ return result;
2771
+ }
2772
+ /**
2773
+ * Update the severity of an incident.
2774
+ * Accepts projectPath directly to avoid redundant REST calls when called from updateWorkItem.
2775
+ */
2776
+ async function updateIncidentSeverity(projectPath, incidentIid, severity) {
2777
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $severity: IssuableSeverity!, $iid: String!) {
2778
+ issueSetSeverity(input: { iid: $iid, severity: $severity, projectPath: $projectPath }) {
2779
+ errors
2780
+ issue {
2781
+ iid
2782
+ id
2783
+ severity
2784
+ }
2785
+ }
2786
+ }`, { projectPath, severity, iid: String(incidentIid) });
2787
+ if (data.issueSetSeverity.errors?.length > 0) {
2788
+ throw new Error(`Failed to set severity: ${data.issueSetSeverity.errors.join(", ")}`);
2789
+ }
2790
+ return data.issueSetSeverity.issue;
2791
+ }
2792
+ /**
2793
+ * Update the escalation status of an incident.
2794
+ * Accepts projectPath directly to avoid redundant REST calls when called from updateWorkItem.
2795
+ */
2796
+ async function updateIncidentEscalationStatus(projectPath, incidentIid, status) {
2797
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) {
2798
+ issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) {
2799
+ errors
2800
+ issue {
2801
+ id
2802
+ escalationStatus
2803
+ }
2804
+ }
2805
+ }`, { projectPath, status, iid: String(incidentIid) });
2806
+ if (data.issueSetEscalationStatus.errors?.length > 0) {
2807
+ throw new Error(`Failed to set escalation status: ${data.issueSetEscalationStatus.errors.join(", ")}`);
2808
+ }
2809
+ return data.issueSetEscalationStatus.issue;
2810
+ }
2811
+ /**
2812
+ * Set the status of a work item.
2813
+ */
2814
+ async function setIssueStatus(projectId, issueIid, status) {
2815
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2816
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $status: WorkItemsStatusesStatusID!) {
2817
+ workItemUpdate(input: { id: $id, statusWidget: { status: $status } }) {
2818
+ workItem {
2819
+ id
2820
+ widgets {
2821
+ __typename
2822
+ ... on WorkItemWidgetStatus {
2823
+ status { id name category color }
2824
+ }
2825
+ }
2826
+ }
2827
+ errors
2828
+ }
2829
+ }`, { id: workItemGID, status });
2830
+ if (data.workItemUpdate.errors?.length > 0) {
2831
+ throw new Error(`Failed to set status: ${data.workItemUpdate.errors.join(", ")}`);
2832
+ }
2833
+ // Extract the current status from the response
2834
+ const statusWidget = data.workItemUpdate.workItem?.widgets?.find((w) => w.__typename === "WorkItemWidgetStatus");
2835
+ return {
2836
+ id: data.workItemUpdate.workItem.id,
2837
+ status: statusWidget?.status || null,
2838
+ };
2839
+ }
2840
+ /**
2841
+ * Resolve a project ID (numeric or path) to its full path_with_namespace.
2842
+ */
2843
+ async function resolveProjectPath(projectId) {
2844
+ projectId = decodeURIComponent(projectId);
2845
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2846
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2847
+ const projectResponse = await fetch(projectUrl.toString(), {
2848
+ ...getFetchConfig(),
2849
+ });
2850
+ await handleGitLabError(projectResponse);
2851
+ const project = await projectResponse.json();
2852
+ return project.path_with_namespace;
2853
+ }
2854
+ /**
2855
+ * Get a single work item with all widget data.
2856
+ */
2857
+ async function getWorkItem(projectId, iid) {
2858
+ const projectPath = await resolveProjectPath(projectId);
2859
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2860
+ namespace(fullPath: $path) {
2861
+ workItem(iid: $iid) {
2862
+ id
2863
+ iid
2864
+ title
2865
+ state
2866
+ description
2867
+ webUrl
2868
+ confidential
2869
+ author { username }
2870
+ createdAt
2871
+ closedAt
2872
+ workItemType { name }
2873
+ widgets {
2874
+ __typename
2875
+ ... on WorkItemWidgetHierarchy {
2876
+ hasChildren hasParent
2877
+ parent { id iid title webUrl workItemType { name } namespace { fullPath } }
2878
+ children { nodes { id iid title state webUrl workItemType { name } namespace { fullPath } } }
2879
+ }
2880
+ ... on WorkItemWidgetStatus { status { id name category color iconName position } }
2881
+ ... on WorkItemWidgetCustomFields {
2882
+ customFieldValues {
2883
+ __typename
2884
+ customField { id name fieldType }
2885
+ ... on WorkItemNumberFieldValue { value }
2886
+ ... on WorkItemTextFieldValue { value }
2887
+ ... on WorkItemSelectFieldValue {
2888
+ selectedOptions { id value }
2889
+ }
2890
+ }
2891
+ }
2892
+ ... on WorkItemWidgetLabels { labels { nodes { id title color } } }
2893
+ ... on WorkItemWidgetAssignees { assignees { nodes { id username name } } }
2894
+ ... on WorkItemWidgetWeight { weight rolledUpWeight rolledUpCompletedWeight }
2895
+ ... on WorkItemWidgetHealthStatus { healthStatus }
2896
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
2897
+ ... on WorkItemWidgetMilestone { milestone { id title } }
2898
+ ... on WorkItemWidgetLinkedItems {
2899
+ blocked blockedByCount blockingCount
2900
+ linkedItems { nodes { linkType workItem { id iid title state webUrl workItemType { name } namespace { fullPath } } } }
2901
+ }
2902
+ ... on WorkItemWidgetTimeTracking {
2903
+ timeEstimate totalTimeSpent
2904
+ }
2905
+ ... on WorkItemWidgetDevelopment {
2906
+ willAutoCloseByMergeRequest
2907
+ relatedBranches { nodes { name } }
2908
+ relatedMergeRequests {
2909
+ nodes { iid title webUrl state sourceBranch }
2910
+ }
2911
+ closingMergeRequests {
2912
+ nodes {
2913
+ mergeRequest { iid title webUrl state sourceBranch }
2914
+ }
2915
+ }
2916
+ featureFlags { nodes { name active } }
2917
+ }
2918
+ ... on WorkItemWidgetIteration {
2919
+ iteration { id title startDate dueDate webUrl iterationCadence { id title } }
2920
+ }
2921
+ ... on WorkItemWidgetProgress { progress }
2922
+ ... on WorkItemWidgetColor { color textColor }
2923
+ }
2924
+ }
2925
+ }
2926
+ }`, { path: projectPath, iid: String(iid) });
2927
+ if (!data.namespace?.workItem) {
2928
+ throw new Error(`Work item #${iid} not found in project ${projectPath}`);
2929
+ }
2930
+ const wi = data.namespace.workItem;
2931
+ const widgets = wi.widgets || [];
2932
+ // Flatten widget data into a clean response
2933
+ const hierarchyWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHierarchy");
2934
+ const statusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
2935
+ const labelsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
2936
+ const assigneesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
2937
+ const weightWidget = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
2938
+ const healthStatusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
2939
+ const datesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
2940
+ const milestoneWidget = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
2941
+ const linkedItemsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLinkedItems");
2942
+ const timeTrackingWidget = widgets.find((w) => w.__typename === "WorkItemWidgetTimeTracking");
2943
+ const developmentWidget = widgets.find((w) => w.__typename === "WorkItemWidgetDevelopment");
2944
+ const customFieldsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetCustomFields");
2945
+ // Build response, omitting null/empty values to keep output lean
2946
+ const result = {
2947
+ id: wi.id,
2948
+ iid: wi.iid,
2949
+ title: wi.title,
2950
+ state: wi.state,
2951
+ type: wi.workItemType?.name,
2952
+ webUrl: wi.webUrl,
2953
+ };
2954
+ if (wi.description)
2955
+ result.description = wi.description;
2956
+ if (wi.confidential)
2957
+ result.confidential = true;
2958
+ if (wi.author?.username)
2959
+ result.author = wi.author.username;
2960
+ if (wi.createdAt)
2961
+ result.createdAt = wi.createdAt;
2962
+ if (wi.closedAt)
2963
+ result.closedAt = wi.closedAt;
2964
+ if (statusWidget?.status)
2965
+ result.status = { name: statusWidget.status.name, id: statusWidget.status.id, category: statusWidget.status.category };
2966
+ const labels = (labelsWidget?.labels?.nodes || []).map((l) => l.title);
2967
+ if (labels.length > 0)
2968
+ result.labels = labels;
2969
+ const assignees = (assigneesWidget?.assignees?.nodes || []).map((a) => a.username);
2970
+ if (assignees.length > 0)
2971
+ result.assignees = assignees;
2972
+ if (weightWidget?.weight != null) {
2973
+ result.weight = weightWidget.weight;
2974
+ if (weightWidget.rolledUpWeight != null)
2975
+ result.rolledUpWeight = weightWidget.rolledUpWeight;
2976
+ if (weightWidget.rolledUpCompletedWeight != null)
2977
+ result.rolledUpCompletedWeight = weightWidget.rolledUpCompletedWeight;
2978
+ }
2979
+ if (healthStatusWidget?.healthStatus)
2980
+ result.healthStatus = healthStatusWidget.healthStatus;
2981
+ if (datesWidget?.startDate)
2982
+ result.startDate = datesWidget.startDate;
2983
+ if (datesWidget?.dueDate)
2984
+ result.dueDate = datesWidget.dueDate;
2985
+ if (milestoneWidget?.milestone)
2986
+ result.milestone = { id: milestoneWidget.milestone.id, title: milestoneWidget.milestone.title };
2987
+ const iterationWidget = widgets.find((w) => w.__typename === "WorkItemWidgetIteration");
2988
+ if (iterationWidget?.iteration) {
2989
+ result.iteration = {
2990
+ id: iterationWidget.iteration.id,
2991
+ title: iterationWidget.iteration.title,
2992
+ startDate: iterationWidget.iteration.startDate,
2993
+ dueDate: iterationWidget.iteration.dueDate,
2994
+ };
2995
+ }
2996
+ const progressWidget = widgets.find((w) => w.__typename === "WorkItemWidgetProgress");
2997
+ if (progressWidget?.progress != null)
2998
+ result.progress = progressWidget.progress;
2999
+ const colorWidget = widgets.find((w) => w.__typename === "WorkItemWidgetColor");
3000
+ if (colorWidget?.color)
3001
+ result.color = colorWidget.color;
3002
+ if (hierarchyWidget?.parent)
3003
+ result.parent = { iid: hierarchyWidget.parent.iid, title: hierarchyWidget.parent.title, type: hierarchyWidget.parent.workItemType?.name, project: hierarchyWidget.parent.namespace?.fullPath, webUrl: hierarchyWidget.parent.webUrl };
3004
+ const children = hierarchyWidget?.children?.nodes || [];
3005
+ if (children.length > 0)
3006
+ result.children = children.map((c) => ({ iid: c.iid, title: c.title, state: c.state, type: c.workItemType?.name, project: c.namespace?.fullPath, webUrl: c.webUrl }));
3007
+ if (linkedItemsWidget?.blocked)
3008
+ result.blocked = true;
3009
+ if (linkedItemsWidget?.blockedByCount > 0)
3010
+ result.blockedByCount = linkedItemsWidget.blockedByCount;
3011
+ if (linkedItemsWidget?.blockingCount > 0)
3012
+ result.blockingCount = linkedItemsWidget.blockingCount;
3013
+ const linkedNodes = linkedItemsWidget?.linkedItems?.nodes || [];
3014
+ if (linkedNodes.length > 0) {
3015
+ result.linkedItems = linkedNodes.map((n) => ({
3016
+ linkType: n.linkType,
3017
+ iid: n.workItem?.iid,
3018
+ title: n.workItem?.title,
3019
+ state: n.workItem?.state,
3020
+ type: n.workItem?.workItemType?.name,
3021
+ project: n.workItem?.namespace?.fullPath,
3022
+ webUrl: n.workItem?.webUrl,
3023
+ }));
3024
+ }
3025
+ if (timeTrackingWidget?.timeEstimate > 0)
3026
+ result.timeEstimate = timeTrackingWidget.timeEstimate;
3027
+ if (timeTrackingWidget?.totalTimeSpent > 0)
3028
+ result.totalTimeSpent = timeTrackingWidget.totalTimeSpent;
3029
+ // Development: only include if there's actual data
3030
+ const relatedMRs = developmentWidget?.relatedMergeRequests?.nodes || [];
3031
+ const closingMRs = (developmentWidget?.closingMergeRequests?.nodes || []).map((n) => n.mergeRequest);
3032
+ const branches = developmentWidget?.relatedBranches?.nodes || [];
3033
+ const flags = developmentWidget?.featureFlags?.nodes || [];
3034
+ if (relatedMRs.length > 0 || closingMRs.length > 0 || branches.length > 0 || flags.length > 0) {
3035
+ const dev = {};
3036
+ if (relatedMRs.length > 0)
3037
+ dev.relatedMergeRequests = relatedMRs;
3038
+ if (closingMRs.length > 0)
3039
+ dev.closingMergeRequests = closingMRs;
3040
+ if (branches.length > 0)
3041
+ dev.relatedBranches = branches.map((b) => b.name);
3042
+ if (flags.length > 0)
3043
+ dev.featureFlags = flags;
3044
+ result.development = dev;
3045
+ }
3046
+ const cfValues = (customFieldsWidget?.customFieldValues || []).filter((cfv) => cfv.value != null || cfv.selectedOptions != null);
3047
+ if (cfValues.length > 0) {
3048
+ result.customFields = cfValues.map((cfv) => ({
3049
+ name: cfv.customField?.name,
3050
+ type: cfv.customField?.fieldType,
3051
+ value: cfv.value ?? cfv.selectedOptions ?? null,
3052
+ }));
3053
+ }
3054
+ return result;
3055
+ }
3056
+ /**
3057
+ * List work items in a project with filters.
3058
+ */
3059
+ async function listWorkItems(projectId, options) {
3060
+ const projectPath = await resolveProjectPath(projectId);
3061
+ // Map type names to GraphQL enum values
3062
+ const typeMap = {
3063
+ issue: "ISSUE",
3064
+ task: "TASK",
3065
+ incident: "INCIDENT",
3066
+ test_case: "TEST_CASE",
3067
+ epic: "EPIC",
3068
+ key_result: "KEY_RESULT",
3069
+ objective: "OBJECTIVE",
3070
+ requirement: "REQUIREMENT",
3071
+ ticket: "TICKET",
3072
+ };
3073
+ const variables = {
3074
+ path: projectPath,
3075
+ first: options.first || 20,
3076
+ };
3077
+ if (options.types && options.types.length > 0) {
3078
+ variables.types = options.types.map((t) => typeMap[t] || t.replace(/ /g, "_").toUpperCase());
3079
+ }
3080
+ if (options.state) {
3081
+ variables.state = options.state === "opened" ? "opened" : "closed";
3082
+ }
3083
+ if (options.search) {
3084
+ variables.search = options.search;
3085
+ }
3086
+ if (options.assignee_usernames && options.assignee_usernames.length > 0) {
3087
+ variables.assigneeUsernames = options.assignee_usernames;
3088
+ }
3089
+ if (options.label_names && options.label_names.length > 0) {
3090
+ variables.labelName = options.label_names;
3091
+ }
3092
+ if (options.after) {
3093
+ variables.after = options.after;
3094
+ }
3095
+ const data = await executeGraphQL(`query($path: ID!, $types: [IssueType!], $state: IssuableState, $search: String, $assigneeUsernames: [String!], $labelName: [String!], $first: Int, $after: String) {
3096
+ project(fullPath: $path) {
3097
+ workItems(types: $types, state: $state, search: $search, assigneeUsernames: $assigneeUsernames, labelName: $labelName, first: $first, after: $after) {
3098
+ nodes {
3099
+ id iid title state webUrl workItemType { name }
3100
+ widgets {
3101
+ __typename
3102
+ ... on WorkItemWidgetStatus { status { id name category color } }
3103
+ ... on WorkItemWidgetLabels { labels { nodes { title } } }
3104
+ ... on WorkItemWidgetAssignees { assignees { nodes { username } } }
3105
+ ... on WorkItemWidgetWeight { weight }
3106
+ ... on WorkItemWidgetHealthStatus { healthStatus }
3107
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
3108
+ ... on WorkItemWidgetMilestone { milestone { id title } }
3109
+ }
3110
+ }
3111
+ pageInfo { hasNextPage endCursor }
3112
+ }
3113
+ }
3114
+ }`, variables);
3115
+ const workItems = data.project?.workItems?.nodes || [];
3116
+ const pageInfo = data.project?.workItems?.pageInfo || {};
3117
+ // Flatten widget data for each item
3118
+ const items = workItems.map((wi) => {
3119
+ const widgets = wi.widgets || [];
3120
+ const statusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
3121
+ const labelsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
3122
+ const assigneesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
3123
+ const weightWidget = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
3124
+ const healthStatusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
3125
+ const datesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
3126
+ const milestoneWidget = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
3127
+ const item = {
3128
+ iid: wi.iid,
3129
+ title: wi.title,
3130
+ state: wi.state,
3131
+ type: wi.workItemType?.name,
3132
+ webUrl: wi.webUrl,
3133
+ };
3134
+ if (statusWidget?.status)
3135
+ item.status = statusWidget.status.name;
3136
+ const labels = (labelsWidget?.labels?.nodes || []).map((l) => l.title);
3137
+ if (labels.length > 0)
3138
+ item.labels = labels;
3139
+ const assignees = (assigneesWidget?.assignees?.nodes || []).map((a) => a.username);
3140
+ if (assignees.length > 0)
3141
+ item.assignees = assignees;
3142
+ if (weightWidget?.weight != null)
3143
+ item.weight = weightWidget.weight;
3144
+ if (healthStatusWidget?.healthStatus)
3145
+ item.healthStatus = healthStatusWidget.healthStatus;
3146
+ if (datesWidget?.startDate)
3147
+ item.startDate = datesWidget.startDate;
3148
+ if (datesWidget?.dueDate)
3149
+ item.dueDate = datesWidget.dueDate;
3150
+ if (milestoneWidget?.milestone)
3151
+ item.milestone = milestoneWidget.milestone.title;
3152
+ return item;
3153
+ });
3154
+ return { items, pageInfo };
3155
+ }
3156
+ /**
3157
+ * Create a new work item using GraphQL.
3158
+ */
3159
+ async function createWorkItem(projectId, options) {
3160
+ const projectPath = await resolveProjectPath(projectId);
3161
+ const typeName = options.type || "issue";
3162
+ const typeGID = await resolveWorkItemTypeGID(projectPath, typeName);
3163
+ // Build the input dynamically - only include widgets that have values
3164
+ const inputFields = [
3165
+ "$projectPath: ID!",
3166
+ "$title: String!",
3167
+ "$typeId: WorkItemsTypeID!",
3168
+ ];
3169
+ const inputValues = [
3170
+ "namespacePath: $projectPath",
3171
+ "title: $title",
3172
+ "workItemTypeId: $typeId",
3173
+ ];
3174
+ const variables = {
3175
+ projectPath,
3176
+ title: options.title,
3177
+ typeId: typeGID,
3178
+ };
3179
+ if (options.description !== undefined) {
3180
+ inputFields.push("$description: String!");
3181
+ inputValues.push("descriptionWidget: { description: $description }");
3182
+ variables.description = options.description;
3183
+ }
3184
+ // Resolve label names and usernames to GIDs in a single GraphQL call
3185
+ const { labelIds, userIds } = await resolveNamesToIds(projectPath, options.labels, options.assignee_usernames);
3186
+ if (labelIds.length > 0) {
3187
+ inputFields.push("$labelIds: [LabelID!]!");
3188
+ inputValues.push("labelsWidget: { labelIds: $labelIds }");
3189
+ variables.labelIds = labelIds;
3190
+ }
3191
+ if (options.weight !== undefined) {
3192
+ inputFields.push("$weight: Int");
3193
+ inputValues.push("weightWidget: { weight: $weight }");
3194
+ variables.weight = options.weight;
3195
+ }
3196
+ // Resolve parent GID if provided
3197
+ if (options.parent_iid !== undefined) {
3198
+ const { workItemGID: parentGID } = await resolveWorkItemGID(projectId, options.parent_iid);
3199
+ inputFields.push("$parentId: WorkItemID");
3200
+ inputValues.push("hierarchyWidget: { parentId: $parentId }");
3201
+ variables.parentId = parentGID;
3202
+ }
3203
+ if (userIds.length > 0) {
3204
+ inputFields.push("$assigneeIds: [UserID!]!");
3205
+ inputValues.push("assigneesWidget: { assigneeIds: $assigneeIds }");
3206
+ variables.assigneeIds = userIds;
3207
+ }
3208
+ if (options.health_status !== undefined) {
3209
+ inputFields.push("$healthStatus: HealthStatus");
3210
+ inputValues.push("healthStatusWidget: { healthStatus: $healthStatus }");
3211
+ variables.healthStatus = options.health_status;
3212
+ }
3213
+ // Start and due date widget - combine into one widget
3214
+ if (options.start_date !== undefined || options.due_date !== undefined) {
3215
+ const dateParts = [];
3216
+ if (options.start_date !== undefined) {
3217
+ inputFields.push("$startDate: Date");
3218
+ dateParts.push("startDate: $startDate");
3219
+ variables.startDate = options.start_date;
3220
+ }
3221
+ if (options.due_date !== undefined) {
3222
+ inputFields.push("$dueDate: Date");
3223
+ dateParts.push("dueDate: $dueDate");
3224
+ variables.dueDate = options.due_date;
3225
+ }
3226
+ inputValues.push(`startAndDueDateWidget: { ${dateParts.join(", ")} }`);
3227
+ }
3228
+ if (options.milestone_id !== undefined) {
3229
+ // Convert numeric ID to GID format if needed
3230
+ const milestoneGID = options.milestone_id.startsWith("gid://")
3231
+ ? options.milestone_id
3232
+ : `gid://gitlab/Milestone/${options.milestone_id}`;
3233
+ inputFields.push("$milestoneId: MilestoneID");
3234
+ inputValues.push("milestoneWidget: { milestoneId: $milestoneId }");
3235
+ variables.milestoneId = milestoneGID;
3236
+ }
3237
+ if (options.iteration_id !== undefined) {
3238
+ const iterationGID = options.iteration_id.startsWith("gid://")
3239
+ ? options.iteration_id
3240
+ : `gid://gitlab/Iteration/${options.iteration_id}`;
3241
+ inputFields.push("$iterationId: IterationID");
3242
+ inputValues.push("iterationWidget: { iterationId: $iterationId }");
3243
+ variables.iterationId = iterationGID;
3244
+ }
3245
+ if (options.confidential !== undefined) {
3246
+ inputFields.push("$confidential: Boolean");
3247
+ inputValues.push("confidential: $confidential");
3248
+ variables.confidential = options.confidential;
3249
+ }
3250
+ const mutation = `mutation(${inputFields.join(", ")}) {
3251
+ workItemCreate(input: { ${inputValues.join(", ")} }) {
3252
+ workItem {
3253
+ id
3254
+ iid
3255
+ title
3256
+ webUrl
3257
+ workItemType { name }
3258
+ }
3259
+ errors
3260
+ }
3261
+ }`;
3262
+ const data = await executeGraphQL(mutation, variables);
3263
+ if (data.workItemCreate.errors?.length > 0) {
3264
+ throw new Error(`Failed to create work item: ${data.workItemCreate.errors.join(", ")}`);
3265
+ }
3266
+ const wi = data.workItemCreate.workItem;
3267
+ return {
3268
+ id: wi.id,
3269
+ iid: wi.iid,
3270
+ title: wi.title,
3271
+ type: wi.workItemType?.name,
3272
+ webUrl: wi.webUrl,
3273
+ };
3274
+ }
3275
+ /**
3276
+ * Update a work item - consolidated handler for title, description, labels, assignees,
3277
+ * weight, state, status, parent, and children operations.
3278
+ */
3279
+ async function updateWorkItem(projectId, iid, options) {
3280
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, iid);
3281
+ // Build the main workItemUpdate mutation dynamically
3282
+ const inputParts = ["id: $id"];
3283
+ const varDefs = ["$id: WorkItemID!"];
3284
+ const variables = { id: workItemGID };
3285
+ if (options.title !== undefined) {
3286
+ varDefs.push("$title: String");
3287
+ inputParts.push("title: $title");
3288
+ variables.title = options.title;
3289
+ }
3290
+ if (options.description !== undefined) {
3291
+ varDefs.push("$description: String!");
3292
+ inputParts.push("descriptionWidget: { description: $description }");
3293
+ variables.description = options.description;
3294
+ }
3295
+ // Resolve label names and usernames to GIDs in a single GraphQL call
3296
+ const allLabelNames = [...(options.add_labels || []), ...(options.remove_labels || [])];
3297
+ const needsResolve = allLabelNames.length > 0 || options.assignee_usernames?.length;
3298
+ const { labelIds: resolvedLabelIds, userIds } = needsResolve
3299
+ ? await resolveNamesToIds(projectPath, allLabelNames.length > 0 ? allLabelNames : undefined, options.assignee_usernames)
3300
+ : { labelIds: [], userIds: [] };
3301
+ if (options.add_labels || options.remove_labels) {
3302
+ const labelParts = [];
3303
+ let offset = 0;
3304
+ if (options.add_labels && options.add_labels.length > 0) {
3305
+ const addIds = resolvedLabelIds.slice(0, options.add_labels.length);
3306
+ offset = options.add_labels.length;
3307
+ varDefs.push("$addLabelIds: [LabelID!]");
3308
+ labelParts.push("addLabelIds: $addLabelIds");
3309
+ variables.addLabelIds = addIds;
3310
+ }
3311
+ if (options.remove_labels && options.remove_labels.length > 0) {
3312
+ const removeIds = resolvedLabelIds.slice(offset);
3313
+ varDefs.push("$removeLabelIds: [LabelID!]");
3314
+ labelParts.push("removeLabelIds: $removeLabelIds");
3315
+ variables.removeLabelIds = removeIds;
3316
+ }
3317
+ if (labelParts.length > 0) {
3318
+ inputParts.push(`labelsWidget: { ${labelParts.join(", ")} }`);
3319
+ }
3320
+ }
3321
+ if (userIds.length > 0) {
3322
+ varDefs.push("$assigneeIds: [UserID!]!");
3323
+ inputParts.push("assigneesWidget: { assigneeIds: $assigneeIds }");
3324
+ variables.assigneeIds = userIds;
3325
+ }
3326
+ if (options.state_event !== undefined) {
3327
+ varDefs.push("$stateEvent: WorkItemStateEvent");
3328
+ inputParts.push("stateEvent: $stateEvent");
3329
+ variables.stateEvent = options.state_event === "close" ? "CLOSE" : "REOPEN";
3330
+ }
3331
+ if (options.weight !== undefined) {
3332
+ varDefs.push("$weight: Int");
3333
+ inputParts.push("weightWidget: { weight: $weight }");
3334
+ variables.weight = options.weight;
3335
+ }
3336
+ if (options.status !== undefined) {
3337
+ varDefs.push("$status: WorkItemsStatusesStatusID");
3338
+ inputParts.push("statusWidget: { status: $status }");
3339
+ variables.status = options.status;
3340
+ }
3341
+ if (options.health_status !== undefined) {
3342
+ varDefs.push("$healthStatus: HealthStatus");
3343
+ inputParts.push("healthStatusWidget: { healthStatus: $healthStatus }");
3344
+ variables.healthStatus = options.health_status;
3345
+ }
3346
+ // Start and due date widget - combine into one widget
3347
+ if (options.start_date !== undefined || options.due_date !== undefined) {
3348
+ const dateParts = [];
3349
+ if (options.start_date !== undefined) {
3350
+ varDefs.push("$startDate: Date");
3351
+ dateParts.push("startDate: $startDate");
3352
+ variables.startDate = options.start_date;
3353
+ }
3354
+ if (options.due_date !== undefined) {
3355
+ varDefs.push("$dueDate: Date");
3356
+ dateParts.push("dueDate: $dueDate");
3357
+ variables.dueDate = options.due_date;
3358
+ }
3359
+ inputParts.push(`startAndDueDateWidget: { ${dateParts.join(", ")} }`);
3360
+ }
3361
+ if (options.milestone_id !== undefined) {
3362
+ // Convert numeric ID to GID format if needed
3363
+ const milestoneGID = options.milestone_id.startsWith("gid://")
3364
+ ? options.milestone_id
3365
+ : `gid://gitlab/Milestone/${options.milestone_id}`;
3366
+ varDefs.push("$milestoneId: MilestoneID");
3367
+ inputParts.push("milestoneWidget: { milestoneId: $milestoneId }");
3368
+ variables.milestoneId = milestoneGID;
3369
+ }
3370
+ if (options.iteration_id !== undefined) {
3371
+ const iterationGID = options.iteration_id.startsWith("gid://")
3372
+ ? options.iteration_id
3373
+ : `gid://gitlab/Iteration/${options.iteration_id}`;
3374
+ varDefs.push("$iterationId: IterationID");
3375
+ inputParts.push("iterationWidget: { iterationId: $iterationId }");
3376
+ variables.iterationId = iterationGID;
3377
+ }
3378
+ if (options.confidential !== undefined) {
3379
+ varDefs.push("$confidential: Boolean");
3380
+ inputParts.push("confidential: $confidential");
3381
+ variables.confidential = options.confidential;
3382
+ }
3383
+ // Custom fields widget
3384
+ if (options.custom_fields && options.custom_fields.length > 0) {
3385
+ const cfValues = options.custom_fields.map(cf => {
3386
+ const cfId = cf.custom_field_id.startsWith("gid://")
3387
+ ? cf.custom_field_id
3388
+ : `gid://gitlab/IssuablesCustomField/${cf.custom_field_id}`;
3389
+ const val = { customFieldId: cfId };
3390
+ if (cf.text_value !== undefined)
3391
+ val.textValue = cf.text_value;
3392
+ if (cf.number_value !== undefined)
3393
+ val.numberValue = cf.number_value;
3394
+ if (cf.selected_option_ids !== undefined)
3395
+ val.selectedOptionIds = cf.selected_option_ids;
3396
+ if (cf.date_value !== undefined)
3397
+ val.dateValue = cf.date_value;
3398
+ return val;
3399
+ });
3400
+ varDefs.push("$customFieldsWidget: [WorkItemWidgetCustomFieldValueInputType!]");
3401
+ inputParts.push("customFieldsWidget: $customFieldsWidget");
3402
+ variables.customFieldsWidget = cfValues;
3403
+ }
3404
+ // Hierarchy: set parent or remove parent
3405
+ if (options.remove_parent) {
3406
+ inputParts.push("hierarchyWidget: { parentId: null }");
3407
+ }
3408
+ else if (options.parent_iid !== undefined) {
3409
+ const parentProjectId = options.parent_project_id || projectId;
3410
+ const { workItemGID: parentGID } = await resolveWorkItemGID(parentProjectId, options.parent_iid);
3411
+ varDefs.push("$parentId: WorkItemID");
3412
+ inputParts.push("hierarchyWidget: { parentId: $parentId }");
3413
+ variables.parentId = parentGID;
3414
+ }
3415
+ // Execute the main update mutation
3416
+ const mutation = `mutation(${varDefs.join(", ")}) {
3417
+ workItemUpdate(input: { ${inputParts.join(", ")} }) {
3418
+ workItem {
3419
+ id
3420
+ iid
3421
+ title
3422
+ state
3423
+ webUrl
3424
+ workItemType { name }
3425
+ widgets {
3426
+ __typename
3427
+ ... on WorkItemWidgetStatus { status { id name category color } }
3428
+ ... on WorkItemWidgetLabels { labels { nodes { title } } }
3429
+ ... on WorkItemWidgetAssignees { assignees { nodes { username } } }
3430
+ ... on WorkItemWidgetWeight { weight }
3431
+ ... on WorkItemWidgetHierarchy {
3432
+ parent { id title workItemType { name } }
3433
+ }
3434
+ ... on WorkItemWidgetHealthStatus { healthStatus }
3435
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
3436
+ ... on WorkItemWidgetMilestone { milestone { id title } }
3437
+ }
3438
+ }
3439
+ errors
3440
+ }
3441
+ }`;
3442
+ const data = await executeGraphQL(mutation, variables);
3443
+ if (data.workItemUpdate.errors?.length > 0) {
3444
+ throw new Error(`Failed to update work item: ${data.workItemUpdate.errors.join(", ")}`);
3445
+ }
3446
+ // Handle children_to_add: use separate workItemUpdate call with hierarchyWidget.childrenIds
3447
+ if (options.children_to_add && options.children_to_add.length > 0) {
3448
+ const childGIDs = [];
3449
+ for (const child of options.children_to_add) {
3450
+ const { workItemGID: childGID } = await resolveWorkItemGID(child.project_id, child.iid);
3451
+ childGIDs.push(childGID);
3452
+ }
3453
+ const addData = await executeGraphQL(`mutation($id: WorkItemID!, $childrenIds: [WorkItemID!]!) {
3454
+ workItemUpdate(input: { id: $id, hierarchyWidget: { childrenIds: $childrenIds } }) {
3455
+ errors
3456
+ }
3457
+ }`, { id: workItemGID, childrenIds: childGIDs });
3458
+ if (addData.workItemUpdate.errors?.length > 0) {
3459
+ throw new Error(`Failed to add children: ${addData.workItemUpdate.errors.join(", ")}`);
3460
+ }
3461
+ }
3462
+ // Handle children_to_remove: remove parent from each child
3463
+ if (options.children_to_remove && options.children_to_remove.length > 0) {
3464
+ for (const child of options.children_to_remove) {
3465
+ await removeIssueParent(child.project_id, child.iid);
3466
+ }
3467
+ }
3468
+ // Handle linked_items_to_add: use workItemAddLinkedItems mutation
3469
+ if (options.linked_items_to_add && options.linked_items_to_add.length > 0) {
3470
+ // Group by link_type since each mutation call needs a single linkType
3471
+ const groupedByType = {};
3472
+ for (const item of options.linked_items_to_add) {
3473
+ const linkType = item.link_type || "RELATED";
3474
+ if (!groupedByType[linkType])
3475
+ groupedByType[linkType] = [];
3476
+ const { workItemGID: targetGID } = await resolveWorkItemGID(item.project_id, item.iid);
3477
+ groupedByType[linkType].push(targetGID);
3478
+ }
3479
+ for (const [linkType, targetGIDs] of Object.entries(groupedByType)) {
3480
+ const addLinkedData = await executeGraphQL(`mutation($id: WorkItemID!, $workItemsIds: [WorkItemID!]!, $linkType: WorkItemRelatedLinkType!) {
3481
+ workItemAddLinkedItems(input: { id: $id, workItemsIds: $workItemsIds, linkType: $linkType }) {
3482
+ errors
3483
+ }
3484
+ }`, { id: workItemGID, workItemsIds: targetGIDs, linkType });
3485
+ if (addLinkedData.workItemAddLinkedItems.errors?.length > 0) {
3486
+ throw new Error(`Failed to add linked items: ${addLinkedData.workItemAddLinkedItems.errors.join(", ")}`);
3487
+ }
3488
+ }
3489
+ }
3490
+ // Handle linked_items_to_remove: use workItemRemoveLinkedItems mutation
3491
+ if (options.linked_items_to_remove && options.linked_items_to_remove.length > 0) {
3492
+ const targetGIDs = [];
3493
+ for (const item of options.linked_items_to_remove) {
3494
+ const { workItemGID: targetGID } = await resolveWorkItemGID(item.project_id, item.iid);
3495
+ targetGIDs.push(targetGID);
3496
+ }
3497
+ const removeLinkedData = await executeGraphQL(`mutation($id: WorkItemID!, $workItemsIds: [WorkItemID!]!) {
3498
+ workItemRemoveLinkedItems(input: { id: $id, workItemsIds: $workItemsIds }) {
3499
+ errors
3500
+ }
3501
+ }`, { id: workItemGID, workItemsIds: targetGIDs });
3502
+ if (removeLinkedData.workItemRemoveLinkedItems.errors?.length > 0) {
3503
+ throw new Error(`Failed to remove linked items: ${removeLinkedData.workItemRemoveLinkedItems.errors.join(", ")}`);
3504
+ }
3505
+ }
3506
+ // Handle incident-specific fields via separate mutations
3507
+ if (options.severity !== undefined) {
3508
+ await updateIncidentSeverity(projectPath, iid, options.severity);
3509
+ }
3510
+ if (options.escalation_status !== undefined) {
3511
+ await updateIncidentEscalationStatus(projectPath, iid, options.escalation_status);
3512
+ }
3513
+ // Flatten the response
3514
+ const wi = data.workItemUpdate.workItem;
3515
+ const widgets = wi?.widgets || [];
3516
+ const statusW = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
3517
+ const labelsW = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
3518
+ const assigneesW = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
3519
+ const weightW = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
3520
+ const hierarchyW = widgets.find((w) => w.__typename === "WorkItemWidgetHierarchy");
3521
+ const healthStatusW = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
3522
+ const datesW = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
3523
+ const milestoneW = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
3524
+ return {
3525
+ id: wi.id,
3526
+ iid: wi.iid,
3527
+ title: wi.title,
3528
+ state: wi.state,
3529
+ type: wi.workItemType?.name,
3530
+ webUrl: wi.webUrl,
3531
+ status: statusW?.status || null,
3532
+ labels: (labelsW?.labels?.nodes || []).map((l) => l.title),
3533
+ assignees: (assigneesW?.assignees?.nodes || []).map((a) => a.username),
3534
+ weight: weightW?.weight ?? null,
3535
+ parent: hierarchyW?.parent || null,
3536
+ healthStatus: healthStatusW?.healthStatus || null,
3537
+ startDate: datesW?.startDate || null,
3538
+ dueDate: datesW?.dueDate || null,
3539
+ milestone: milestoneW?.milestone || null,
3540
+ children_added: options.children_to_add?.length || 0,
3541
+ children_removed: options.children_to_remove?.length || 0,
3542
+ linked_items_added: options.linked_items_to_add?.length || 0,
3543
+ linked_items_removed: options.linked_items_to_remove?.length || 0,
3544
+ ...(options.severity !== undefined && { severity: options.severity }),
3545
+ ...(options.escalation_status !== undefined && { escalation_status: options.escalation_status }),
3546
+ };
3547
+ }
1927
3548
  /**
1928
3549
  * List all issue links for a specific issue
1929
3550
  * 이슈 관계 목록 조회
@@ -2344,6 +3965,12 @@ async function updateMergeRequestNote(projectId, mergeRequestIid, noteId, body)
2344
3965
  const data = await response.json();
2345
3966
  return GitLabDiscussionNoteSchema.parse(data);
2346
3967
  }
3968
+ function encodeRepoFilePayloadContent(content) {
3969
+ if (GITLAB_REPO_FILE_ENCODING === "base64") {
3970
+ return Buffer.from(content).toString("base64");
3971
+ }
3972
+ return content;
3973
+ }
2347
3974
  /**
2348
3975
  * Create or update a file in a GitLab project
2349
3976
  * 파일 생성 또는 업데이트
@@ -2362,9 +3989,9 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
2362
3989
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}`);
2363
3990
  const body = {
2364
3991
  branch,
2365
- content,
3992
+ content: encodeRepoFilePayloadContent(content),
2366
3993
  commit_message: commitMessage,
2367
- encoding: "text",
3994
+ encoding: GITLAB_REPO_FILE_ENCODING,
2368
3995
  ...(previousPath ? { previous_path: previousPath } : {}),
2369
3996
  };
2370
3997
  // Check if file exists
@@ -2436,8 +4063,8 @@ async function createTree(projectId, files, ref) {
2436
4063
  body: JSON.stringify({
2437
4064
  files: files.map(file => ({
2438
4065
  file_path: file.path,
2439
- content: file.content,
2440
- encoding: "text",
4066
+ content: encodeRepoFilePayloadContent(file.content),
4067
+ encoding: GITLAB_REPO_FILE_ENCODING,
2441
4068
  })),
2442
4069
  }),
2443
4070
  });
@@ -2474,8 +4101,8 @@ async function createCommit(projectId, message, branch, actions) {
2474
4101
  actions: actions.map(action => ({
2475
4102
  action: "create",
2476
4103
  file_path: action.path,
2477
- content: action.content,
2478
- encoding: "text",
4104
+ content: encodeRepoFilePayloadContent(action.content),
4105
+ encoding: GITLAB_REPO_FILE_ENCODING,
2479
4106
  })),
2480
4107
  }),
2481
4108
  });
@@ -2525,6 +4152,52 @@ async function searchProjects(query, page = 1, perPage = 20) {
2525
4152
  items: projects,
2526
4153
  });
2527
4154
  }
4155
+ /**
4156
+ * Search for code blobs using GitLab Search API
4157
+ * Supports global, project-level, and group-level search
4158
+ */
4159
+ async function searchBlobs(params) {
4160
+ let basePath;
4161
+ if (params.project_id) {
4162
+ const decodedProjectId = decodeURIComponent(params.project_id);
4163
+ const projectId = encodeURIComponent(getEffectiveProjectId(decodedProjectId));
4164
+ basePath = `${getEffectiveApiUrl()}/projects/${projectId}/search`;
4165
+ }
4166
+ else if (params.group_id) {
4167
+ const groupId = encodeURIComponent(decodeURIComponent(params.group_id));
4168
+ basePath = `${getEffectiveApiUrl()}/groups/${groupId}/search`;
4169
+ }
4170
+ else {
4171
+ basePath = `${getEffectiveApiUrl()}/search`;
4172
+ }
4173
+ const url = new URL(basePath);
4174
+ url.searchParams.append("scope", "blobs");
4175
+ url.searchParams.append("search", params.search);
4176
+ if (params.ref) {
4177
+ url.searchParams.append("ref", params.ref);
4178
+ }
4179
+ if (params.page) {
4180
+ url.searchParams.append("page", params.page.toString());
4181
+ }
4182
+ if (params.per_page) {
4183
+ url.searchParams.append("per_page", params.per_page.toString());
4184
+ }
4185
+ if (params.filename) {
4186
+ url.searchParams.append("filename", params.filename);
4187
+ }
4188
+ if (params.path) {
4189
+ url.searchParams.append("path", params.path);
4190
+ }
4191
+ if (params.extension) {
4192
+ url.searchParams.append("extension", params.extension);
4193
+ }
4194
+ const response = await fetch(url.toString(), {
4195
+ ...getFetchConfig(),
4196
+ });
4197
+ await handleGitLabError(response);
4198
+ const data = await response.json();
4199
+ return z.array(GitLabSearchBlobResultSchema).parse(data);
4200
+ }
2528
4201
  /**
2529
4202
  * Create a new GitLab repository
2530
4203
  * 새 저장소 생성
@@ -2850,14 +4523,107 @@ async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, pag
2850
4523
  if (perPage) {
2851
4524
  url.searchParams.append("per_page", perPage.toString());
2852
4525
  }
2853
- if (unidiff) {
2854
- url.searchParams.append("unidiff", "true");
4526
+ if (unidiff) {
4527
+ url.searchParams.append("unidiff", "true");
4528
+ }
4529
+ const response = await fetch(url.toString(), {
4530
+ ...getFetchConfig(),
4531
+ });
4532
+ await handleGitLabError(response);
4533
+ return await response.json(); // Return full response including commits, diff_refs, changes, etc.
4534
+ }
4535
+ /**
4536
+ * Returns the list of changed files in a merge request WITHOUT diff content.
4537
+ * Use this as STEP 1 of code review: get file paths, then fetch diffs in batches
4538
+ * with getMergeRequestFileDiff to avoid loading the entire diff payload at once.
4539
+ *
4540
+ * @param {string} projectId - The ID or URL-encoded path of the project
4541
+ * @param {number|string} [mergeRequestIid] - The internal ID of the merge request
4542
+ * @param {string} [branchName] - The name of the source branch (used to resolve MR if iid not provided)
4543
+ * @param {string[]} [excludedFilePatterns] - Regex patterns to exclude files from the result
4544
+ * @returns {Promise<any[]>} Array of changed file metadata (new_path, old_path, new_file, deleted_file, renamed_file)
4545
+ */
4546
+ async function listMergeRequestChangedFiles(projectId, mergeRequestIid, branchName, excludedFilePatterns) {
4547
+ projectId = decodeURIComponent(projectId);
4548
+ if (!mergeRequestIid && !branchName) {
4549
+ throw new Error("Either mergeRequestIid or branchName must be provided");
4550
+ }
4551
+ if (branchName && !mergeRequestIid) {
4552
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
4553
+ mergeRequestIid = mergeRequest.iid;
4554
+ }
4555
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/changes`);
4556
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4557
+ await handleGitLabError(response);
4558
+ const data = (await response.json());
4559
+ const rawFiles = (data.changes || []).map((f) => ({
4560
+ new_path: f.new_path,
4561
+ old_path: f.old_path,
4562
+ new_file: f.new_file,
4563
+ deleted_file: f.deleted_file,
4564
+ renamed_file: f.renamed_file,
4565
+ }));
4566
+ return filterDiffsByPatterns(rawFiles, excludedFilePatterns);
4567
+ }
4568
+ /**
4569
+ * Get diffs for specific files from a merge request.
4570
+ * Use this as STEP 2 of code review: pass file paths obtained from
4571
+ * listMergeRequestChangedFiles to fetch their diffs efficiently.
4572
+ *
4573
+ * @param {string} projectId - The ID or URL-encoded path of the project
4574
+ * @param {string[]} filePaths - List of file paths to retrieve diffs for
4575
+ * @param {number|string} [mergeRequestIid] - The internal ID of the merge request
4576
+ * @param {string} [branchName] - The name of the source branch (used to resolve MR if iid not provided)
4577
+ * @param {boolean} [unidiff] - Return diff in unified diff format
4578
+ * @returns {Promise<any[]>} Array of diff objects for each requested file, or error objects for files not found
4579
+ */
4580
+ async function getMergeRequestFileDiff(projectId, filePaths, mergeRequestIid, branchName, unidiff) {
4581
+ projectId = decodeURIComponent(projectId);
4582
+ if (!mergeRequestIid && !branchName) {
4583
+ throw new Error("Either mergeRequestIid or branchName must be provided");
4584
+ }
4585
+ if (branchName && !mergeRequestIid) {
4586
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
4587
+ mergeRequestIid = mergeRequest.iid;
4588
+ }
4589
+ // Paginate through /diffs once, collecting all requested files.
4590
+ // More efficient than N separate searches when fetching multiple files.
4591
+ const remaining = new Set(filePaths);
4592
+ const results = [];
4593
+ let page = 1;
4594
+ const perPage = 20;
4595
+ while (remaining.size > 0) {
4596
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/diffs`);
4597
+ url.searchParams.append("page", page.toString());
4598
+ url.searchParams.append("per_page", perPage.toString());
4599
+ if (unidiff) {
4600
+ url.searchParams.append("unidiff", "true");
4601
+ }
4602
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4603
+ await handleGitLabError(response);
4604
+ const items = (await response.json());
4605
+ if (!Array.isArray(items) || items.length === 0) {
4606
+ break;
4607
+ }
4608
+ for (const item of items) {
4609
+ if (remaining.has(item.new_path) || remaining.has(item.old_path)) {
4610
+ results.push(item);
4611
+ remaining.delete(item.new_path);
4612
+ remaining.delete(item.old_path);
4613
+ }
4614
+ }
4615
+ if (items.length < perPage) {
4616
+ break;
4617
+ }
4618
+ page++;
4619
+ }
4620
+ for (const notFound of remaining) {
4621
+ results.push({
4622
+ error: `File not found in merge request diffs: ${notFound}`,
4623
+ hint: "Use list_merge_request_changed_files to verify the correct file paths.",
4624
+ });
2855
4625
  }
2856
- const response = await fetch(url.toString(), {
2857
- ...getFetchConfig(),
2858
- });
2859
- await handleGitLabError(response);
2860
- return await response.json(); // Return full response including commits, diff_refs, changes, etc.
4626
+ return results;
2861
4627
  }
2862
4628
  /**
2863
4629
  * Get branch comparison diffs
@@ -3007,6 +4773,23 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
3007
4773
  source_endpoint: "approval_state",
3008
4774
  };
3009
4775
  }
4776
+ /**
4777
+ * Get the conflicts of a merge request
4778
+ *
4779
+ * @param {string} projectId - The ID or URL-encoded path of the project
4780
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
4781
+ * @returns {Promise<Record<string, unknown>>} The merge request conflicts
4782
+ */
4783
+ async function getMergeRequestConflicts(projectId, mergeRequestIid) {
4784
+ projectId = decodeURIComponent(projectId);
4785
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/conflicts`);
4786
+ const response = await fetch(url.toString(), {
4787
+ ...getFetchConfig(),
4788
+ method: "GET",
4789
+ });
4790
+ await handleGitLabError(response);
4791
+ return (await response.json());
4792
+ }
3010
4793
  async function getMergeRequestApprovalsFallback(projectId, mergeRequestIid) {
3011
4794
  const approvalsUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approvals`);
3012
4795
  const approvalsResponse = await fetch(approvalsUrl.toString(), {
@@ -3088,7 +4871,7 @@ noteableIid, body) {
3088
4871
  * @returns {Promise<GitLabDraftNote[]>} Array of draft notes
3089
4872
  */
3090
4873
  async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
3091
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}`);
4874
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}`, { ...getFetchConfig() });
3092
4875
  if (!response.ok) {
3093
4876
  const errorText = await response.text();
3094
4877
  throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
@@ -3828,6 +5611,84 @@ async function deleteWikiPage(projectId, slug) {
3828
5611
  });
3829
5612
  await handleGitLabError(response);
3830
5613
  }
5614
+ /**
5615
+ * List wiki pages in a GitLab group
5616
+ */
5617
+ async function listGroupWikiPages(groupId, options = {}) {
5618
+ groupId = decodeURIComponent(groupId); // Decode group ID
5619
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis`);
5620
+ if (options.page)
5621
+ url.searchParams.append("page", options.page.toString());
5622
+ if (options.per_page)
5623
+ url.searchParams.append("per_page", options.per_page.toString());
5624
+ if (options.with_content)
5625
+ url.searchParams.append("with_content", options.with_content.toString());
5626
+ const response = await fetch(url.toString(), {
5627
+ ...getFetchConfig(),
5628
+ });
5629
+ await handleGitLabError(response);
5630
+ const data = await response.json();
5631
+ return GitLabWikiPageSchema.array().parse(data);
5632
+ }
5633
+ /**
5634
+ * Get a specific group wiki page
5635
+ */
5636
+ async function getGroupWikiPage(groupId, slug) {
5637
+ groupId = decodeURIComponent(groupId); // Decode group ID
5638
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, { ...getFetchConfig() });
5639
+ await handleGitLabError(response);
5640
+ const data = await response.json();
5641
+ return GitLabWikiPageSchema.parse(data);
5642
+ }
5643
+ /**
5644
+ * Create a new group wiki page
5645
+ */
5646
+ async function createGroupWikiPage(groupId, title, content, format) {
5647
+ groupId = decodeURIComponent(groupId); // Decode group ID
5648
+ const body = { title, content };
5649
+ if (format)
5650
+ body.format = format;
5651
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis`, {
5652
+ ...getFetchConfig(),
5653
+ method: "POST",
5654
+ body: JSON.stringify(body),
5655
+ });
5656
+ await handleGitLabError(response);
5657
+ const data = await response.json();
5658
+ return GitLabWikiPageSchema.parse(data);
5659
+ }
5660
+ /**
5661
+ * Update an existing group wiki page
5662
+ */
5663
+ async function updateGroupWikiPage(groupId, slug, title, content, format) {
5664
+ groupId = decodeURIComponent(groupId); // Decode group ID
5665
+ const body = {};
5666
+ if (title)
5667
+ body.title = title;
5668
+ if (content)
5669
+ body.content = content;
5670
+ if (format)
5671
+ body.format = format;
5672
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, {
5673
+ ...getFetchConfig(),
5674
+ method: "PUT",
5675
+ body: JSON.stringify(body),
5676
+ });
5677
+ await handleGitLabError(response);
5678
+ const data = await response.json();
5679
+ return GitLabWikiPageSchema.parse(data);
5680
+ }
5681
+ /**
5682
+ * Delete a group wiki page
5683
+ */
5684
+ async function deleteGroupWikiPage(groupId, slug) {
5685
+ groupId = decodeURIComponent(groupId); // Decode group ID
5686
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, {
5687
+ ...getFetchConfig(),
5688
+ method: "DELETE",
5689
+ });
5690
+ await handleGitLabError(response);
5691
+ }
3831
5692
  /**
3832
5693
  * List pipelines in a GitLab project
3833
5694
  *
@@ -4181,11 +6042,8 @@ async function createPipeline(projectId, ref, variables, inputs) {
4181
6042
  body.inputs = inputs;
4182
6043
  }
4183
6044
  const response = await fetch(url.toString(), {
6045
+ ...getFetchConfig(),
4184
6046
  method: "POST",
4185
- headers: {
4186
- ...BASE_HEADERS,
4187
- ...buildAuthHeaders(),
4188
- },
4189
6047
  body: JSON.stringify(body),
4190
6048
  });
4191
6049
  await handleGitLabError(response);
@@ -4203,11 +6061,8 @@ async function retryPipeline(projectId, pipelineId) {
4203
6061
  projectId = decodeURIComponent(projectId); // Decode project ID
4204
6062
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
4205
6063
  const response = await fetch(url.toString(), {
6064
+ ...getFetchConfig(),
4206
6065
  method: "POST",
4207
- headers: {
4208
- ...BASE_HEADERS,
4209
- ...buildAuthHeaders(),
4210
- },
4211
6066
  });
4212
6067
  await handleGitLabError(response);
4213
6068
  const data = await response.json();
@@ -4224,11 +6079,8 @@ async function cancelPipeline(projectId, pipelineId) {
4224
6079
  projectId = decodeURIComponent(projectId); // Decode project ID
4225
6080
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
4226
6081
  const response = await fetch(url.toString(), {
6082
+ ...getFetchConfig(),
4227
6083
  method: "POST",
4228
- headers: {
4229
- ...BASE_HEADERS,
4230
- ...buildAuthHeaders(),
4231
- },
4232
6084
  });
4233
6085
  await handleGitLabError(response);
4234
6086
  const data = await response.json();
@@ -4319,13 +6171,7 @@ async function getRepositoryTree(options) {
4319
6171
  queryParams.append("page_token", options.page_token);
4320
6172
  if (options.pagination)
4321
6173
  queryParams.append("pagination", options.pagination);
4322
- const headers = {
4323
- ...BASE_HEADERS,
4324
- ...buildAuthHeaders(),
4325
- };
4326
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
4327
- headers,
4328
- });
6174
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
4329
6175
  if (response.status === 404) {
4330
6176
  throw new Error("Repository or path not found");
4331
6177
  }
@@ -4786,15 +6632,12 @@ async function markdownUpload(projectId, filePath) {
4786
6632
  contentType: "application/octet-stream",
4787
6633
  });
4788
6634
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads`);
6635
+ const defaultFetchConfig = getFetchConfig();
6636
+ delete defaultFetchConfig.headers["Content-Type"]; // Let form-data set the correct Content-Type with boundary
4789
6637
  const response = await fetch(url.toString(), {
6638
+ ...defaultFetchConfig,
4790
6639
  method: "POST",
4791
- headers: {
4792
- ...BASE_HEADERS,
4793
- ...buildAuthHeaders(),
4794
- // Remove Content-Type header to let form-data set it with boundary
4795
- "Content-Type": undefined,
4796
- },
4797
- body: form,
6640
+ body: form
4798
6641
  });
4799
6642
  if (!response.ok) {
4800
6643
  await handleGitLabError(response);
@@ -4820,11 +6663,8 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
4820
6663
  const effectiveProjectId = getEffectiveProjectId(projectId);
4821
6664
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
4822
6665
  const response = await fetch(url.toString(), {
6666
+ ...getFetchConfig(),
4823
6667
  method: "GET",
4824
- headers: {
4825
- ...BASE_HEADERS,
4826
- ...buildAuthHeaders(),
4827
- },
4828
6668
  });
4829
6669
  if (!response.ok) {
4830
6670
  await handleGitLabError(response);
@@ -4871,13 +6711,7 @@ async function listEvents(options = {}) {
4871
6711
  url.searchParams.append(key, value.toString());
4872
6712
  }
4873
6713
  });
4874
- const response = await fetch(url.toString(), {
4875
- method: "GET",
4876
- headers: {
4877
- ...BASE_HEADERS,
4878
- ...buildAuthHeaders(),
4879
- },
4880
- });
6714
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4881
6715
  if (!response.ok) {
4882
6716
  await handleGitLabError(response);
4883
6717
  }
@@ -4899,13 +6733,7 @@ async function getProjectEvents(projectId, options = {}) {
4899
6733
  url.searchParams.append(key, value.toString());
4900
6734
  }
4901
6735
  });
4902
- const response = await fetch(url.toString(), {
4903
- method: "GET",
4904
- headers: {
4905
- ...BASE_HEADERS,
4906
- ...buildAuthHeaders(),
4907
- },
4908
- });
6736
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4909
6737
  if (!response.ok) {
4910
6738
  await handleGitLabError(response);
4911
6739
  }
@@ -5189,6 +7017,51 @@ async function handleToolCall(params) {
5189
7017
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
5190
7018
  };
5191
7019
  }
7020
+ case "search_code": {
7021
+ const args = SearchCodeSchema.parse(params.arguments);
7022
+ const results = await searchBlobs({
7023
+ search: args.search,
7024
+ filename: args.filename,
7025
+ path: args.path,
7026
+ extension: args.extension,
7027
+ page: args.page,
7028
+ per_page: args.per_page,
7029
+ });
7030
+ return {
7031
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7032
+ };
7033
+ }
7034
+ case "search_project_code": {
7035
+ const args = SearchProjectCodeSchema.parse(params.arguments);
7036
+ const results = await searchBlobs({
7037
+ search: args.search,
7038
+ project_id: args.project_id,
7039
+ ref: args.ref,
7040
+ filename: args.filename,
7041
+ path: args.path,
7042
+ extension: args.extension,
7043
+ page: args.page,
7044
+ per_page: args.per_page,
7045
+ });
7046
+ return {
7047
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7048
+ };
7049
+ }
7050
+ case "search_group_code": {
7051
+ const args = SearchGroupCodeSchema.parse(params.arguments);
7052
+ const results = await searchBlobs({
7053
+ search: args.search,
7054
+ group_id: args.group_id,
7055
+ filename: args.filename,
7056
+ path: args.path,
7057
+ extension: args.extension,
7058
+ page: args.page,
7059
+ per_page: args.per_page,
7060
+ });
7061
+ return {
7062
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7063
+ };
7064
+ }
5192
7065
  case "create_repository": {
5193
7066
  if (GITLAB_PROJECT_ID) {
5194
7067
  throw new Error("Direct project ID is set. So fork_repository is not allowed");
@@ -5338,6 +7211,13 @@ async function handleToolCall(params) {
5338
7211
  content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
5339
7212
  };
5340
7213
  }
7214
+ case "list_merge_request_changed_files": {
7215
+ const args = ListMergeRequestChangedFilesSchema.parse(params.arguments);
7216
+ const files = await listMergeRequestChangedFiles(args.project_id, args.merge_request_iid, args.source_branch, args.excluded_file_patterns);
7217
+ return {
7218
+ content: [{ type: "text", text: JSON.stringify(files, null, 2) }],
7219
+ };
7220
+ }
5341
7221
  case "list_merge_request_diffs": {
5342
7222
  const args = ListMergeRequestDiffsSchema.parse(params.arguments);
5343
7223
  const changes = await listMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.page, args.per_page, args.unidiff);
@@ -5345,6 +7225,13 @@ async function handleToolCall(params) {
5345
7225
  content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
5346
7226
  };
5347
7227
  }
7228
+ case "get_merge_request_file_diff": {
7229
+ const args = GetMergeRequestFileDiffSchema.parse(params.arguments);
7230
+ const fileDiff = await getMergeRequestFileDiff(args.project_id, args.file_paths, args.merge_request_iid, args.source_branch, args.unidiff);
7231
+ return {
7232
+ content: [{ type: "text", text: JSON.stringify(fileDiff, null, 2) }],
7233
+ };
7234
+ }
5348
7235
  case "list_merge_request_versions": {
5349
7236
  const args = ListMergeRequestVersionsSchema.parse(params.arguments);
5350
7237
  const versions = await listMergeRequestVersions(args.project_id, args.merge_request_iid);
@@ -5396,6 +7283,13 @@ async function handleToolCall(params) {
5396
7283
  content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
5397
7284
  };
5398
7285
  }
7286
+ case "get_merge_request_conflicts": {
7287
+ const args = GetMergeRequestConflictsSchema.parse(params.arguments);
7288
+ const conflicts = await getMergeRequestConflicts(args.project_id, args.merge_request_iid);
7289
+ return {
7290
+ content: [{ type: "text", text: JSON.stringify(conflicts, null, 2) }],
7291
+ };
7292
+ }
5399
7293
  case "mr_discussions": {
5400
7294
  const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
5401
7295
  const { project_id, merge_request_iid, ...options } = args;
@@ -5664,6 +7558,93 @@ async function handleToolCall(params) {
5664
7558
  ],
5665
7559
  };
5666
7560
  }
7561
+ case "get_work_item": {
7562
+ const args = GetWorkItemSchema.parse(params.arguments);
7563
+ const result = await getWorkItem(args.project_id, args.iid);
7564
+ return {
7565
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7566
+ };
7567
+ }
7568
+ case "list_work_items": {
7569
+ const args = ListWorkItemsSchema.parse(params.arguments);
7570
+ const { project_id, ...options } = args;
7571
+ const result = await listWorkItems(project_id, options);
7572
+ return {
7573
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7574
+ };
7575
+ }
7576
+ case "create_work_item": {
7577
+ const args = CreateWorkItemSchema.parse(params.arguments);
7578
+ const { project_id, ...options } = args;
7579
+ const result = await createWorkItem(project_id, options);
7580
+ return {
7581
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7582
+ };
7583
+ }
7584
+ case "update_work_item": {
7585
+ const args = UpdateWorkItemSchema.parse(params.arguments);
7586
+ const { project_id, iid, ...options } = args;
7587
+ const result = await updateWorkItem(project_id, iid, options);
7588
+ return {
7589
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7590
+ };
7591
+ }
7592
+ case "convert_work_item_type": {
7593
+ const args = ConvertWorkItemTypeSchema.parse(params.arguments);
7594
+ const result = await convertIssueType(args.project_id, args.iid, args.new_type);
7595
+ return {
7596
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7597
+ };
7598
+ }
7599
+ case "list_work_item_statuses": {
7600
+ const args = ListWorkItemStatusesSchema.parse(params.arguments);
7601
+ const result = await listIssueStatuses(args.project_id, args.work_item_type);
7602
+ return {
7603
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7604
+ };
7605
+ }
7606
+ case "list_custom_field_definitions": {
7607
+ const args = ListCustomFieldDefinitionsSchema.parse(params.arguments);
7608
+ const result = await listCustomFieldDefinitions(args.project_id, args.work_item_type);
7609
+ return {
7610
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7611
+ };
7612
+ }
7613
+ case "move_work_item": {
7614
+ const args = MoveWorkItemSchema.parse(params.arguments);
7615
+ const result = await moveWorkItem(args.project_id, args.iid, args.target_project_id);
7616
+ return {
7617
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7618
+ };
7619
+ }
7620
+ case "list_work_item_notes": {
7621
+ const args = ListWorkItemNotesSchema.parse(params.arguments);
7622
+ const result = await listWorkItemNotes(args.project_id, args.iid, args);
7623
+ return {
7624
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7625
+ };
7626
+ }
7627
+ case "create_work_item_note": {
7628
+ const args = CreateWorkItemNoteSchema.parse(params.arguments);
7629
+ const result = await createWorkItemNote(args.project_id, args.iid, args.body, args);
7630
+ return {
7631
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7632
+ };
7633
+ }
7634
+ case "get_timeline_events": {
7635
+ const args = GetTimelineEventsSchema.parse(params.arguments);
7636
+ const result = await getTimelineEvents(args.project_id, args.incident_iid);
7637
+ return {
7638
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7639
+ };
7640
+ }
7641
+ case "create_timeline_event": {
7642
+ const args = CreateTimelineEventSchema.parse(params.arguments);
7643
+ const result = await createTimelineEvent(args.project_id, args.incident_iid, args.note, args.occurred_at, args.tag_names);
7644
+ return {
7645
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7646
+ };
7647
+ }
5667
7648
  case "list_labels": {
5668
7649
  const args = ListLabelsSchema.parse(params.arguments);
5669
7650
  const labels = await listLabels(args.project_id, args);
@@ -5759,6 +7740,53 @@ async function handleToolCall(params) {
5759
7740
  ],
5760
7741
  };
5761
7742
  }
7743
+ case "list_group_wiki_pages": {
7744
+ const { group_id, page, per_page, with_content } = ListGroupWikiPagesSchema.parse(params.arguments);
7745
+ const wikiPages = await listGroupWikiPages(group_id, {
7746
+ page,
7747
+ per_page,
7748
+ with_content,
7749
+ });
7750
+ return {
7751
+ content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }],
7752
+ };
7753
+ }
7754
+ case "get_group_wiki_page": {
7755
+ const { group_id, slug } = GetGroupWikiPageSchema.parse(params.arguments);
7756
+ const wikiPage = await getGroupWikiPage(group_id, slug);
7757
+ return {
7758
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7759
+ };
7760
+ }
7761
+ case "create_group_wiki_page": {
7762
+ const { group_id, title, content, format } = CreateGroupWikiPageSchema.parse(params.arguments);
7763
+ const wikiPage = await createGroupWikiPage(group_id, title, content, format);
7764
+ return {
7765
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7766
+ };
7767
+ }
7768
+ case "update_group_wiki_page": {
7769
+ const { group_id, slug, title, content, format } = UpdateGroupWikiPageSchema.parse(params.arguments);
7770
+ const wikiPage = await updateGroupWikiPage(group_id, slug, title, content, format);
7771
+ return {
7772
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7773
+ };
7774
+ }
7775
+ case "delete_group_wiki_page": {
7776
+ const { group_id, slug } = DeleteGroupWikiPageSchema.parse(params.arguments);
7777
+ await deleteGroupWikiPage(group_id, slug);
7778
+ return {
7779
+ content: [
7780
+ {
7781
+ type: "text",
7782
+ text: JSON.stringify({
7783
+ status: "success",
7784
+ message: "Group wiki page deleted successfully",
7785
+ }, null, 2),
7786
+ },
7787
+ ],
7788
+ };
7789
+ }
5762
7790
  case "get_repository_tree": {
5763
7791
  const args = GetRepositoryTreeSchema.parse(params.arguments);
5764
7792
  const tree = await getRepositoryTree(args);
@@ -5973,8 +8001,20 @@ async function handleToolCall(params) {
5973
8001
  };
5974
8002
  }
5975
8003
  case "list_merge_requests": {
5976
- const args = ListMergeRequestsSchema.parse(params.arguments);
5977
- const mergeRequests = await listMergeRequests(args.project_id, args);
8004
+ const { project_id, ...options } = ListMergeRequestsSchema.parse(params.arguments);
8005
+ // GitLab API treats _id and _username as mutually exclusive for these fields.
8006
+ // When both are provided, prefer _username and remove _id to avoid 400 errors.
8007
+ const cleanedOptions = { ...options };
8008
+ if (cleanedOptions.author_id && cleanedOptions.author_username) {
8009
+ delete cleanedOptions.author_id;
8010
+ }
8011
+ if (cleanedOptions.assignee_id && cleanedOptions.assignee_username) {
8012
+ delete cleanedOptions.assignee_id;
8013
+ }
8014
+ if (cleanedOptions.reviewer_id && cleanedOptions.reviewer_username) {
8015
+ delete cleanedOptions.reviewer_id;
8016
+ }
8017
+ const mergeRequests = await listMergeRequests(project_id, cleanedOptions);
5978
8018
  return {
5979
8019
  content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }],
5980
8020
  };
@@ -6306,6 +8346,10 @@ function determineTransportMode() {
6306
8346
  async function startStdioServer() {
6307
8347
  const serverInstance = createServer();
6308
8348
  const transport = new StdioServerTransport();
8349
+ transport.onclose = () => {
8350
+ logger.info("Stdio transport closed, releasing client pool");
8351
+ clientPool.closeAll();
8352
+ };
6309
8353
  await serverInstance.connect(transport);
6310
8354
  }
6311
8355
  /**
@@ -6314,6 +8358,7 @@ async function startStdioServer() {
6314
8358
  async function startSSEServer() {
6315
8359
  const app = express();
6316
8360
  const transports = {};
8361
+ let shuttingDown = false;
6317
8362
  app.get("/sse", async (_, res) => {
6318
8363
  const serverInstance = createServer();
6319
8364
  const transport = new SSEServerTransport("/messages", res);
@@ -6340,12 +8385,35 @@ async function startSSEServer() {
6340
8385
  transport: TransportMode.SSE,
6341
8386
  });
6342
8387
  });
6343
- app.listen(Number(PORT), HOST, () => {
8388
+ const httpServer = app.listen(Number(PORT), HOST, () => {
6344
8389
  logger.info(`GitLab MCP Server running with SSE transport`);
6345
8390
  const colorGreen = "\x1b[32m";
6346
8391
  const colorReset = "\x1b[0m";
6347
8392
  logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/sse${colorReset}`);
6348
8393
  });
8394
+ const shutdown = async (signal) => {
8395
+ if (shuttingDown)
8396
+ return;
8397
+ shuttingDown = true;
8398
+ logger.info(`${signal} received, shutting down SSE server...`);
8399
+ httpServer.close(() => logger.info("SSE HTTP server closed"));
8400
+ await Promise.allSettled(Object.values(transports).map(async (transport) => {
8401
+ try {
8402
+ await transport.close();
8403
+ }
8404
+ catch (error) {
8405
+ logger.error("Error closing SSE transport:", error);
8406
+ }
8407
+ }));
8408
+ clientPool.closeAll();
8409
+ process.exit(0);
8410
+ };
8411
+ process.on("SIGTERM", () => {
8412
+ void shutdown("SIGTERM");
8413
+ });
8414
+ process.on("SIGINT", () => {
8415
+ void shutdown("SIGINT");
8416
+ });
6349
8417
  }
6350
8418
  /**
6351
8419
  * Start server with Streamable HTTP transport
@@ -6399,10 +8467,12 @@ async function startStreamableHTTPServer() {
6399
8467
  /**
6400
8468
  * Parse authentication from request headers
6401
8469
  * Returns null if no auth found or invalid format
8470
+ * Supports: JOB-TOKEN header, Private-Token header, Authorization Bearer header
6402
8471
  */
6403
8472
  const parseAuthHeaders = (req) => {
6404
8473
  const authHeader = req.headers["authorization"] || "";
6405
8474
  const privateToken = req.headers["private-token"] || "";
8475
+ const jobToken = req.headers["job-token"] || "";
6406
8476
  const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
6407
8477
  let apiUrl = GITLAB_API_URL; // Default API URL
6408
8478
  // Only process dynamic URL if the feature is enabled
@@ -6419,7 +8489,11 @@ async function startStreamableHTTPServer() {
6419
8489
  // Extract token
6420
8490
  let token = null;
6421
8491
  let header = null;
6422
- if (privateToken) {
8492
+ if (jobToken) {
8493
+ token = jobToken.trim();
8494
+ header = "JOB-TOKEN";
8495
+ }
8496
+ else if (privateToken) {
6423
8497
  token = privateToken.trim();
6424
8498
  header = "Private-Token";
6425
8499
  }
@@ -6476,13 +8550,43 @@ async function startStreamableHTTPServer() {
6476
8550
  };
6477
8551
  // Configure Express middleware
6478
8552
  app.use(express.json());
8553
+ // MCP OAuth — mount auth router and prepare bearer-auth middleware
8554
+ if (GITLAB_MCP_OAUTH) {
8555
+ // Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP.
8556
+ // Only enabled in OAuth mode where the server is typically behind a reverse proxy.
8557
+ app.set("trust proxy", 1);
8558
+ const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
8559
+ const issuerUrl = new URL(MCP_SERVER_URL);
8560
+ const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE);
8561
+ // Mounts /.well-known/oauth-authorization-server,
8562
+ // /.well-known/oauth-protected-resource,
8563
+ // /authorize, /token, /register, /revoke
8564
+ app.use(mcpAuthRouter({
8565
+ provider: oauthProvider,
8566
+ issuerUrl,
8567
+ baseUrl: issuerUrl,
8568
+ scopesSupported: ["api", "read_api", "read_user"],
8569
+ resourceName: "GitLab MCP Server",
8570
+ }));
8571
+ // Expose provider so the /mcp route middleware can reference it
8572
+ app._mcpOAuthProvider = oauthProvider;
8573
+ }
8574
+ // Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
8575
+ // Unauthenticated requests receive 401 + WWW-Authenticate header, which is
8576
+ // exactly what Claude.ai needs to trigger the OAuth browser flow.
8577
+ const mcpBearerAuth = GITLAB_MCP_OAUTH
8578
+ ? requireBearerAuth({
8579
+ verifier: app._mcpOAuthProvider,
8580
+ requiredScopes: [],
8581
+ })
8582
+ : (_req, _res, next) => next();
6479
8583
  // Streamable HTTP endpoint - handles both session creation and message handling
6480
- app.post("/mcp", async (req, res) => {
8584
+ app.post("/mcp", mcpBearerAuth, async (req, res) => {
6481
8585
  const sessionId = req.headers["mcp-session-id"];
6482
8586
  // Track request
6483
8587
  metrics.requestsProcessed++;
6484
8588
  // Rate limiting check for existing sessions
6485
- if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
8589
+ if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && !checkRateLimit(sessionId)) {
6486
8590
  metrics.rejectedByRateLimit++;
6487
8591
  res.status(429).json({
6488
8592
  error: "Rate limit exceeded",
@@ -6507,8 +8611,8 @@ async function startStreamableHTTPServer() {
6507
8611
  if (!authData) {
6508
8612
  metrics.authFailures++;
6509
8613
  res.status(401).json({
6510
- error: "Missing Authorization or Private-Token header",
6511
- message: "Remote authorization is enabled. Please provide Authorization or Private-Token header.",
8614
+ error: "Missing Authorization, Private-Token, or JOB-TOKEN header",
8615
+ message: "Remote authorization is enabled. Please provide Authorization, Private-Token, or JOB-TOKEN header.",
6512
8616
  });
6513
8617
  return;
6514
8618
  }
@@ -6532,6 +8636,31 @@ async function startStreamableHTTPServer() {
6532
8636
  // First request without session - will fail in initialization
6533
8637
  }
6534
8638
  }
8639
+ // MCP OAuth mode — token already validated by requireBearerAuth middleware.
8640
+ // req.auth is populated by the middleware; store/refresh per session so that
8641
+ // buildAuthHeaders() can pick it up via AsyncLocalStorage, exactly like the
8642
+ // REMOTE_AUTHORIZATION path.
8643
+ if (GITLAB_MCP_OAUTH) {
8644
+ const authInfo = req.auth;
8645
+ if (authInfo?.token && sessionId) {
8646
+ if (!authBySession[sessionId]) {
8647
+ authBySession[sessionId] = {
8648
+ header: "Authorization",
8649
+ token: authInfo.token,
8650
+ lastUsed: Date.now(),
8651
+ apiUrl: GITLAB_API_URL,
8652
+ };
8653
+ logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8654
+ setAuthTimeout(sessionId);
8655
+ }
8656
+ else {
8657
+ // Update token on every request — the client may have refreshed it
8658
+ authBySession[sessionId].token = authInfo.token;
8659
+ authBySession[sessionId].lastUsed = Date.now();
8660
+ setAuthTimeout(sessionId);
8661
+ }
8662
+ }
8663
+ }
6535
8664
  // Handle request with proper AsyncLocalStorage context
6536
8665
  const handleRequest = async () => {
6537
8666
  try {
@@ -6559,6 +8688,20 @@ async function startStreamableHTTPServer() {
6559
8688
  setAuthTimeout(newSessionId);
6560
8689
  }
6561
8690
  }
8691
+ // Store OAuth token for newly created session in MCP OAuth mode
8692
+ if (GITLAB_MCP_OAUTH && !authBySession[newSessionId]) {
8693
+ const authInfo = req.auth;
8694
+ if (authInfo?.token) {
8695
+ authBySession[newSessionId] = {
8696
+ header: "Authorization",
8697
+ token: authInfo.token,
8698
+ lastUsed: Date.now(),
8699
+ apiUrl: GITLAB_API_URL,
8700
+ };
8701
+ logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8702
+ setAuthTimeout(newSessionId);
8703
+ }
8704
+ }
6562
8705
  },
6563
8706
  });
6564
8707
  // Set up cleanup handler when transport closes
@@ -6568,7 +8711,7 @@ async function startStreamableHTTPServer() {
6568
8711
  logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
6569
8712
  delete streamableTransports[sid];
6570
8713
  metrics.activeSessions--;
6571
- if (REMOTE_AUTHORIZATION) {
8714
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6572
8715
  cleanupSessionAuth(sid);
6573
8716
  delete sessionRequestCounts[sid];
6574
8717
  logger.info(`Session ${sid}: cleaned up auth mapping`);
@@ -6591,8 +8734,8 @@ async function startStreamableHTTPServer() {
6591
8734
  });
6592
8735
  }
6593
8736
  };
6594
- // Execute with auth context in remote mode
6595
- if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
8737
+ // Execute with auth context in remote mode (REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH)
8738
+ if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && authBySession[sessionId]) {
6596
8739
  const authData = authBySession[sessionId];
6597
8740
  const ctx = {
6598
8741
  sessionId,
@@ -6605,7 +8748,7 @@ async function startStreamableHTTPServer() {
6605
8748
  await sessionAuthStore.run(ctx, handleRequest);
6606
8749
  }
6607
8750
  else {
6608
- // Standard execution (no remote auth or no session yet)
8751
+ // Standard execution (no per-session auth or no session yet)
6609
8752
  await handleRequest();
6610
8753
  }
6611
8754
  });
@@ -6623,6 +8766,7 @@ async function startStreamableHTTPServer() {
6623
8766
  ...metrics,
6624
8767
  activeSessions: Object.keys(streamableTransports).length,
6625
8768
  authenticatedSessions: Object.keys(authBySession).length,
8769
+ gitlabClientPool: clientPool.getStats(),
6626
8770
  uptime: process.uptime(),
6627
8771
  memoryUsage: process.memoryUsage(),
6628
8772
  config: {
@@ -6630,6 +8774,7 @@ async function startStreamableHTTPServer() {
6630
8774
  maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
6631
8775
  sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
6632
8776
  remoteAuthEnabled: REMOTE_AUTHORIZATION,
8777
+ mcpOAuthEnabled: GITLAB_MCP_OAUTH,
6633
8778
  },
6634
8779
  });
6635
8780
  });
@@ -6655,7 +8800,7 @@ async function startStreamableHTTPServer() {
6655
8800
  try {
6656
8801
  await transport.close();
6657
8802
  logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
6658
- if (REMOTE_AUTHORIZATION) {
8803
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6659
8804
  cleanupSessionAuth(sessionId);
6660
8805
  delete sessionRequestCounts[sessionId];
6661
8806
  logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
@@ -6691,7 +8836,7 @@ async function startStreamableHTTPServer() {
6691
8836
  const transport = streamableTransports[sessionId];
6692
8837
  if (transport) {
6693
8838
  await transport.close();
6694
- if (REMOTE_AUTHORIZATION) {
8839
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6695
8840
  cleanupSessionAuth(sessionId);
6696
8841
  delete sessionRequestCounts[sessionId];
6697
8842
  }
@@ -6706,6 +8851,7 @@ async function startStreamableHTTPServer() {
6706
8851
  Object.keys(authTimeouts).forEach(sessionId => {
6707
8852
  clearAuthTimeout(sessionId);
6708
8853
  });
8854
+ clientPool.closeAll();
6709
8855
  logger.info("Graceful shutdown complete");
6710
8856
  process.exit(0);
6711
8857
  };