@zereight/mcp-gitlab 2.1.29 → 2.1.38

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.
Files changed (71) hide show
  1. package/README.ko.md +72 -20
  2. package/README.md +120 -82
  3. package/README.zh-CN.md +72 -20
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +850 -490
  7. package/build/schemas.js +115 -28
  8. package/build/scripts/check-skill-sync.js +137 -0
  9. package/build/scripts/generate-tool-docs.js +8 -3
  10. package/build/server/metrics.js +84 -0
  11. package/build/server/transport-mode.js +25 -0
  12. package/build/test/callback-proxy-tests.js +1 -1
  13. package/build/test/client-pool-test.js +1 -1
  14. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  15. package/build/test/dynamic-api-url-test.js +4 -4
  16. package/build/test/dynamic-routing-tests.js +4 -4
  17. package/build/test/group-milestone-schema.test.js +45 -0
  18. package/build/test/mcp-oauth-tests.js +29 -4
  19. package/build/test/mcp-server-name.test.js +87 -0
  20. package/build/test/multi-server-test.js +3 -3
  21. package/build/test/no-proxy-integration-test.js +1 -1
  22. package/build/test/nullable-gitlab-response-fields.test.js +22 -0
  23. package/build/test/path-segment-encoding.test.js +11 -0
  24. package/build/test/remote-auth-simple-test.js +226 -199
  25. package/build/test/server/metrics.test.js +47 -0
  26. package/build/test/sse-auth-guard.test.js +2 -2
  27. package/build/test/stateless/session-id-integration.test.js +3 -3
  28. package/build/test/streamable-http-concurrent-session.test.js +64 -1
  29. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  30. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  31. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  32. package/build/test/test-ci-catalog.js +1 -1
  33. package/build/test/test-ci-lint.js +1 -1
  34. package/build/test/test-ci-variables.js +8 -5
  35. package/build/test/test-dependency-proxy.js +11 -7
  36. package/build/test/test-deployment-tools.js +16 -2
  37. package/build/test/test-download-attachment.js +1 -1
  38. package/build/test/test-get-file-blame.js +1 -1
  39. package/build/test/test-geteffectiveprojectid.js +221 -8
  40. package/build/test/test-issue-description-patch.js +1 -1
  41. package/build/test/test-job-artifacts.js +1 -1
  42. package/build/test/test-list-issues.js +1 -1
  43. package/build/test/test-list-merge-requests.js +1 -1
  44. package/build/test/test-list-project-members.js +1 -1
  45. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  46. package/build/test/test-merge-request-pipelines.js +1 -1
  47. package/build/test/test-mr-diffs-filter.js +1 -1
  48. package/build/test/test-mr-file-diffs.js +2 -2
  49. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  50. package/build/test/test-permission-mode.js +216 -0
  51. package/build/test/test-protected-branches.js +23 -4
  52. package/build/test/test-remote-downloads.js +2 -2
  53. package/build/test/test-search-code.js +1 -1
  54. package/build/test/test-tags.js +1 -1
  55. package/build/test/test-todos.js +1 -1
  56. package/build/test/test-token-optimizations.js +3 -3
  57. package/build/test/test-toolset-filtering.js +3 -3
  58. package/build/test/test-update-issue-slim.js +141 -0
  59. package/build/test/test-upload-markdown.js +1 -1
  60. package/build/test/utils/download-token.test.js +35 -0
  61. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  62. package/build/test/utils/graphql-query.test.js +64 -1
  63. package/build/test/utils/mock-gitlab-server.js +24 -31
  64. package/build/test/utils/server-launcher.js +1 -2
  65. package/build/test/utils/version-check.test.js +52 -0
  66. package/build/tools/registry.js +93 -5
  67. package/build/utils/download-token.js +71 -0
  68. package/build/utils/forwarded-public-base-url.js +22 -0
  69. package/build/utils/graphql-query.js +109 -12
  70. package/build/utils/version-check.js +40 -0
  71. package/package.json +5 -4
package/build/schemas.js CHANGED
@@ -527,7 +527,7 @@ export const GetPipelineJobOutputSchema = z.object({
527
527
  limit: z
528
528
  .number()
529
529
  .optional()
530
- .describe("Maximum number of lines to return from the end of the log (default: 1000)"),
530
+ .describe("Maximum number of lines to return from the end of the log (default/max: 1000)"),
531
531
  offset: z
532
532
  .number()
533
533
  .optional()
@@ -939,6 +939,12 @@ export const GitLabMilestonesSchema = z.object({
939
939
  expired: z.coerce.boolean(),
940
940
  web_url: z.string().optional(),
941
941
  });
942
+ // Group milestones return group_id (not project_id)
943
+ export const GitLabGroupMilestonesSchema = GitLabMilestonesSchema.omit({
944
+ project_id: true,
945
+ }).extend({
946
+ group_id: z.coerce.string(),
947
+ });
942
948
  // Input schemas for operations
943
949
  export const CreateRepositoryOptionsSchema = z.object({
944
950
  name: z.string(),
@@ -1026,6 +1032,7 @@ export const CreateBranchOptionsSchema = z.object({
1026
1032
  ref: z.string(), // The source branch/commit for the new branch
1027
1033
  });
1028
1034
  export const GitLabCompareResultSchema = z.object({
1035
+ // GitLab may return null commit for empty/no-op compare ranges
1029
1036
  commit: z
1030
1037
  .object({
1031
1038
  id: z.string().optional(),
@@ -1035,6 +1042,7 @@ export const GitLabCompareResultSchema = z.object({
1035
1042
  author_email: z.string().optional(),
1036
1043
  created_at: z.string().optional(),
1037
1044
  })
1045
+ .nullable()
1038
1046
  .optional(),
1039
1047
  commits: z.array(GitLabCommitSchema),
1040
1048
  diffs: z.array(GitLabDiffSchema),
@@ -1658,8 +1666,9 @@ export const UpdateDefaultBranchSchema = ProjectParamsSchema.extend({
1658
1666
  export const GitLabProtectedBranchAccessLevelSchema = z.object({
1659
1667
  access_level: z.number().nullable().optional(),
1660
1668
  access_level_description: z.string().optional(),
1661
- user_id: z.number().optional(),
1662
- group_id: z.number().optional(),
1669
+ // GitLab returns null for role-based access levels (not user-/group-specific)
1670
+ user_id: z.number().nullable().optional(),
1671
+ group_id: z.number().nullable().optional(),
1663
1672
  });
1664
1673
  export const GitLabProtectedBranchSchema = z.object({
1665
1674
  id: z.number().optional(),
@@ -1704,7 +1713,17 @@ export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
1704
1713
  .optional()
1705
1714
  .describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^vendor/", "^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
1706
1715
  });
1707
- export const GetMergeRequestSchema = ProjectParamsSchema.extend({
1716
+ const flexibleBooleanOptional = z.preprocess(val => {
1717
+ if (typeof val !== "string")
1718
+ return val;
1719
+ const normalized = val.trim().toLowerCase();
1720
+ if (normalized === "true")
1721
+ return true;
1722
+ if (normalized === "false")
1723
+ return false;
1724
+ return val;
1725
+ }, z.boolean().optional());
1726
+ const MergeRequestParamsSchema = ProjectParamsSchema.extend({
1708
1727
  project_id: z
1709
1728
  .preprocess(value => (value === undefined || value === null ? value : String(value)), z
1710
1729
  .string({
@@ -1717,7 +1736,10 @@ export const GetMergeRequestSchema = ProjectParamsSchema.extend({
1717
1736
  merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
1718
1737
  source_branch: z.string().optional().describe("Source branch name"),
1719
1738
  });
1720
- export const UpdateMergeRequestSchema = GetMergeRequestSchema.extend({
1739
+ export const GetMergeRequestSchema = MergeRequestParamsSchema.extend({
1740
+ include_summaries: flexibleBooleanOptional.describe("If true, include deployment_summary, commit_addition_summary and approval_summary (extra API calls, larger response). Default false to reduce token usage."),
1741
+ });
1742
+ export const UpdateMergeRequestSchema = MergeRequestParamsSchema.extend({
1721
1743
  title: z.string().optional().describe("The title of the merge request"),
1722
1744
  description: z.string().optional().describe("The description of the merge request"),
1723
1745
  target_branch: z.string().optional().describe("The target branch"),
@@ -1859,14 +1881,14 @@ export const ListMergeRequestPipelinesSchema = ProjectParamsSchema.extend({
1859
1881
  .transform(value => value.trim()))
1860
1882
  .describe("The internal ID of the merge request"),
1861
1883
  }).merge(PaginationOptionsSchema);
1862
- export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1884
+ export const GetMergeRequestDiffsSchema = MergeRequestParamsSchema.extend({
1863
1885
  view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"),
1864
1886
  excluded_file_patterns: z
1865
1887
  .array(z.string())
1866
1888
  .optional()
1867
1889
  .describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^vendor/", "^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
1868
1890
  });
1869
- export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1891
+ export const ListMergeRequestDiffsSchema = MergeRequestParamsSchema.extend({
1870
1892
  page: z.coerce.number().optional().describe("Page number for pagination (default: 1)"),
1871
1893
  per_page: z.coerce
1872
1894
  .number()
@@ -1877,13 +1899,13 @@ export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1877
1899
  .optional()
1878
1900
  .describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
1879
1901
  });
1880
- export const ListMergeRequestChangedFilesSchema = GetMergeRequestSchema.extend({
1902
+ export const ListMergeRequestChangedFilesSchema = MergeRequestParamsSchema.extend({
1881
1903
  excluded_file_patterns: z
1882
1904
  .array(z.string())
1883
1905
  .optional()
1884
1906
  .describe('Array of regex patterns to exclude files. Examples: ["^vendor/", "\\.pb\\.go$"]'),
1885
1907
  });
1886
- export const GetMergeRequestFileDiffSchema = GetMergeRequestSchema.extend({
1908
+ export const GetMergeRequestFileDiffSchema = MergeRequestParamsSchema.extend({
1887
1909
  file_paths: z
1888
1910
  .array(z.string())
1889
1911
  .describe("List of file paths to retrieve diffs for (e.g. ['src/api/users.ts', 'src/repo/user.go']). " +
@@ -2063,6 +2085,7 @@ export const ListMergeRequestsSchema = z
2063
2085
  export const GetIssueSchema = z.object({
2064
2086
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2065
2087
  issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
2088
+ full_response: flexibleBooleanOptional.describe("If true, return the complete issue object including the full milestone description. Default returns a slim milestone (id, iid, title, state, web_url) to reduce token usage."),
2066
2089
  });
2067
2090
  export const UpdateIssueSchema = z.object({
2068
2091
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -2086,6 +2109,7 @@ export const UpdateIssueSchema = z.object({
2086
2109
  issue_type: z
2087
2110
  .preprocess(val => (typeof val === "string" ? val.toLowerCase() : val), z.enum(["issue", "incident", "test_case", "task"]).optional())
2088
2111
  .describe("The type of issue. One of issue, incident, test_case or task."),
2112
+ full_response: flexibleBooleanOptional.describe("If true, return the complete updated issue object. Default returns a slim confirmation (iid, title, state, web_url, updated_at) to reduce token usage."),
2089
2113
  });
2090
2114
  export const DeleteIssueSchema = z.object({
2091
2115
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -2344,7 +2368,10 @@ export const CreateWikiPageSchema = z.object({
2344
2368
  export const UpdateWikiPageSchema = z.object({
2345
2369
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2346
2370
  slug: z.string().describe("Slug of the wiki page (will be URL-encoded internally)"),
2347
- title: z.string().optional().describe("New title of the wiki page"),
2371
+ title: z
2372
+ .string()
2373
+ .optional()
2374
+ .describe("New title of the wiki page. WARNING: setting this renames the page and changes its slug/URL (for nested pages it can also move the page to a different path), which breaks existing links. To change only the displayed title while keeping the URL, omit this parameter and instead set a `title:` field in the content's YAML front matter."),
2348
2375
  content: z.string().optional().describe("New content of the wiki page"),
2349
2376
  format: z.string().optional().describe("Content format, e.g., markdown, rdoc"),
2350
2377
  });
@@ -2390,7 +2417,10 @@ export const CreateGroupWikiPageSchema = z.object({
2390
2417
  export const UpdateGroupWikiPageSchema = z.object({
2391
2418
  group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2392
2419
  slug: z.string().describe("Slug of the wiki page (will be URL-encoded internally)"),
2393
- title: z.string().optional().describe("New title of the wiki page"),
2420
+ title: z
2421
+ .string()
2422
+ .optional()
2423
+ .describe("New title of the wiki page. WARNING: setting this renames the page and changes its slug/URL (for nested pages it can also move the page to a different path), which breaks existing links. To change only the displayed title while keeping the URL, omit this parameter and instead set a `title:` field in the content's YAML front matter."),
2394
2424
  content: z.string().optional().describe("New content of the wiki page"),
2395
2425
  format: z.string().optional().describe("Content format, e.g., markdown, rdoc"),
2396
2426
  });
@@ -2651,13 +2681,70 @@ export const EditProjectMilestoneSchema = GetProjectMilestoneSchema.extend({
2651
2681
  // Schema for deleting a milestone
2652
2682
  export const DeleteProjectMilestoneSchema = GetProjectMilestoneSchema;
2653
2683
  // Schema for getting issues assigned to a milestone
2654
- export const GetMilestoneIssuesSchema = GetProjectMilestoneSchema;
2684
+ export const GetMilestoneIssuesSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2655
2685
  // Schema for getting merge requests assigned to a milestone
2656
2686
  export const GetMilestoneMergeRequestsSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2657
2687
  // Schema for promoting a project milestone to a group milestone
2658
2688
  export const PromoteProjectMilestoneSchema = GetProjectMilestoneSchema;
2659
2689
  // Schema for getting burndown chart events for a milestone
2660
2690
  export const GetMilestoneBurndownEventsSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema);
2691
+ // Group milestone schemas (mirror project milestones against /groups/:id/milestones)
2692
+ export const ListGroupMilestonesSchema = z
2693
+ .object({
2694
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2695
+ iids: z
2696
+ .array(z.coerce.number())
2697
+ .optional()
2698
+ .describe("Return only the milestones having the given iid"),
2699
+ state: z
2700
+ .enum(["active", "closed"])
2701
+ .optional()
2702
+ .describe("Return only active or closed milestones"),
2703
+ title: z
2704
+ .string()
2705
+ .optional()
2706
+ .describe("Return only milestones with a title matching the provided string"),
2707
+ search: z
2708
+ .string()
2709
+ .optional()
2710
+ .describe("Return only milestones with a title or description matching the provided string"),
2711
+ include_ancestors: z.coerce.boolean().optional().describe("Include ancestor groups"),
2712
+ include_descendants: z.coerce.boolean().optional().describe("Include descendant groups"),
2713
+ updated_before: z
2714
+ .string()
2715
+ .optional()
2716
+ .describe("Return milestones updated before the specified date (ISO 8601 format)"),
2717
+ updated_after: z
2718
+ .string()
2719
+ .optional()
2720
+ .describe("Return milestones updated after the specified date (ISO 8601 format)"),
2721
+ })
2722
+ .merge(PaginationOptionsSchema);
2723
+ export const GetGroupMilestoneSchema = z.object({
2724
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2725
+ milestone_id: z.coerce.string().describe("The ID of a group milestone"),
2726
+ });
2727
+ export const CreateGroupMilestoneSchema = z.object({
2728
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2729
+ title: z.string().describe("The title of the milestone"),
2730
+ description: z.string().optional().describe("The description of the milestone"),
2731
+ due_date: z.string().optional().describe("The due date of the milestone (YYYY-MM-DD)"),
2732
+ start_date: z.string().optional().describe("The start date of the milestone (YYYY-MM-DD)"),
2733
+ });
2734
+ export const EditGroupMilestoneSchema = GetGroupMilestoneSchema.extend({
2735
+ title: z.string().optional().describe("The title of the milestone"),
2736
+ description: z.string().optional().describe("The description of the milestone"),
2737
+ due_date: z.string().optional().describe("The due date of the milestone (YYYY-MM-DD)"),
2738
+ start_date: z.string().optional().describe("The start date of the milestone (YYYY-MM-DD)"),
2739
+ state_event: z
2740
+ .enum(["close", "activate"])
2741
+ .optional()
2742
+ .describe("The state event of the milestone"),
2743
+ });
2744
+ export const DeleteGroupMilestoneSchema = GetGroupMilestoneSchema;
2745
+ export const GetGroupMilestoneIssuesSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2746
+ export const GetGroupMilestoneMergeRequestsSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2747
+ export const GetGroupMilestoneBurndownEventsSchema = GetGroupMilestoneSchema.merge(PaginationOptionsSchema);
2661
2748
  // Add schemas for commit operations
2662
2749
  export const ListCommitsSchema = z.object({
2663
2750
  project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
@@ -3330,17 +3417,17 @@ const workItemTypeEnum = z
3330
3417
  "requirement",
3331
3418
  "ticket",
3332
3419
  ]));
3333
- const ProjectIdOrPathSchema = z.coerce
3420
+ const NamespaceIdOrPathSchema = z.coerce
3334
3421
  .string()
3335
- .describe("Project ID, URL-encoded project path, or group path (e.g. 'group/subgroup' for group-level work items)");
3422
+ .describe("Project ID, URL-encoded project path, group path, or explicit namespace prefix for ambiguous numeric IDs (e.g. 'group/subgroup', 'group:123', or 'project:123')");
3336
3423
  // Common params for work item tools
3337
3424
  const WorkItemParamsSchema = z.object({
3338
- project_id: ProjectIdOrPathSchema,
3425
+ project_id: NamespaceIdOrPathSchema,
3339
3426
  iid: z.coerce.number().describe("The internal ID (IID) of the work item"),
3340
3427
  });
3341
3428
  export const GetWorkItemSchema = WorkItemParamsSchema;
3342
3429
  export const ListWorkItemsSchema = z.object({
3343
- project_id: ProjectIdOrPathSchema,
3430
+ project_id: NamespaceIdOrPathSchema,
3344
3431
  types: z
3345
3432
  .array(workItemTypeEnum)
3346
3433
  .optional()
@@ -3360,7 +3447,7 @@ export const ListWorkItemsSchema = z.object({
3360
3447
  .describe("Cursor for pagination (from previous response's endCursor)"),
3361
3448
  });
3362
3449
  export const CreateWorkItemSchema = z.object({
3363
- project_id: ProjectIdOrPathSchema,
3450
+ project_id: NamespaceIdOrPathSchema,
3364
3451
  title: z.string().describe("Title of the work item"),
3365
3452
  type: workItemTypeEnum
3366
3453
  .optional()
@@ -3501,19 +3588,19 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
3501
3588
  .describe("Incident only: set escalation status"),
3502
3589
  });
3503
3590
  export const ConvertWorkItemTypeSchema = z.object({
3504
- project_id: ProjectIdOrPathSchema,
3591
+ project_id: NamespaceIdOrPathSchema,
3505
3592
  iid: z.coerce.number().describe("The internal ID of the work item"),
3506
3593
  new_type: workItemTypeEnum.describe("The target work item type to convert to"),
3507
3594
  });
3508
3595
  export const ListWorkItemStatusesSchema = z.object({
3509
- project_id: ProjectIdOrPathSchema,
3596
+ project_id: NamespaceIdOrPathSchema,
3510
3597
  work_item_type: workItemTypeEnum
3511
3598
  .optional()
3512
3599
  .default("issue")
3513
3600
  .describe("The work item type to list available statuses for. Defaults to 'issue'."),
3514
3601
  });
3515
3602
  export const ListWorkItemNotesSchema = z.object({
3516
- project_id: ProjectIdOrPathSchema,
3603
+ project_id: NamespaceIdOrPathSchema,
3517
3604
  iid: z.coerce.number().describe("The internal ID of the work item"),
3518
3605
  page_size: z.coerce
3519
3606
  .number()
@@ -3528,7 +3615,7 @@ export const ListWorkItemNotesSchema = z.object({
3528
3615
  .describe("Sort order for discussions"),
3529
3616
  });
3530
3617
  export const CreateWorkItemNoteSchema = z.object({
3531
- project_id: ProjectIdOrPathSchema,
3618
+ project_id: NamespaceIdOrPathSchema,
3532
3619
  iid: z.coerce.number().describe("The internal ID of the work item"),
3533
3620
  body: z.string().describe("Note body (Markdown supported)"),
3534
3621
  internal: z.coerce
@@ -3551,7 +3638,7 @@ export const MoveWorkItemSchema = z.object({
3551
3638
  .describe("Project ID, URL-encoded project path, or group path of the target namespace"),
3552
3639
  });
3553
3640
  export const ListCustomFieldDefinitionsSchema = z.object({
3554
- project_id: ProjectIdOrPathSchema,
3641
+ project_id: NamespaceIdOrPathSchema,
3555
3642
  work_item_type: workItemTypeEnum
3556
3643
  .optional()
3557
3644
  .default("issue")
@@ -3608,17 +3695,17 @@ export const DeleteIssueNoteEmojiReactionSchema = ProjectParamsSchema.extend({
3608
3695
  });
3609
3696
  // --- Emoji Reaction schemas (GraphQL: Work Items) ---
3610
3697
  export const CreateWorkItemEmojiReactionSchema = z.object({
3611
- project_id: ProjectIdOrPathSchema,
3698
+ project_id: NamespaceIdOrPathSchema,
3612
3699
  iid: z.coerce.number().describe("The internal ID of the work item"),
3613
3700
  name: emojiNameField,
3614
3701
  });
3615
3702
  export const DeleteWorkItemEmojiReactionSchema = z.object({
3616
- project_id: ProjectIdOrPathSchema,
3703
+ project_id: NamespaceIdOrPathSchema,
3617
3704
  iid: z.coerce.number().describe("The internal ID of the work item"),
3618
3705
  name: emojiNameField,
3619
3706
  });
3620
3707
  export const CreateWorkItemNoteEmojiReactionSchema = z.object({
3621
- project_id: ProjectIdOrPathSchema,
3708
+ project_id: NamespaceIdOrPathSchema,
3622
3709
  iid: z.coerce.number().describe("The internal ID of the work item"),
3623
3710
  note_id: z
3624
3711
  .string()
@@ -3626,7 +3713,7 @@ export const CreateWorkItemNoteEmojiReactionSchema = z.object({
3626
3713
  name: emojiNameField,
3627
3714
  });
3628
3715
  export const DeleteWorkItemNoteEmojiReactionSchema = z.object({
3629
- project_id: ProjectIdOrPathSchema,
3716
+ project_id: NamespaceIdOrPathSchema,
3630
3717
  iid: z.coerce.number().describe("The internal ID of the work item"),
3631
3718
  note_id: z
3632
3719
  .string()
@@ -3650,11 +3737,11 @@ export const ListIssueNoteEmojiReactionsSchema = ProjectParamsSchema.extend({
3650
3737
  discussion_id: noteEmojiDiscussionField,
3651
3738
  });
3652
3739
  export const ListWorkItemEmojiReactionsSchema = z.object({
3653
- project_id: ProjectIdOrPathSchema,
3740
+ project_id: NamespaceIdOrPathSchema,
3654
3741
  iid: z.coerce.number().describe("The internal ID of the work item"),
3655
3742
  });
3656
3743
  export const ListWorkItemNoteEmojiReactionsSchema = z.object({
3657
- project_id: ProjectIdOrPathSchema,
3744
+ project_id: NamespaceIdOrPathSchema,
3658
3745
  iid: z.coerce.number().describe("The internal ID of the work item"),
3659
3746
  note_id: z
3660
3747
  .string()
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Verify skills/gitlab-mcp/ stays in sync with tools/registry.ts.
4
+ *
5
+ * Run with: npm run check:skill-sync
6
+ */
7
+ import { readFileSync, readdirSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { TOOLSET_DEFINITIONS, allTools, destructiveTools, DEFAULT_TOOLSET_IDS, } from "../tools/registry.js";
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const REPO_ROOT = join(__dirname, "..");
13
+ const SKILL_PATH = join(REPO_ROOT, "skills", "gitlab-mcp", "SKILL.md");
14
+ const REF_DIR = join(REPO_ROOT, "skills", "gitlab-mcp", "reference");
15
+ // Response field names in reference docs — not MCP tools.
16
+ const REFERENCE_FIELD_ALLOWLIST = new Set([
17
+ "allow_multiple",
18
+ "deleted_file",
19
+ "excluded_file_patterns",
20
+ "group_id",
21
+ "new_file",
22
+ "new_path",
23
+ "old_path",
24
+ "project_id",
25
+ "renamed_file",
26
+ ]);
27
+ function fail(message) {
28
+ console.error(`::error::${message}`);
29
+ process.exit(1);
30
+ }
31
+ function parseToolsetTable(skill) {
32
+ const rows = new Map();
33
+ const rowRe = /^\| ([a-z_]+) \((\d+) tools?\) \| (yes|no) \|/gm;
34
+ let match;
35
+ while ((match = rowRe.exec(skill)) !== null) {
36
+ rows.set(match[1], {
37
+ count: Number(match[2]),
38
+ isDefault: match[3] === "yes",
39
+ });
40
+ }
41
+ return rows;
42
+ }
43
+ function parseIntroCounts(skill) {
44
+ const match = skill.match(/providing (\d+) tools: (\d+) tools across (\d+) toolsets/);
45
+ if (!match) {
46
+ fail("skills/gitlab-mcp/SKILL.md intro line is missing or malformed");
47
+ }
48
+ return {
49
+ totalTools: Number(match[1]),
50
+ uniqueInToolsets: Number(match[2]),
51
+ toolsetCount: Number(match[3]),
52
+ };
53
+ }
54
+ function parseDestructiveTools(skill) {
55
+ const section = skill.match(/## Destructive Tools[^\n]*\n\n(`[^`]+`(?:, `[^`]+`)*)$/m);
56
+ if (!section) {
57
+ fail("skills/gitlab-mcp/SKILL.md destructive tools section is missing or malformed");
58
+ }
59
+ return new Set([...section[1].matchAll(/`([a-z_]+)`/g)].map((m) => m[1]));
60
+ }
61
+ function collectReferenceToolNames() {
62
+ const toolNames = new Set();
63
+ for (const file of readdirSync(REF_DIR).filter((name) => name.endsWith(".md"))) {
64
+ const text = readFileSync(join(REF_DIR, file), "utf8");
65
+ for (const match of text.matchAll(/`([a-z][a-z0-9_]*)`/g)) {
66
+ const name = match[1];
67
+ if (!REFERENCE_FIELD_ALLOWLIST.has(name)) {
68
+ toolNames.add(name);
69
+ }
70
+ }
71
+ }
72
+ return toolNames;
73
+ }
74
+ const skill = readFileSync(SKILL_PATH, "utf8");
75
+ const errors = [];
76
+ const registryCounts = Object.fromEntries(TOOLSET_DEFINITIONS.map((definition) => [definition.id, definition.tools.size]));
77
+ const skillRows = parseToolsetTable(skill);
78
+ if (skillRows.size !== TOOLSET_DEFINITIONS.length) {
79
+ errors.push(`toolset table has ${skillRows.size} rows; registry has ${TOOLSET_DEFINITIONS.length}`);
80
+ }
81
+ for (const definition of TOOLSET_DEFINITIONS) {
82
+ const row = skillRows.get(definition.id);
83
+ if (!row) {
84
+ errors.push(`missing toolset row for ${definition.id}`);
85
+ continue;
86
+ }
87
+ const actualCount = registryCounts[definition.id];
88
+ if (row.count !== actualCount) {
89
+ errors.push(`${definition.id}: skill says ${row.count}, registry has ${actualCount}`);
90
+ }
91
+ const expectedDefault = DEFAULT_TOOLSET_IDS.has(definition.id);
92
+ if (row.isDefault !== expectedDefault) {
93
+ errors.push(`${definition.id}: skill default=${row.isDefault}, registry default=${expectedDefault}`);
94
+ }
95
+ }
96
+ for (const id of skillRows.keys()) {
97
+ if (!registryCounts[id]) {
98
+ errors.push(`unknown toolset in skill table: ${id}`);
99
+ }
100
+ }
101
+ const uniqueInToolsets = new Set(TOOLSET_DEFINITIONS.flatMap((definition) => [...definition.tools]))
102
+ .size;
103
+ const intro = parseIntroCounts(skill);
104
+ if (intro.totalTools !== allTools.length) {
105
+ errors.push(`intro total tools: skill says ${intro.totalTools}, registry has ${allTools.length}`);
106
+ }
107
+ if (intro.uniqueInToolsets !== uniqueInToolsets) {
108
+ errors.push(`intro unique toolset tools: skill says ${intro.uniqueInToolsets}, registry has ${uniqueInToolsets}`);
109
+ }
110
+ if (intro.toolsetCount !== TOOLSET_DEFINITIONS.length) {
111
+ errors.push(`intro toolset count: skill says ${intro.toolsetCount}, registry has ${TOOLSET_DEFINITIONS.length}`);
112
+ }
113
+ const skillDestructive = parseDestructiveTools(skill);
114
+ for (const tool of skillDestructive) {
115
+ if (!destructiveTools.has(tool)) {
116
+ errors.push(`destructive list includes non-destructive tool: ${tool}`);
117
+ }
118
+ }
119
+ for (const tool of destructiveTools) {
120
+ if (!skillDestructive.has(tool)) {
121
+ errors.push(`destructive list missing registry tool: ${tool}`);
122
+ }
123
+ }
124
+ const registryToolNames = new Set(allTools.map((tool) => tool.name));
125
+ for (const tool of collectReferenceToolNames()) {
126
+ if (!registryToolNames.has(tool)) {
127
+ errors.push(`reference mentions unknown tool: ${tool}`);
128
+ }
129
+ }
130
+ if (errors.length > 0) {
131
+ console.error("skills/gitlab-mcp/ is out of sync with tools/registry.ts:");
132
+ for (const error of errors) {
133
+ console.error(`- ${error}`);
134
+ }
135
+ process.exit(1);
136
+ }
137
+ console.log("skills/gitlab-mcp/ is in sync with tools/registry.ts");
@@ -77,7 +77,7 @@ const GROUP_META = {
77
77
  },
78
78
  milestones: {
79
79
  title: "Milestones",
80
- blurb: "Project milestone CRUD plus associated issues/MRs and burndown events.",
80
+ blurb: "Project and group milestone CRUD plus associated issues/MRs and burndown events.",
81
81
  },
82
82
  wiki: {
83
83
  title: "Wiki",
@@ -273,8 +273,13 @@ function buildIndexPage(groupedToolsList) {
273
273
  "",
274
274
  ...buildToggleSection(groupedToolsList),
275
275
  "",
276
- "Read-only mode (`GITLAB_READ_ONLY_MODE=true`) hides every write tool",
277
- "regardless of toggles. See [Environment Variables](../configuration/environment-variables.md)",
276
+ "Permission modes control which tools are exposed:",
277
+ "",
278
+ "- `GITLAB_PERMISSION_MODE=readonly` — hides every write tool regardless of toggles.",
279
+ "- `GITLAB_PERMISSION_MODE=modify` — allows create/update but blocks all `delete_*` tools.",
280
+ "- `GITLAB_READ_ONLY_MODE=true` (deprecated) — same as `readonly`; prefer `GITLAB_PERMISSION_MODE=readonly`.",
281
+ "",
282
+ "See [Environment Variables](../configuration/environment-variables.md)",
278
283
  "and [CLI Arguments](../getting-started/cli-arguments.md) for the full list.",
279
284
  "",
280
285
  "## Legend",
@@ -0,0 +1,84 @@
1
+ export function escapePrometheusLabel(value) {
2
+ return String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
3
+ }
4
+ export function formatPrometheusMetrics(snapshot) {
5
+ const configLabels = Object.entries({
6
+ max_sessions: snapshot.config.maxSessions,
7
+ max_requests_per_minute: snapshot.config.maxRequestsPerMinute,
8
+ session_timeout_seconds: snapshot.config.sessionTimeoutSeconds,
9
+ remote_auth_enabled: snapshot.config.remoteAuthEnabled,
10
+ mcp_oauth_enabled: snapshot.config.mcpOAuthEnabled,
11
+ stateless_mode_enabled: snapshot.config.statelessModeEnabled,
12
+ stateless_rotation_key: snapshot.config.statelessRotationKey,
13
+ })
14
+ .map(([key, value]) => `${key}="${escapePrometheusLabel(value)}"`)
15
+ .join(",");
16
+ return [
17
+ "# HELP gitlab_mcp_requests_processed_total Total MCP requests processed",
18
+ "# TYPE gitlab_mcp_requests_processed_total counter",
19
+ `gitlab_mcp_requests_processed_total ${snapshot.requestsProcessed}`,
20
+ "",
21
+ "# HELP gitlab_mcp_requests_rejected_total Requests rejected, by reason",
22
+ "# TYPE gitlab_mcp_requests_rejected_total counter",
23
+ `gitlab_mcp_requests_rejected_total{reason="rate_limit"} ${snapshot.rejectedByRateLimit}`,
24
+ `gitlab_mcp_requests_rejected_total{reason="capacity"} ${snapshot.rejectedByCapacity}`,
25
+ "",
26
+ "# HELP gitlab_mcp_auth_failures_total Authentication failures",
27
+ "# TYPE gitlab_mcp_auth_failures_total counter",
28
+ `gitlab_mcp_auth_failures_total ${snapshot.authFailures}`,
29
+ "",
30
+ "# HELP gitlab_mcp_sessions_total Total sessions created",
31
+ "# TYPE gitlab_mcp_sessions_total counter",
32
+ `gitlab_mcp_sessions_total ${snapshot.totalSessions}`,
33
+ "",
34
+ "# HELP gitlab_mcp_sessions_expired_total Sessions expired due to inactivity",
35
+ "# TYPE gitlab_mcp_sessions_expired_total counter",
36
+ `gitlab_mcp_sessions_expired_total ${snapshot.expiredSessions}`,
37
+ "",
38
+ "# HELP gitlab_mcp_active_sessions Currently active sessions",
39
+ "# TYPE gitlab_mcp_active_sessions gauge",
40
+ `gitlab_mcp_active_sessions ${snapshot.activeSessions}`,
41
+ "",
42
+ "# HELP gitlab_mcp_authenticated_sessions Currently authenticated sessions",
43
+ "# TYPE gitlab_mcp_authenticated_sessions gauge",
44
+ `gitlab_mcp_authenticated_sessions ${snapshot.authenticatedSessions}`,
45
+ "",
46
+ "# HELP gitlab_mcp_client_pool_size Current GitLab client pool size",
47
+ "# TYPE gitlab_mcp_client_pool_size gauge",
48
+ `gitlab_mcp_client_pool_size ${snapshot.gitlabClientPool.size}`,
49
+ "",
50
+ "# HELP gitlab_mcp_client_pool_max_size Maximum GitLab client pool size",
51
+ "# TYPE gitlab_mcp_client_pool_max_size gauge",
52
+ `gitlab_mcp_client_pool_max_size ${snapshot.gitlabClientPool.maxSize}`,
53
+ "",
54
+ "# HELP gitlab_mcp_uptime_seconds Process uptime in seconds",
55
+ "# TYPE gitlab_mcp_uptime_seconds gauge",
56
+ `gitlab_mcp_uptime_seconds ${snapshot.uptime}`,
57
+ "",
58
+ "# HELP gitlab_mcp_memory_usage_bytes Node.js memory usage by type",
59
+ "# TYPE gitlab_mcp_memory_usage_bytes gauge",
60
+ ...Object.entries(snapshot.memoryUsage).map(([key, value]) => `gitlab_mcp_memory_usage_bytes{type="${escapePrometheusLabel(key)}"} ${value}`),
61
+ "",
62
+ "# HELP gitlab_mcp_stateless_requests_total Stateless MCP requests processed",
63
+ "# TYPE gitlab_mcp_stateless_requests_total counter",
64
+ `gitlab_mcp_stateless_requests_total ${snapshot.statelessRequests}`,
65
+ "",
66
+ "# HELP gitlab_mcp_stateless_auth_total Stateless auth successes, by source",
67
+ "# TYPE gitlab_mcp_stateless_auth_total counter",
68
+ `gitlab_mcp_stateless_auth_total{source="header"} ${snapshot.statelessAuthFromHeader}`,
69
+ `gitlab_mcp_stateless_auth_total{source="sealed_session_id"} ${snapshot.statelessAuthFromSealedSid}`,
70
+ "",
71
+ "# HELP gitlab_mcp_stateless_auth_failures_total Stateless auth failures",
72
+ "# TYPE gitlab_mcp_stateless_auth_failures_total counter",
73
+ `gitlab_mcp_stateless_auth_failures_total ${snapshot.statelessAuthFailures}`,
74
+ "",
75
+ "# HELP gitlab_mcp_stateless_session_id_rotations_total Stateless session id rotations",
76
+ "# TYPE gitlab_mcp_stateless_session_id_rotations_total counter",
77
+ `gitlab_mcp_stateless_session_id_rotations_total ${snapshot.statelessSidRotated}`,
78
+ "",
79
+ "# HELP gitlab_mcp_config_info Static configuration (value is always 1)",
80
+ "# TYPE gitlab_mcp_config_info gauge",
81
+ `gitlab_mcp_config_info{${configLabels}} 1`,
82
+ "",
83
+ ].join("\n");
84
+ }
@@ -0,0 +1,25 @@
1
+ import { SSE, STREAMABLE_HTTP } from "../config.js";
2
+ /**
3
+ * Available transport modes for MCP server.
4
+ */
5
+ export var TransportMode;
6
+ (function (TransportMode) {
7
+ TransportMode["STDIO"] = "stdio";
8
+ TransportMode["SSE"] = "sse";
9
+ TransportMode["STREAMABLE_HTTP"] = "streamable-http";
10
+ })(TransportMode || (TransportMode = {}));
11
+ /**
12
+ * Determine the transport mode based on environment variables and availability.
13
+ *
14
+ * Transport mode priority (highest to lowest):
15
+ * 1. STREAMABLE_HTTP
16
+ * 2. SSE
17
+ * 3. STDIO
18
+ */
19
+ export function determineTransportMode() {
20
+ if (STREAMABLE_HTTP)
21
+ return TransportMode.STREAMABLE_HTTP;
22
+ if (SSE)
23
+ return TransportMode.SSE;
24
+ return TransportMode.STDIO;
25
+ }
@@ -95,7 +95,7 @@ describe("Callback Proxy Mode", () => {
95
95
  // Track the last code+verifier GitLab received so we can verify the proxy PKCE
96
96
  let lastTokenRequest = {};
97
97
  before(async () => {
98
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
98
+ const mockPort = await findMockServerPort();
99
99
  mockGitLab = new MockGitLabServer({
100
100
  port: mockPort,
101
101
  validTokens: [MOCK_ACCESS_TOKEN],
@@ -22,7 +22,7 @@ describe('Client Pool Limits', () => {
22
22
  let mockGitLabUrl;
23
23
  before(async () => {
24
24
  // Start mock GitLab server
25
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
25
+ const mockPort = await findMockServerPort();
26
26
  mockGitLab = new MockGitLabServer({
27
27
  port: mockPort,
28
28
  validTokens: [MOCK_TOKEN]
@@ -30,10 +30,10 @@ describe("Dynamic API URL allowlist", () => {
30
30
  let secondaryHit = false;
31
31
  let getAttackerHits = () => 0;
32
32
  before(async () => {
33
- const primaryPort = await findMockServerPort(9100);
33
+ const primaryPort = await findMockServerPort();
34
34
  primaryGitLab = new MockGitLabServer({ port: primaryPort, validTokens: [MOCK_TOKEN] });
35
35
  await primaryGitLab.start();
36
- const secondaryPort = await findMockServerPort(9200);
36
+ const secondaryPort = await findMockServerPort();
37
37
  secondaryGitLab = new MockGitLabServer({ port: secondaryPort, validTokens: [MOCK_TOKEN] });
38
38
  secondaryGitLab.addMockHandler("get", "/projects/1/issues", (_req, res) => {
39
39
  secondaryHit = true;