@zereight/mcp-gitlab 2.1.40 → 2.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
82
82
 
83
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.38`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
83
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.41`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
84
84
 
85
85
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
86
86
 
package/README.md CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
82
82
 
83
- No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.38`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
83
+ No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.41`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
84
84
 
85
85
  #### Using CLI Arguments (for clients with env var issues)
86
86
 
@@ -541,183 +541,217 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
541
541
  <!-- TOOLS-START -->
542
542
 
543
543
  1. `merge_merge_request` - Merge a merge request in a GitLab project
544
- 2. `create_or_update_file` - Create or update a single file in a GitLab project
545
- 3. `search_repositories` - Search for GitLab projects
546
- 4. `create_repository` - Create a new GitLab project
547
- 5. `create_group` - Create a new GitLab group or subgroup (name, path, description, visibility, and optional parent_id)
548
- 6. `get_file_contents` - Get the contents of a file or directory from a GitLab project
549
- 7. `push_files` - Push multiple files to a GitLab project in a single commit
550
- 8. `create_issue` - Create a new issue in a GitLab project
551
- 9. `create_merge_request` - Create a new merge request in a GitLab project
552
- 10. `fork_repository` - Fork a GitLab project to your account or specified namespace
553
- 11. `create_branch` - Create a new branch in a GitLab project
554
- 12. `get_merge_request` - Get details of a merge request with compact deployment summary, behind-count, commit addition summary, and approval summary (Either mergeRequestIid or branchName must be provided)
555
- 13. `get_merge_request_diffs` - Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)
556
- 14. `list_merge_request_diffs` - List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)
557
- 15. `get_merge_request_conflicts` - Get the conflicts of a merge request in a GitLab project
558
- 16. `list_merge_request_changed_files` - STEP 1 of code review workflow. Returns ONLY the list of changed file paths in a merge request — WITHOUT diff content. 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). Supports excluded_file_patterns filtering using regex. (Either mergeRequestIid or branchName must be provided)
559
- 17. `get_merge_request_file_diff` - STEP 2 of code review workflow. Get diffs for one or more files from a merge request. Call list_merge_request_changed_files first, then pass them as an array to fetch diffs efficiently. Batching multiple files (recommended 3-5) is supported. (Either mergeRequestIid or branchName must be provided)
560
- 18. `list_merge_request_versions` - List all versions of a merge request
561
- 19. `get_merge_request_version` - Get a specific version of a merge request
562
- 20. `get_branch_diffs` - Get the changes/diffs between two branches or commits in a GitLab project
563
- 21. `update_merge_request` - Update a merge request (Either mergeRequestIid or branchName must be provided)
564
- 22. `create_note` - Create a new note (comment) to an issue or merge request
565
- 23. `create_merge_request_thread` - Create a new thread on a merge request
566
- 24. `mr_discussions` - List discussion items for a merge request
567
- 25. `resolve_merge_request_thread` - Resolve a thread on a merge request
568
- 26. `update_merge_request_note` - Modify an existing merge request thread note
569
- 27. `create_merge_request_note` - Add a new note to an existing merge request thread
570
- 28. `delete_merge_request_discussion_note` - Delete a discussion note on a merge request
571
- 29. `update_merge_request_discussion_note` - Update a discussion note on a merge request
572
- 30. `create_merge_request_discussion_note` - Add a new discussion note to an existing merge request thread
573
- 31. `delete_merge_request_note` - Delete an existing merge request note
574
- 32. `get_merge_request_note` - Get a specific note for a merge request
575
- 33. `get_merge_request_notes` - List notes for a merge request
576
- 34. `get_draft_note` - Get a single draft note from a merge request
577
- 35. `list_draft_notes` - List draft notes for a merge request
578
- 36. `create_draft_note` - Create a draft note for a merge request
579
- 37. `update_draft_note` - Update an existing draft note
580
- 38. `delete_draft_note` - Delete a draft note
581
- 39. `publish_draft_note` - Publish a single draft note
582
- 40. `bulk_publish_draft_notes` - Publish all draft notes for a merge request
583
- 41. `list_merge_requests` - List merge requests globally or in a specific GitLab project with filtering options (project_id is now optional)
584
- 42. `approve_merge_request` - Approve a merge request (requires appropriate permissions)
585
- 43. `unapprove_merge_request` - Unapprove a previously approved merge request
586
- 44. `get_merge_request_approval_state` - Get merge request approval details including approvers (uses `approval_state` when available, otherwise falls back to `approvals`)
587
- 45. `update_issue_note` - Modify an existing issue thread note
588
- 46. `create_issue_note` - Add a new note to an existing issue thread
589
- 47. `list_issues` - List issues (default: created by current user only; use scope='all' for all accessible issues)
590
- 48. `my_issues` - List issues assigned to the authenticated user (defaults to open issues)
591
- 49. `get_issue` - Get details of a specific issue in a GitLab project
592
- 50. `update_issue` - Update an issue in a GitLab project
593
- 51. `update_issue_description_patch` - Apply a patch (search/replace or unified diff) to an issue description. Reduces token usage by sending only the change instead of the full description. Supports `dry_run` to preview and `create_note` to summarize.
594
- 52. `delete_issue` - Delete an issue from a GitLab project
595
- 53. `list_todos` - List GitLab to-do items for the current user
596
- 54. `mark_todo_done` - Mark a GitLab to-do item as done
597
- 55. `mark_all_todos_done` - Mark all pending GitLab to-do items as done for the current user
598
- 56. `list_issue_links` - List all issue links for a specific issue
599
- 57. `list_issue_discussions` - List discussions for an issue in a GitLab project
600
- 58. `get_issue_link` - Get a specific issue link
601
- 59. `create_issue_link` - Create an issue link between two issues
602
- 60. `delete_issue_link` - Delete an issue link
603
- 61. `list_namespaces` - List all namespaces available to the current user
604
- 62. `get_namespace` - Get details of a namespace by ID or path
605
- 63. `verify_namespace` - Verify if a namespace path exists
606
- 64. `get_project` - Get details of a specific project
607
- 65. `list_projects` - List projects accessible by the current user
608
- 66. `list_project_members` - List members of a GitLab project
609
- 67. `list_group_projects` - List projects in a GitLab group with filtering options
610
- 68. `list_group_iterations` - List group iterations with filtering options
611
- 69. `list_labels` - List labels for a project
612
- 70. `get_label` - Get a single label from a project
613
- 71. `create_label` - Create a new label in a project
614
- 72. `update_label` - Update an existing label in a project
615
- 73. `delete_label` - Delete a label from a project
616
- 74. `list_pipelines` - List pipelines in a GitLab project with filtering options
617
- 75. `get_pipeline` - Get details of a specific pipeline in a GitLab project
618
- 76. `list_pipeline_jobs` - List all jobs in a specific pipeline
619
- 77. `list_pipeline_trigger_jobs` - List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines
620
- 78. `get_pipeline_job` - Get details of a GitLab pipeline job number
621
- 79. `get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage
622
- 80. `validate_ci_lint` - Validate provided GitLab CI/CD YAML content for a project
623
- 81. `validate_project_ci_lint` - Validate an existing `.gitlab-ci.yml` configuration for a project
624
- 82. `create_pipeline` - Create a new pipeline for a branch or tag
625
- 83. `retry_pipeline` - Retry a failed or canceled pipeline
626
- 84. `cancel_pipeline` - Cancel a running pipeline
627
- 85. `play_pipeline_job` - Run a manual pipeline job
628
- 86. `retry_pipeline_job` - Retry a failed or canceled pipeline job
629
- 87. `cancel_pipeline_job` - Cancel a running pipeline job
630
- 88. `list_deployments` - List deployments in a GitLab project with filtering options
631
- 89. `get_deployment` - Get details of a specific deployment in a GitLab project
632
- 90. `list_environments` - List environments in a GitLab project
633
- 91. `get_environment` - Get details of a specific environment in a GitLab project
634
- 92. `list_job_artifacts` - List artifact files in a job's artifacts archive. Returns file names, paths, types, and sizes
635
- 93. `download_job_artifacts` - Download the entire artifact archive (zip) for a job to a local path. Returns the saved file path
636
- 94. `get_job_artifact_file` - Get the content of a single file from a job's artifacts by its path within the archive
637
- 95. `list_milestones` - List milestones in a GitLab project with filtering options
638
- 96. `get_milestone` - Get details of a specific milestone
639
- 97. `create_milestone` - Create a new milestone in a GitLab project
640
- 98. `edit_milestone` - Edit an existing milestone in a GitLab project
641
- 99. `delete_milestone` - Delete a milestone from a GitLab project
642
- 100. `get_milestone_issue` - Get issues associated with a specific milestone
643
- 101. `get_milestone_merge_requests` - Get merge requests associated with a specific milestone
644
- 102. `promote_milestone` - Promote a milestone to the next stage
645
- 103. `get_milestone_burndown_events` - Get burndown events for a specific milestone
646
- 104. `list_group_milestones` - List milestones in a GitLab group with filtering options
647
- 105. `get_group_milestone` - Get details of a specific group milestone
648
- 106. `create_group_milestone` - Create a new milestone in a GitLab group
649
- 107. `edit_group_milestone` - Edit an existing group milestone
650
- 108. `delete_group_milestone` - Delete a milestone from a GitLab group
651
- 109. `get_group_milestone_issue` - Get issues associated with a specific group milestone
652
- 110. `get_group_milestone_merge_requests` - Get merge requests associated with a specific group milestone
653
- 111. `get_group_milestone_burndown_events` - Get burndown events for a specific group milestone
654
- 112. `list_wiki_pages` - List wiki pages in a GitLab project
655
- 113. `get_wiki_page` - Get details of a specific wiki page
656
- 114. `create_wiki_page` - Create a new wiki page in a GitLab project
657
- 115. `update_wiki_page` - Update an existing wiki page in a GitLab project
658
- 116. `delete_wiki_page` - Delete a wiki page from a GitLab project
659
- 117. `list_group_wiki_pages` - List wiki pages in a GitLab group
660
- 118. `get_group_wiki_page` - Get details of a specific group wiki page
661
- 119. `create_group_wiki_page` - Create a new wiki page in a GitLab group
662
- 120. `update_group_wiki_page` - Update an existing wiki page in a GitLab group
663
- 121. `delete_group_wiki_page` - Delete a wiki page from a GitLab group
664
- 122. `get_repository_tree` - Get the repository tree for a GitLab project (list files and directories)
665
- 123. `list_commits` - List repository commits with filtering options
666
- 124. `get_commit` - Get details of a specific commit
667
- 125. `get_commit_diff` - Get changes/diffs of a specific commit
668
- 126. `list_commit_statuses` - List statuses for a specific commit
669
- 127. `create_commit_status` - Create or update the status of a specific commit
670
- 128. `list_releases` - List all releases for a project
671
- 129. `get_release` - Get a release by tag name
672
- 130. `create_release` - Create a new release in a GitLab project
673
- 131. `update_release` - Update an existing release in a GitLab project
674
- 132. `delete_release` - Delete a release from a GitLab project (does not delete the associated tag)
675
- 133. `create_release_evidence` - Create release evidence for an existing release (GitLab Premium/Ultimate only)
676
- 134. `download_release_asset` - Download a release asset file by direct asset path
677
- 135. `list_tags` - List repository tags with filtering and pagination support
678
- 136. `get_tag` - Get details of a specific repository tag
679
- 137. `create_tag` - Create a new tag in the repository
680
- 138. `delete_tag` - Delete a tag from the repository
681
- 139. `get_tag_signature` - Get the signature of a signed tag
682
- 140. `get_users` - Get GitLab user details by usernames
683
- 141. `list_events` - List all events for the currently authenticated user
684
- 142. `get_project_events` - List all visible events for a specified project
685
- 143. `upload_markdown` - Upload a file to a GitLab project for use in markdown content
686
- 144. `download_attachment` - Download an uploaded file from a GitLab project by secret and filename
687
- 145. `get_work_item` - Get a single work item with full details including status, hierarchy (parent/children), type, labels, assignees, and all widgets
688
- 146. `list_work_items` - List work items in a project with filters (type, state, search, assignees, labels). Returns items with status and hierarchy info
689
- 147. `create_work_item` - 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
690
- 148. `update_work_item` - 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
691
- 149. `convert_work_item_type` - Convert a work item to a different type (e.g. issue to task, task to incident)
692
- 150. `list_work_item_statuses` - List available statuses for a work item type in a project. Requires GitLab Premium/Ultimate with configurable statuses
693
- 151. `list_custom_field_definitions` - 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
694
- 152. `move_work_item` - Move a work item (issue, task, etc.) to a different project. Uses GitLab GraphQL issueMove mutation
695
- 153. `list_work_item_notes` - List notes and discussions on a work item. Returns threaded discussions with author, body, timestamps, and system/internal flags
696
- 154. `create_work_item_note` - Add a note/comment to a work item. Supports Markdown, internal notes, and threaded replies
697
- 155. `get_timeline_events` - List timeline events for an incident. Returns chronological events with notes, timestamps, and tags
698
- 156. `create_timeline_event` - Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'
699
- 157. `list_webhooks` - List all configured webhooks for a GitLab project or group. Provide either project_id or group_id
700
- 158. `list_webhook_events` - List recent webhook events (past 7 days) for a project or group webhook. Use summary mode for overview, then get_webhook_event for full details
701
- 159. `get_webhook_event` - Get full details of a specific webhook event by ID, including request/response payloads
702
- 160. `search_code` - Search for code across all projects on the GitLab instance (requires advanced search or exact code search to be enabled)
703
- 161. `search_project_code` - Search for code within a specific GitLab project (requires advanced search or exact code search to be enabled)
704
- 162. `search_group_code` - Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled)
705
- 163. `execute_graphql` - Execute a GitLab GraphQL query
706
- 164. `list_merge_request_pipelines` - List pipelines for a merge request with pagination support
707
- 165. `list_project_variables` - List CI/CD variables for a project with optional environment scope filter
708
- 166. `get_project_variable` - Get a single CI/CD variable from a project by key, with optional environment scope filter
709
- 167. `create_project_variable` - Create a new CI/CD variable in a project
710
- 168. `update_project_variable` - Update an existing CI/CD variable in a project, with optional filter to disambiguate by environment scope
711
- 169. `delete_project_variable` - Delete a CI/CD variable from a project, with optional filter to disambiguate by environment scope
712
- 170. `list_group_variables` - List CI/CD variables for a group with optional environment scope filter
713
- 171. `get_group_variable` - Get a single CI/CD variable from a group by key, with optional environment scope filter
714
- 172. `create_group_variable` - Create a new CI/CD variable in a group
715
- 173. `update_group_variable` - Update an existing CI/CD variable in a group, with optional filter to disambiguate by environment scope
716
- 174. `delete_group_variable` - Delete a CI/CD variable from a group, with optional filter to disambiguate by environment scope
717
- 175. `get_dependency_proxy_settings` - Get dependency proxy settings for a group (enabled status, blob count, total size, image prefix, TTL policy)
718
- 176. `update_dependency_proxy_settings` - Update dependency proxy settings for a group (enable/disable, credentials for authenticated Docker Hub pulls)
719
- 177. `list_dependency_proxy_blobs` - List cached dependency proxy blobs for a group with cursor-based pagination
720
- 178. `purge_dependency_proxy_cache` - Schedule purge of all cached dependency proxy blobs for a group
544
+ 2. `approve_merge_request` - Approve a merge request (requires appropriate permissions)
545
+ 3. `unapprove_merge_request` - Unapprove a previously approved merge request
546
+ 4. `get_merge_request_approval_state` - Get merge request approval details including approvers (uses `approval_state` when available, otherwise falls back to `approvals`)
547
+ 5. `get_merge_request_conflicts` - Get the conflicts of a merge request in a GitLab project
548
+ 6. `list_merge_request_pipelines` - List pipelines for a merge request with pagination support
549
+ 7. `execute_graphql` - Execute a GitLab GraphQL query
550
+ 8. `create_or_update_file` - Create or update a single file in a GitLab project
551
+ 9. `search_repositories` - Search for GitLab projects
552
+ 10. `create_repository` - Create a new GitLab project
553
+ 11. `create_group` - Create a new GitLab group or subgroup (name, path, description, visibility, and optional parent_id)
554
+ 12. `get_file_contents` - Get the contents of a file or directory from a GitLab project
555
+ 13. `push_files` - Push multiple files to a GitLab project in a single commit
556
+ 14. `create_issue` - Create a new issue in a GitLab project
557
+ 15. `create_merge_request` - Create a new merge request in a GitLab project
558
+ 16. `fork_repository` - Fork a GitLab project to your account or specified namespace
559
+ 17. `create_branch` - Create a new branch in a GitLab project
560
+ 18. `get_branch` - Get branch details (commit, protection status)
561
+ 19. `list_branches` - List branches in project with search filter
562
+ 20. `delete_branch` - Delete branch from project
563
+ 21. `list_protected_branches` - List protected branches in a project, supports search filter
564
+ 22. `get_protected_branch` - Get details of a single protected branch (access levels, force push settings)
565
+ 23. `protect_branch` - Protect a repository branch (set push/merge/unprotect access levels)
566
+ 24. `unprotect_branch` - Remove protection from a previously protected branch
567
+ 25. `update_default_branch` - Change the default branch of a project
568
+ 26. `get_merge_request` - Get details of a merge request with compact deployment summary, behind-count, commit addition summary, and approval summary (Either mergeRequestIid or branchName must be provided)
569
+ 27. `get_merge_request_diffs` - Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)
570
+ 28. `list_merge_request_changed_files` - STEP 1 of code review workflow. Returns ONLY the list of changed file paths in a merge request — WITHOUT diff content. 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). Supports excluded_file_patterns filtering using regex. (Either mergeRequestIid or branchName must be provided)
571
+ 29. `list_merge_request_diffs` - List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)
572
+ 30. `get_merge_request_file_diff` - STEP 2 of code review workflow. Get diffs for one or more files from a merge request. Call list_merge_request_changed_files first, then pass them as an array to fetch diffs efficiently. Batching multiple files (recommended 3-5) is supported. (Either mergeRequestIid or branchName must be provided)
573
+ 31. `list_merge_request_versions` - List all versions of a merge request
574
+ 32. `get_merge_request_version` - Get a specific version of a merge request
575
+ 33. `get_branch_diffs` - Get the changes/diffs between two branches or commits in a GitLab project
576
+ 34. `update_merge_request` - Update a merge request (Either mergeRequestIid or branchName must be provided)
577
+ 35. `create_note` - Create a new note (comment) to an issue or merge request
578
+ 36. `create_merge_request_thread` - Create a new thread on a merge request
579
+ 37. `resolve_merge_request_thread` - Resolve a thread on a merge request
580
+ 38. `mr_discussions` - List discussion items for a merge request
581
+ 39. `delete_merge_request_discussion_note` - Delete a discussion note on a merge request
582
+ 40. `update_merge_request_discussion_note` - Update a discussion note on a merge request
583
+ 41. `create_merge_request_discussion_note` - Add a new discussion note to an existing merge request thread
584
+ 42. `create_merge_request_note` - Add a new note to an existing merge request thread
585
+ 43. `delete_merge_request_note` - Delete an existing merge request note
586
+ 44. `get_merge_request_note` - Get a specific note for a merge request
587
+ 45. `get_merge_request_notes` - List notes for a merge request
588
+ 46. `update_merge_request_note` - Modify an existing merge request thread note
589
+ 47. `get_draft_note` - Get a single draft note from a merge request
590
+ 48. `list_draft_notes` - List draft notes for a merge request
591
+ 49. `create_draft_note` - Create a draft note for a merge request
592
+ 50. `update_draft_note` - Update an existing draft note
593
+ 51. `delete_draft_note` - Delete a draft note
594
+ 52. `publish_draft_note` - Publish a single draft note
595
+ 53. `bulk_publish_draft_notes` - Publish all draft notes for a merge request
596
+ 54. `list_merge_request_emoji_reactions` - List all emoji reactions on a merge request
597
+ 55. `list_merge_request_note_emoji_reactions` - List all emoji reactions on a merge request note. Pass discussion_id for discussion thread replies.
598
+ 56. `create_merge_request_emoji_reaction` - Add an emoji reaction to a merge request (e.g. thumbsup, rocket, eyes)
599
+ 57. `delete_merge_request_emoji_reaction` - Remove an emoji reaction from a merge request
600
+ 58. `create_merge_request_note_emoji_reaction` - Add an emoji reaction to a merge request note. Pass discussion_id for discussion thread replies.
601
+ 59. `delete_merge_request_note_emoji_reaction` - Remove an emoji reaction from a merge request note. Pass discussion_id for discussion thread replies.
602
+ 60. `update_issue_note` - Modify an existing issue thread note
603
+ 61. `create_issue_note` - Add a new note to an existing issue thread
604
+ 62. `list_issue_emoji_reactions` - List all emoji reactions on an issue
605
+ 63. `list_issue_note_emoji_reactions` - List all emoji reactions on an issue note. Pass discussion_id for discussion thread replies.
606
+ 64. `create_issue_emoji_reaction` - Add an emoji reaction to an issue (e.g. thumbsup, rocket, eyes)
607
+ 65. `delete_issue_emoji_reaction` - Remove an emoji reaction from an issue
608
+ 66. `create_issue_note_emoji_reaction` - Add an emoji reaction to an issue note. Pass discussion_id for discussion thread replies.
609
+ 67. `delete_issue_note_emoji_reaction` - Remove an emoji reaction from an issue note. Pass discussion_id for discussion thread replies.
610
+ 68. `list_issues` - List issues (default: created by current user only; use scope='all' for all accessible issues)
611
+ 69. `my_issues` - List issues assigned to the authenticated user (defaults to open issues)
612
+ 70. `get_issue` - Get details of a specific issue in a GitLab project
613
+ 71. `update_issue` - Update an issue in a GitLab project
614
+ 72. `update_issue_description_patch` - Apply a patch (search/replace or unified diff) to an issue description. Reduces token usage by sending only the change instead of the full description. Supports `dry_run` to preview and `create_note` to summarize.
615
+ 73. `delete_issue` - Delete an issue from a GitLab project
616
+ 74. `list_todos` - List GitLab to-do items for the current user
617
+ 75. `mark_todo_done` - Mark a GitLab to-do item as done
618
+ 76. `mark_all_todos_done` - Mark all pending GitLab to-do items as done for the current user
619
+ 77. `list_issue_links` - List all issue links for a specific issue
620
+ 78. `list_issue_discussions` - List discussions for an issue in a GitLab project
621
+ 79. `get_issue_link` - Get a specific issue link
622
+ 80. `create_issue_link` - Create an issue link between two issues
623
+ 81. `delete_issue_link` - Delete an issue link
624
+ 82. `list_namespaces` - List all namespaces available to the current user
625
+ 83. `get_namespace` - Get details of a namespace by ID or path
626
+ 84. `verify_namespace` - Verify if a namespace path exists
627
+ 85. `get_project` - Get details of a specific project
628
+ 86. `list_projects` - List projects accessible by the current user
629
+ 87. `update_project` - Update project settings such as description, visibility, default branch, and feature access levels
630
+ 88. `list_project_members` - List members of a GitLab project
631
+ 89. `list_labels` - List labels for a project
632
+ 90. `get_label` - Get a single label from a project
633
+ 91. `create_label` - Create a new label in a project
634
+ 92. `update_label` - Update an existing label in a project
635
+ 93. `delete_label` - Delete a label from a project
636
+ 94. `list_group_projects` - List projects in a GitLab group with filtering options
637
+ 95. `list_wiki_pages` - List wiki pages in a GitLab project
638
+ 96. `get_wiki_page` - Get details of a specific wiki page
639
+ 97. `create_wiki_page` - Create a new wiki page in a GitLab project
640
+ 98. `update_wiki_page` - Update an existing wiki page in a GitLab project
641
+ 99. `delete_wiki_page` - Delete a wiki page from a GitLab project
642
+ 100. `list_group_wiki_pages` - List wiki pages in a GitLab group
643
+ 101. `get_group_wiki_page` - Get details of a specific group wiki page
644
+ 102. `create_group_wiki_page` - Create a new wiki page in a GitLab group
645
+ 103. `update_group_wiki_page` - Update an existing wiki page in a GitLab group
646
+ 104. `delete_group_wiki_page` - Delete a wiki page from a GitLab group
647
+ 105. `get_repository_tree` - Get the repository tree for a GitLab project (list files and directories)
648
+ 106. `list_pipelines` - List pipelines in a GitLab project with filtering options
649
+ 107. `get_pipeline` - Get details of a specific pipeline in a GitLab project
650
+ 108. `list_deployments` - List deployments in a GitLab project with filtering options
651
+ 109. `get_deployment` - Get details of a specific deployment in a GitLab project
652
+ 110. `list_environments` - List environments in a GitLab project
653
+ 111. `get_environment` - Get details of a specific environment in a GitLab project
654
+ 112. `list_pipeline_jobs` - List all jobs in a specific pipeline
655
+ 113. `list_pipeline_trigger_jobs` - List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines
656
+ 114. `get_pipeline_job` - Get details of a GitLab pipeline job number
657
+ 115. `get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage
658
+ 116. `validate_ci_lint` - Validate provided GitLab CI/CD YAML content for a project
659
+ 117. `validate_project_ci_lint` - Validate an existing `.gitlab-ci.yml` configuration for a project
660
+ 118. `list_ci_catalog_resources` - List GitLab CI/CD Catalog resources/components visible to the user
661
+ 119. `get_ci_catalog_resource` - Get details for a GitLab CI/CD Catalog resource, including versions and components
662
+ 120. `create_pipeline` - Create a new pipeline for a branch or tag
663
+ 121. `retry_pipeline` - Retry a failed or canceled pipeline
664
+ 122. `cancel_pipeline` - Cancel a running pipeline
665
+ 123. `play_pipeline_job` - Run a manual pipeline job
666
+ 124. `retry_pipeline_job` - Retry a failed or canceled pipeline job
667
+ 125. `cancel_pipeline_job` - Cancel a running pipeline job
668
+ 126. `list_job_artifacts` - List artifact files in a job's artifacts archive. Returns file names, paths, types, and sizes
669
+ 127. `download_job_artifacts` - Download the entire artifact archive (zip) for a job to a local path. Returns the saved file path
670
+ 128. `get_job_artifact_file` - Get the content of a single file from a job's artifacts by its path within the archive
671
+ 129. `list_merge_requests` - List merge requests globally or in a specific GitLab project with filtering options (project_id is now optional)
672
+ 130. `list_milestones` - List milestones in a GitLab project with filtering options
673
+ 131. `get_milestone` - Get details of a specific milestone
674
+ 132. `create_milestone` - Create a new milestone in a GitLab project
675
+ 133. `edit_milestone` - Edit an existing milestone in a GitLab project
676
+ 134. `delete_milestone` - Delete a milestone from a GitLab project
677
+ 135. `get_milestone_issue` - Get issues associated with a specific milestone
678
+ 136. `get_milestone_merge_requests` - Get merge requests associated with a specific milestone
679
+ 137. `promote_milestone` - Promote a milestone to the next stage
680
+ 138. `get_milestone_burndown_events` - Get burndown events for a specific milestone
681
+ 139. `list_group_milestones` - List milestones in a GitLab group with filtering options
682
+ 140. `get_group_milestone` - Get details of a specific group milestone
683
+ 141. `create_group_milestone` - Create a new milestone in a GitLab group
684
+ 142. `edit_group_milestone` - Edit an existing group milestone
685
+ 143. `delete_group_milestone` - Delete a milestone from a GitLab group
686
+ 144. `get_group_milestone_issue` - Get issues associated with a specific group milestone
687
+ 145. `get_group_milestone_merge_requests` - Get merge requests associated with a specific group milestone
688
+ 146. `get_group_milestone_burndown_events` - Get burndown events for a specific group milestone
689
+ 147. `get_users` - Get GitLab user details by usernames
690
+ 148. `get_user` - Get user details by ID
691
+ 149. `whoami` - Get current authenticated user details
692
+ 150. `list_commits` - List repository commits with filtering options
693
+ 151. `get_commit` - Get details of a specific commit
694
+ 152. `get_commit_diff` - Get changes/diffs of a specific commit
695
+ 153. `get_file_blame` - Get git blame for a file at a given ref. Each entry maps a contiguous range of source lines to the commit that last changed them (id, author, authored_date, message). Use range_start/range_end to limit blame to specific lines.
696
+ 154. `list_commit_statuses` - List statuses for a specific commit
697
+ 155. `create_commit_status` - Create or update the status of a specific commit
698
+ 156. `list_group_iterations` - List group iterations with filtering options
699
+ 157. `upload_markdown` - Upload a file to a GitLab project for use in markdown content
700
+ 158. `download_attachment` - Download an uploaded file from a GitLab project by secret and filename
701
+ 159. `health_check` - Verify server status and authentication
702
+ 160. `list_events` - List all events for the currently authenticated user
703
+ 161. `get_project_events` - List all visible events for a specified project
704
+ 162. `list_releases` - List all releases for a project
705
+ 163. `get_release` - Get a release by tag name
706
+ 164. `create_release` - Create a new release in a GitLab project
707
+ 165. `update_release` - Update an existing release in a GitLab project
708
+ 166. `delete_release` - Delete a release from a GitLab project (does not delete the associated tag)
709
+ 167. `create_release_evidence` - Create release evidence for an existing release (GitLab Premium/Ultimate only)
710
+ 168. `download_release_asset` - Download a release asset file by direct asset path
711
+ 169. `list_tags` - List repository tags with filtering and pagination support
712
+ 170. `get_tag` - Get details of a specific repository tag
713
+ 171. `create_tag` - Create a new tag in the repository
714
+ 172. `delete_tag` - Delete a tag from the repository
715
+ 173. `get_tag_signature` - Get the signature of a signed tag
716
+ 174. `get_work_item` - Get a single work item with full details including status, hierarchy (parent/children), type, labels, assignees, and all widgets
717
+ 175. `list_work_items` - List work items in a project with filters (type, state, search, assignees, labels). Returns items with status and hierarchy info
718
+ 176. `create_work_item` - 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
719
+ 177. `update_work_item` - 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
720
+ 178. `convert_work_item_type` - Convert a work item to a different type (e.g. issue to task, task to incident)
721
+ 179. `list_work_item_statuses` - List available statuses for a work item type in a project. Requires GitLab Premium/Ultimate with configurable statuses
722
+ 180. `list_custom_field_definitions` - 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
723
+ 181. `move_work_item` - Move a work item (issue, task, etc.) to a different project. Uses GitLab GraphQL issueMove mutation
724
+ 182. `list_work_item_notes` - List notes and discussions on a work item. Returns threaded discussions with author, body, timestamps, and system/internal flags
725
+ 183. `create_work_item_note` - Add a note/comment to a work item. Supports Markdown, internal notes, and threaded replies
726
+ 184. `list_work_item_emoji_reactions` - List all emoji reactions on a work item
727
+ 185. `list_work_item_note_emoji_reactions` - List all emoji reactions on a work item note (comment, thread, or thread reply)
728
+ 186. `create_work_item_emoji_reaction` - Add an emoji reaction to a work item (e.g. thumbsup, rocket, eyes)
729
+ 187. `delete_work_item_emoji_reaction` - Remove an emoji reaction from a work item
730
+ 188. `create_work_item_note_emoji_reaction` - Add an emoji reaction to a work item note (comment, thread, or thread reply)
731
+ 189. `delete_work_item_note_emoji_reaction` - Remove an emoji reaction from a work item note (comment, thread, or thread reply)
732
+ 190. `get_timeline_events` - List timeline events for an incident. Returns chronological events with notes, timestamps, and tags
733
+ 191. `create_timeline_event` - Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'
734
+ 192. `list_webhooks` - List all configured webhooks for a GitLab project or group. Provide either project_id or group_id
735
+ 193. `list_webhook_events` - List recent webhook events (past 7 days) for a project or group webhook. Use summary mode for overview, then get_webhook_event for full details
736
+ 194. `get_webhook_event` - Get full details of a specific webhook event by ID, including request/response payloads
737
+ 195. `search_code` - Search for code across all projects on the GitLab instance (requires advanced search or exact code search to be enabled)
738
+ 196. `search_project_code` - Search for code within a specific GitLab project (requires advanced search or exact code search to be enabled)
739
+ 197. `search_group_code` - Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled)
740
+ 198. `list_project_variables` - List CI/CD variables for a project with optional environment scope filter
741
+ 199. `get_project_variable` - Get a single CI/CD variable from a project by key, with optional environment scope filter
742
+ 200. `create_project_variable` - Create a new CI/CD variable in a project
743
+ 201. `update_project_variable` - Update an existing CI/CD variable in a project, with optional filter to disambiguate by environment scope
744
+ 202. `delete_project_variable` - Delete a CI/CD variable from a project, with optional filter to disambiguate by environment scope
745
+ 203. `list_group_variables` - List CI/CD variables for a group with optional environment scope filter
746
+ 204. `get_group_variable` - Get a single CI/CD variable from a group by key, with optional environment scope filter
747
+ 205. `create_group_variable` - Create a new CI/CD variable in a group
748
+ 206. `update_group_variable` - Update an existing CI/CD variable in a group, with optional filter to disambiguate by environment scope
749
+ 207. `delete_group_variable` - Delete a CI/CD variable from a group, with optional filter to disambiguate by environment scope
750
+ 208. `get_dependency_proxy_settings` - Get dependency proxy settings for a group (enabled status, blob count, total size, image prefix, TTL policy)
751
+ 209. `update_dependency_proxy_settings` - Update dependency proxy settings for a group (enable/disable, credentials for authenticated Docker Hub pulls)
752
+ 210. `list_dependency_proxy_blobs` - List cached dependency proxy blobs for a group with cursor-based pagination
753
+ 211. `purge_dependency_proxy_cache` - Schedule purge of all cached dependency proxy blobs for a group
754
+ 212. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, users, workitems, webhooks, search, variables, dependency_proxy. Already-active categories are listed in the response.
721
755
 
722
756
  <!-- TOOLS-END -->
723
757
 
package/README.zh-CN.md CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
82
82
 
83
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.38`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
83
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.41`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
84
84
 
85
85
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
86
86
 
package/build/index.js CHANGED
@@ -90,6 +90,8 @@ import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation
90
90
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
91
91
  import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
92
92
  import { checkForNewVersion } from "./utils/version-check.js";
93
+ import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
94
+ import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./utils/bulk-publish-options.js";
93
95
  import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
94
96
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
95
97
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
@@ -1122,12 +1124,17 @@ function parseAllowedGitLabApiUrls(value) {
1122
1124
  }
1123
1125
  function encodeGitLabPathSegment(value) {
1124
1126
  const segment = String(value);
1127
+ let decodedSegment;
1125
1128
  try {
1126
- return encodeURIComponent(decodeURIComponent(segment));
1129
+ decodedSegment = decodeURIComponent(segment);
1127
1130
  }
1128
1131
  catch {
1129
- return encodeURIComponent(segment);
1132
+ decodedSegment = segment;
1130
1133
  }
1134
+ if (decodedSegment === "." || decodedSegment === "..") {
1135
+ throw new Error("GitLab URL path segments cannot be '.' or '..'");
1136
+ }
1137
+ return encodeURIComponent(decodedSegment);
1131
1138
  }
1132
1139
  function encodeGitLabPath(value) {
1133
1140
  return value.split("/").map(encodeGitLabPathSegment).join("/");
@@ -3060,7 +3067,7 @@ async function createMergeRequest(projectId, options) {
3060
3067
  */
3061
3068
  async function listDiscussions(projectId, resourceType, resourceIid, options = {}) {
3062
3069
  projectId = decodeURIComponent(projectId); // Decode project ID
3063
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${resourceType}/${resourceIid}/discussions`);
3070
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${resourceType}/${encodeGitLabPathSegment(resourceIid)}/discussions`);
3064
3071
  // Add query parameters for pagination and sorting
3065
3072
  if (options.page) {
3066
3073
  url.searchParams.append("page", options.page.toString());
@@ -3298,15 +3305,15 @@ async function deleteMergeRequestNote(projectId, mergeRequestIid, noteId) {
3298
3305
  function buildAwardEmojiPath(entity, projectId, entityIid, opts) {
3299
3306
  projectId = decodeURIComponent(projectId);
3300
3307
  const pp = encodeURIComponent(getEffectiveProjectId(projectId));
3301
- let path = `${getEffectiveApiUrl()}/projects/${pp}/${entity}/${entityIid}`;
3308
+ let path = `${getEffectiveApiUrl()}/projects/${pp}/${entity}/${encodeGitLabPathSegment(entityIid)}`;
3302
3309
  if (opts?.noteId) {
3303
3310
  path = opts.discussionId
3304
- ? `${path}/discussions/${opts.discussionId}/notes/${opts.noteId}`
3305
- : `${path}/notes/${opts.noteId}`;
3311
+ ? `${path}/discussions/${encodeGitLabPathSegment(opts.discussionId)}/notes/${encodeGitLabPathSegment(opts.noteId)}`
3312
+ : `${path}/notes/${encodeGitLabPathSegment(opts.noteId)}`;
3306
3313
  }
3307
3314
  path += "/award_emoji";
3308
3315
  if (opts?.awardId)
3309
- path += `/${opts.awardId}`;
3316
+ path += `/${encodeGitLabPathSegment(opts.awardId)}`;
3310
3317
  return path;
3311
3318
  }
3312
3319
  async function createRestAwardEmoji(path, name) {
@@ -4234,7 +4241,7 @@ async function createNote(projectId, noteableType, // specifies 'issue' or 'merg
4234
4241
  noteableIid, body) {
4235
4242
  projectId = decodeURIComponent(projectId); // Decode project ID
4236
4243
  // ⚙️ Response type can be adjusted according to the GitLab API documentation
4237
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
4244
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${noteableType}s/${encodeGitLabPathSegment(noteableIid)}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
4238
4245
  );
4239
4246
  const response = await fetch(url.toString(), {
4240
4247
  ...getFetchConfig(),
@@ -4254,7 +4261,8 @@ noteableIid, body) {
4254
4261
  * @returns {Promise<GitLabDraftNote[]>} Array of draft notes
4255
4262
  */
4256
4263
  async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
4257
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}`, { ...getFetchConfig() });
4264
+ const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
4265
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/merge_requests/${encodeGitLabPathSegment(merge_request_iid)}/draft_notes/${encodeGitLabPathSegment(draft_note_id)}`, { ...getFetchConfig() });
4258
4266
  if (!response.ok) {
4259
4267
  const errorText = await response.text();
4260
4268
  throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
@@ -4418,19 +4426,49 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
4418
4426
  };
4419
4427
  }
4420
4428
  }
4429
+ async function fetchGitLabInstanceVersion() {
4430
+ try {
4431
+ const response = await fetch(`${getEffectiveApiUrl()}/version`, {
4432
+ ...getFetchConfig(),
4433
+ });
4434
+ if (!response.ok)
4435
+ return null;
4436
+ const data = await response.json();
4437
+ if (typeof data === "object" &&
4438
+ data !== null &&
4439
+ "version" in data &&
4440
+ typeof data.version === "string") {
4441
+ return data.version;
4442
+ }
4443
+ return null;
4444
+ }
4445
+ catch {
4446
+ return null;
4447
+ }
4448
+ }
4421
4449
  /**
4422
4450
  * Publish all draft notes for a merge request
4423
4451
  * @param {string} projectId - The ID or URL-encoded path of the project
4424
4452
  * @param {number|string} mergeRequestIid - The internal ID of the merge request
4453
+ * @param {BulkPublishDraftNotesBody} options - Optional GitLab 19.2+ bulk_publish body fields
4425
4454
  * @returns {Promise<GitLabDiscussionNote[]>} Array of published notes
4426
4455
  */
4427
- async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
4456
+ async function bulkPublishDraftNotes(projectId, mergeRequestIid, options = {}) {
4428
4457
  projectId = decodeURIComponent(projectId);
4429
4458
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}/draft_notes/bulk_publish`);
4459
+ const body = buildBulkPublishDraftNotesBody(options);
4460
+ if (needsGitLab19_2BulkPublish(body)) {
4461
+ await assertGitLabVersionAtLeast({
4462
+ major: 19,
4463
+ minor: 2,
4464
+ feature: "reviewer_state, note, and internal on bulk_publish_draft_notes",
4465
+ retryHint: "Omit reviewer_state, note, and internal, then retry.",
4466
+ }, fetchGitLabInstanceVersion);
4467
+ }
4430
4468
  const response = await fetch(url.toString(), {
4431
4469
  ...getFetchConfig(),
4432
4470
  method: "POST", // Changed from PUT to POST
4433
- body: JSON.stringify({}), // Send empty body for POST request
4471
+ body: JSON.stringify(body),
4434
4472
  });
4435
4473
  if (!response.ok) {
4436
4474
  const errorText = await response.text();
@@ -5323,7 +5361,7 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
5323
5361
  throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
5324
5362
  }
5325
5363
  await handleGitLabError(response);
5326
- const filename = `artifacts_job_${jobId}.zip`;
5364
+ const filename = `artifacts_job_${encodeGitLabPathSegment(jobId)}.zip`;
5327
5365
  const savePath = localPath ? path.join(localPath, filename) : filename;
5328
5366
  fs.mkdirSync(path.dirname(savePath), { recursive: true });
5329
5367
  if (!response.body) {
@@ -5345,7 +5383,7 @@ async function getJobArtifactFile(projectId, jobId, artifactPath) {
5345
5383
  const effectiveProjectId = getEffectiveProjectId(projectId);
5346
5384
  const encodedArtifactPath = artifactPath
5347
5385
  .split("/")
5348
- .map(segment => encodeURIComponent(segment))
5386
+ .map(segment => encodeGitLabPathSegment(segment))
5349
5387
  .join("/");
5350
5388
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`);
5351
5389
  const response = await fetch(url.toString(), {
@@ -6487,9 +6525,20 @@ function getImageMimeType(filename) {
6487
6525
  const ext = path.extname(filename).toLowerCase();
6488
6526
  return IMAGE_MIME_TYPES[ext] ?? null;
6489
6527
  }
6528
+ function getSafeAttachmentFilename(filename) {
6529
+ if (!filename ||
6530
+ filename === "." ||
6531
+ filename === ".." ||
6532
+ filename.includes("/") ||
6533
+ filename.includes("\\")) {
6534
+ throw new Error("Invalid filename: directory separators are not allowed.");
6535
+ }
6536
+ return filename;
6537
+ }
6490
6538
  async function downloadAttachment(projectId, secret, filename, localPath) {
6491
6539
  const effectiveProjectId = getEffectiveProjectId(projectId);
6492
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
6540
+ const safeFilename = getSafeAttachmentFilename(filename);
6541
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${encodeGitLabPathSegment(secret)}/${encodeGitLabPathSegment(safeFilename)}`);
6493
6542
  const response = await fetch(url.toString(), {
6494
6543
  ...getFetchConfig(),
6495
6544
  method: "GET",
@@ -6497,7 +6546,7 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
6497
6546
  if (!response.ok) {
6498
6547
  await handleGitLabError(response);
6499
6548
  }
6500
- const mimeType = getImageMimeType(filename);
6549
+ const mimeType = getImageMimeType(safeFilename);
6501
6550
  // For non-image files, always save to disk.
6502
6551
  // For image files, only save to disk if local_path is explicitly provided.
6503
6552
  if (!mimeType || localPath) {
@@ -6510,10 +6559,10 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
6510
6559
  normalizedLocalPath.includes(path.sep + ".." + path.sep)) {
6511
6560
  throw new Error("Invalid local_path: directory traversal is not allowed.");
6512
6561
  }
6513
- savePath = path.join(normalizedLocalPath, filename);
6562
+ savePath = path.join(normalizedLocalPath, safeFilename);
6514
6563
  }
6515
6564
  else {
6516
- savePath = filename;
6565
+ savePath = safeFilename;
6517
6566
  }
6518
6567
  const dir = path.dirname(savePath);
6519
6568
  if (!fs.existsSync(dir)) {
@@ -6524,11 +6573,11 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
6524
6573
  throw new Error("No response body from GitLab");
6525
6574
  }
6526
6575
  await streamPipeline(response.body, fs.createWriteStream(savePath));
6527
- return { buffer: Buffer.alloc(0), filename, mimeType, savedPath: savePath };
6576
+ return { buffer: Buffer.alloc(0), filename: safeFilename, mimeType, savedPath: savePath };
6528
6577
  }
6529
6578
  // Images returned inline — buffer into memory for base64 encoding
6530
6579
  const buffer = Buffer.from(await response.arrayBuffer());
6531
- return { buffer, filename, mimeType };
6580
+ return { buffer, filename: safeFilename, mimeType };
6532
6581
  }
6533
6582
  /**
6534
6583
  * List all events for the currently authenticated user
@@ -6605,7 +6654,7 @@ async function listReleases(projectId, options = {}) {
6605
6654
  */
6606
6655
  async function getRelease(projectId, tagName, includeHtmlDescription) {
6607
6656
  const effectiveProjectId = getEffectiveProjectId(projectId);
6608
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`);
6657
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}`);
6609
6658
  if (includeHtmlDescription !== undefined) {
6610
6659
  url.searchParams.append("include_html_description", includeHtmlDescription.toString());
6611
6660
  }
@@ -6644,7 +6693,7 @@ async function createRelease(projectId, options) {
6644
6693
  */
6645
6694
  async function updateRelease(projectId, tagName, options) {
6646
6695
  const effectiveProjectId = getEffectiveProjectId(projectId);
6647
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`, {
6696
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}`, {
6648
6697
  ...getFetchConfig(),
6649
6698
  method: "PUT",
6650
6699
  body: JSON.stringify(options),
@@ -6662,7 +6711,7 @@ async function updateRelease(projectId, tagName, options) {
6662
6711
  */
6663
6712
  async function deleteRelease(projectId, tagName) {
6664
6713
  const effectiveProjectId = getEffectiveProjectId(projectId);
6665
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}`, {
6714
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}`, {
6666
6715
  ...getFetchConfig(),
6667
6716
  method: "DELETE",
6668
6717
  });
@@ -6678,7 +6727,7 @@ async function deleteRelease(projectId, tagName) {
6678
6727
  */
6679
6728
  async function createReleaseEvidence(projectId, tagName) {
6680
6729
  const effectiveProjectId = getEffectiveProjectId(projectId);
6681
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}/evidence`, {
6730
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}/evidence`, {
6682
6731
  ...getFetchConfig(),
6683
6732
  method: "POST",
6684
6733
  });
@@ -6694,7 +6743,7 @@ async function createReleaseEvidence(projectId, tagName) {
6694
6743
  */
6695
6744
  async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
6696
6745
  const effectiveProjectId = getEffectiveProjectId(projectId);
6697
- const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}/downloads/${directAssetPath}`, {
6746
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}/downloads/${encodeGitLabPath(directAssetPath)}`, {
6698
6747
  ...getFetchConfig(),
6699
6748
  });
6700
6749
  await handleGitLabError(response);
@@ -7595,8 +7644,8 @@ async function handleToolCall(params) {
7595
7644
  }
7596
7645
  case "bulk_publish_draft_notes": {
7597
7646
  const args = BulkPublishDraftNotesSchema.parse(params.arguments);
7598
- const { project_id, merge_request_iid } = args;
7599
- const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid);
7647
+ const { project_id, merge_request_iid, ...options } = args;
7648
+ const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid, options);
7600
7649
  return {
7601
7650
  content: [{ type: "text", text: JSON.stringify(publishedNotes) }],
7602
7651
  };
@@ -9536,13 +9585,16 @@ async function startStreamableHTTPServer() {
9536
9585
  clearAuthTimeout(sessionId);
9537
9586
  // Set new timeout
9538
9587
  authTimeouts[sessionId] = setTimeout(() => {
9539
- if (authBySession[sessionId]) {
9540
- logger.info(`Session ${sessionId}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`);
9588
+ const authData = authBySession[sessionId];
9589
+ const transport = streamableTransports[sessionId];
9590
+ if (authData || transport) {
9591
+ logger.info(authData
9592
+ ? `Session ${sessionId}: auth token expired after ${SESSION_TIMEOUT_SECONDS}s of inactivity`
9593
+ : `Session ${sessionId}: unauthenticated discovery expired after ${SESSION_TIMEOUT_SECONDS}s`);
9541
9594
  delete authBySession[sessionId];
9542
9595
  delete authTimeouts[sessionId];
9543
9596
  metrics.expiredSessions++;
9544
9597
  // Close the transport to free the slot; without this, stale sessions accumulate and exhaust MAX_SESSIONS.
9545
- const transport = streamableTransports[sessionId];
9546
9598
  if (transport) {
9547
9599
  transport.close().catch(err => {
9548
9600
  logger.error(`Error closing transport for expired session ${sessionId}:`, err);
@@ -9975,10 +10027,12 @@ async function startStreamableHTTPServer() {
9975
10027
  return;
9976
10028
  }
9977
10029
  const newRemoteAuthData = !sessionId && REMOTE_AUTHORIZATION ? parseAuthHeaders(req) : null;
10030
+ let unauthenticatedDiscoveryRequested = false;
9978
10031
  let remoteAuthValidatedForInit = false;
9979
10032
  if (!sessionId && REMOTE_AUTHORIZATION) {
9980
10033
  const allowUnauthenticatedDiscovery = GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY &&
9981
10034
  isUnauthenticatedDiscoveryRequestBody(req.body);
10035
+ unauthenticatedDiscoveryRequested = allowUnauthenticatedDiscovery && !newRemoteAuthData;
9982
10036
  if (!newRemoteAuthData && !allowUnauthenticatedDiscovery) {
9983
10037
  metrics.authFailures++;
9984
10038
  res.status(401).json({
@@ -10147,6 +10201,9 @@ async function startStreamableHTTPServer() {
10147
10201
  logger.info(`Session ${newSessionId}: stored ${authData.header} header`);
10148
10202
  setAuthTimeout(newSessionId);
10149
10203
  }
10204
+ else if (unauthenticatedDiscoveryRequested) {
10205
+ setAuthTimeout(newSessionId);
10206
+ }
10150
10207
  }
10151
10208
  // Store OAuth token for newly created session in MCP OAuth mode.
10152
10209
  // If Private-Token or JOB-TOKEN headers are present, prefer them.
package/build/schemas.js CHANGED
@@ -2547,26 +2547,31 @@ export const MergeRequestThreadPositionSchema = z.object({
2547
2547
  });
2548
2548
  const optionalMergeRequestThreadPosition = z.preprocess(omitIncompleteMergeRequestPosition, MergeRequestThreadPositionSchema.optional());
2549
2549
  // Draft Notes API schemas
2550
+ // Response shape follows Draft Notes API (not Notes API): note, author_id,
2551
+ // line_code, merge_request_id, commit_id — no created_at/updated_at.
2550
2552
  export const GitLabDraftNoteSchema = z
2551
2553
  .object({
2552
2554
  id: z.coerce.string(),
2555
+ author_id: z.coerce.number().nullable().optional(),
2553
2556
  author: GitLabUserSchema.optional(),
2554
2557
  body: z.string().optional(),
2555
- note: z.string().optional(), // Some APIs might use 'note' instead of 'body'
2556
- created_at: z.string().optional(),
2557
- updated_at: z.string().optional(),
2558
+ note: z.string().optional(), // Draft Notes API uses 'note'; keep 'body' for callers
2559
+ line_code: z.string().nullable().optional(),
2560
+ merge_request_id: z.coerce.number().nullable().optional(),
2561
+ commit_id: z.string().nullable().optional(),
2558
2562
  discussion_id: z.string().nullable().optional(),
2559
2563
  position: z.record(z.unknown()).nullable().optional(),
2560
2564
  resolve_discussion: z.coerce.boolean().optional(),
2561
2565
  })
2562
2566
  .transform(data => ({
2563
- // Normalize the response to always have consistent field names
2564
2567
  id: data.id,
2568
+ author_id: data.author_id ?? null,
2565
2569
  author: data.author,
2566
2570
  body: data.body || data.note || "",
2567
- created_at: data.created_at || "",
2568
- updated_at: data.updated_at || "",
2569
- discussion_id: data.discussion_id || null,
2571
+ line_code: data.line_code ?? null,
2572
+ merge_request_id: data.merge_request_id ?? null,
2573
+ commit_id: data.commit_id ?? null,
2574
+ discussion_id: data.discussion_id ?? null,
2570
2575
  position: data.position,
2571
2576
  resolve_discussion: data.resolve_discussion,
2572
2577
  }));
@@ -2617,6 +2622,18 @@ export const PublishDraftNoteSchema = ProjectParamsSchema.extend({
2617
2622
  // Bulk publish draft notes schema
2618
2623
  export const BulkPublishDraftNotesSchema = ProjectParamsSchema.extend({
2619
2624
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2625
+ reviewer_state: z
2626
+ .enum(["requested_changes", "reviewed"])
2627
+ .optional()
2628
+ .describe("Set reviewer review state after publishing (GitLab 19.2+). Does not record a formal approval. Works even with no draft notes."),
2629
+ note: z
2630
+ .string()
2631
+ .optional()
2632
+ .describe("Summary note body to post on the merge request (GitLab 19.2+)"),
2633
+ internal: z.coerce
2634
+ .boolean()
2635
+ .optional()
2636
+ .describe("If true, the summary note is internal (GitLab 19.2+, default false)"),
2620
2637
  });
2621
2638
  // Schema for creating a new merge request thread
2622
2639
  export const CreateMergeRequestThreadSchema = ProjectParamsSchema.extend({
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { GitLabDraftNoteSchema } from "../schemas.js";
4
+ test("draft note schema keeps Draft Notes API fields and drops fake timestamps", () => {
5
+ const parsed = GitLabDraftNoteSchema.parse({
6
+ id: 42,
7
+ author_id: 7,
8
+ note: "looks wrong on this line",
9
+ line_code: "a1b2c3d4e5f6_12_17",
10
+ merge_request_id: 99,
11
+ commit_id: "abc123",
12
+ discussion_id: null,
13
+ position: { new_path: "src/app.ts", new_line: 17 },
14
+ resolve_discussion: false,
15
+ // Notes-API leftovers that Draft Notes API never returns — must be stripped
16
+ created_at: "2024-01-01T00:00:00Z",
17
+ updated_at: "2024-01-02T00:00:00Z",
18
+ });
19
+ assert.equal(parsed.id, "42");
20
+ assert.equal(parsed.author_id, 7);
21
+ assert.equal(parsed.body, "looks wrong on this line");
22
+ assert.equal(parsed.line_code, "a1b2c3d4e5f6_12_17");
23
+ assert.equal(parsed.merge_request_id, 99);
24
+ assert.equal(parsed.commit_id, "abc123");
25
+ assert.equal(parsed.discussion_id, null);
26
+ assert.equal(parsed.resolve_discussion, false);
27
+ assert.equal("created_at" in parsed, false);
28
+ assert.equal("updated_at" in parsed, false);
29
+ });
30
+ test("draft note schema preserves null line_code for unresolved positions", () => {
31
+ const parsed = GitLabDraftNoteSchema.parse({
32
+ id: "43",
33
+ author_id: 7,
34
+ note: "orphan draft",
35
+ line_code: null,
36
+ merge_request_id: 99,
37
+ commit_id: null,
38
+ discussion_id: null,
39
+ position: null,
40
+ resolve_discussion: false,
41
+ });
42
+ assert.equal(parsed.line_code, null);
43
+ assert.equal(parsed.commit_id, null);
44
+ });
@@ -14,6 +14,10 @@ const rawPathSegmentPatterns = [
14
14
  "/jobs/${jobId}",
15
15
  "/pipelines/${pipelineId}",
16
16
  "/milestones/${milestoneId}",
17
+ "/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}",
18
+ "/uploads/${secret}/${filename}",
19
+ "/downloads/${directAssetPath}",
20
+ "/${entity}/${entityIid}",
17
21
  ];
18
22
  test("GitLab URL path IDs are encoded before interpolation", () => {
19
23
  for (const pattern of rawPathSegmentPatterns) {
@@ -106,6 +106,21 @@ describe("Streamable HTTP unauthenticated tool discovery", { timeout: 20_000 },
106
106
  assert.ok(Array.isArray(listResponse.data.result?.tools), "tools/list should return tools");
107
107
  assert.ok(listResponse.data.result.tools.length > 0, "tools/list should not be empty");
108
108
  });
109
+ test("expires unauthenticated discovery sessions and frees capacity", async () => {
110
+ const { server, mcpUrl } = await launchRemoteAuthServer({
111
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
112
+ MAX_SESSIONS: "1",
113
+ SESSION_TIMEOUT_SECONDS: "1",
114
+ });
115
+ servers.push(server);
116
+ await initialize(mcpUrl);
117
+ await new Promise(resolve => setTimeout(resolve, 2_000));
118
+ const healthResponse = await fetch(mcpUrl.replace("/mcp", "/health"));
119
+ assert.strictEqual(healthResponse.status, 200);
120
+ const nextSession = await rawMcpRequest(mcpUrl, initializeBody);
121
+ assert.strictEqual(nextSession.status, 200, nextSession.text);
122
+ assert.ok(nextSession.sessionId, "expired discovery session should free capacity");
123
+ });
109
124
  test("still blocks unauthenticated tools/call when discovery is enabled", async () => {
110
125
  const { server, mcpUrl } = await launchRemoteAuthServer({
111
126
  GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
@@ -125,6 +125,14 @@ describe('download_attachment', () => {
125
125
  raw.result.content.some(c => typeof c.text === 'string' && c.text.toLowerCase().includes('traversal'));
126
126
  assert.ok(isRpcError || isContentError, 'Should return an error mentioning directory traversal');
127
127
  });
128
+ test('filename with directory traversal is rejected before writing to disk', async () => {
129
+ const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png#../../owned.txt' }, env);
130
+ const isRpcError = typeof raw.error?.message === 'string' &&
131
+ raw.error.message.toLowerCase().includes('filename');
132
+ const isContentError = Array.isArray(raw.result?.content) &&
133
+ raw.result.content.some(c => typeof c.text === 'string' && c.text.toLowerCase().includes('filename'));
134
+ assert.ok(isRpcError || isContentError, 'Should return an error mentioning filename validation');
135
+ });
128
136
  test('non-existent local_path directory is auto-created before saving', async () => {
129
137
  const baseDir = `omc-test-newdir-${RUN_ID}`;
130
138
  const localPath = `${baseDir}/subdir`;
@@ -582,7 +582,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
582
582
  REMOTE_AUTHORIZATION: 'true',
583
583
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
584
584
  GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
585
- GITLAB_TOOLSETS: 'variables',
585
+ GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines',
586
586
  }
587
587
  });
588
588
  servers.push(server);
@@ -657,6 +657,32 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
657
657
  const project = JSON.parse(content.text);
658
658
  assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use default project');
659
659
  });
660
+ test('should enforce the project allowlist for get_draft_note', async () => {
661
+ await assert.rejects(client.callTool('get_draft_note', {
662
+ project_id: OTHER_PROJECT_ID,
663
+ merge_request_iid: '1',
664
+ draft_note_id: '1',
665
+ }), /Access denied/);
666
+ });
667
+ test('should reject dot path segments before making GitLab requests', async () => {
668
+ const calls = [
669
+ {
670
+ name: 'get_draft_note',
671
+ args: { project_id: DEFAULT_PROJECT_ID, merge_request_iid: '..', draft_note_id: '1' },
672
+ },
673
+ {
674
+ name: 'get_job_artifact_file',
675
+ args: { project_id: DEFAULT_PROJECT_ID, job_id: '1', artifact_path: '../file.txt' },
676
+ },
677
+ {
678
+ name: 'list_issue_emoji_reactions',
679
+ args: { project_id: DEFAULT_PROJECT_ID, issue_iid: '..' },
680
+ },
681
+ ];
682
+ for (const { name, args } of calls) {
683
+ await assert.rejects(client.callTool(name, args), /path segments/);
684
+ }
685
+ });
660
686
  });
661
687
  describe('GITLAB_READ_ONLY_MODE enforces read-only for all write tools', () => {
662
688
  let mcpUrl;
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "../../utils/bulk-publish-options.js";
4
+ describe("When buildBulkPublishDraftNotesBody runs", () => {
5
+ describe("with no-op defaults", () => {
6
+ test("should omit internal false and empty note", () => {
7
+ assert.deepEqual(buildBulkPublishDraftNotesBody({ internal: false, note: "" }), {});
8
+ });
9
+ });
10
+ describe("with meaningful 19.2 fields", () => {
11
+ test("should keep reviewer_state, note, and internal true", () => {
12
+ assert.deepEqual(buildBulkPublishDraftNotesBody({
13
+ reviewer_state: "reviewed",
14
+ note: "LGTM",
15
+ internal: true,
16
+ }), { reviewer_state: "reviewed", note: "LGTM", internal: true });
17
+ });
18
+ });
19
+ });
20
+ describe("When needsGitLab19_2BulkPublish runs", () => {
21
+ describe("with an empty body", () => {
22
+ test("should return false", () => {
23
+ assert.equal(needsGitLab19_2BulkPublish({}), false);
24
+ assert.equal(needsGitLab19_2BulkPublish(buildBulkPublishDraftNotesBody({ internal: false })), false);
25
+ });
26
+ });
27
+ describe("with a non-empty body", () => {
28
+ test("should return true", () => {
29
+ assert.equal(needsGitLab19_2BulkPublish({ reviewer_state: "reviewed" }), true);
30
+ });
31
+ });
32
+ });
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { assertGitLabVersionAtLeast, isGitLabVersionAtLeast, parseGitLabVersion, } from "../../utils/gitlab-version-gate.js";
4
+ function versionFetcher(version) {
5
+ return async () => version;
6
+ }
7
+ function bulkPublishRequirement() {
8
+ return {
9
+ major: 19,
10
+ minor: 2,
11
+ feature: "reviewer_state, note, and internal on bulk_publish_draft_notes",
12
+ retryHint: "Omit reviewer_state, note, and internal, then retry.",
13
+ };
14
+ }
15
+ describe("When parseGitLabVersion runs", () => {
16
+ describe("with a release suffix", () => {
17
+ test("should parse major.minor.patch from ee builds", () => {
18
+ assert.deepEqual(parseGitLabVersion("19.2.0-ee"), {
19
+ major: 19,
20
+ minor: 2,
21
+ patch: 0,
22
+ });
23
+ });
24
+ });
25
+ describe("with a malformed string", () => {
26
+ test("should return null", () => {
27
+ assert.equal(parseGitLabVersion("unknown"), null);
28
+ assert.equal(parseGitLabVersion(""), null);
29
+ });
30
+ });
31
+ });
32
+ describe("When isGitLabVersionAtLeast compares versions", () => {
33
+ describe("with a version at or above the floor", () => {
34
+ test("should return true", () => {
35
+ assert.equal(isGitLabVersionAtLeast("19.2.0-ee", 19, 2), true);
36
+ assert.equal(isGitLabVersionAtLeast("19.3.1", 19, 2), true);
37
+ assert.equal(isGitLabVersionAtLeast("20.0.0", 19, 2), true);
38
+ });
39
+ });
40
+ describe("with a version below the floor", () => {
41
+ test("should return false", () => {
42
+ assert.equal(isGitLabVersionAtLeast("19.1.9-ee", 19, 2), false);
43
+ assert.equal(isGitLabVersionAtLeast("17.5.0", 19, 2), false);
44
+ });
45
+ });
46
+ describe("with an unparseable version", () => {
47
+ test("should return null", () => {
48
+ assert.equal(isGitLabVersionAtLeast("not-a-version", 19, 2), null);
49
+ });
50
+ });
51
+ });
52
+ describe("When assertGitLabVersionAtLeast runs", () => {
53
+ describe("with an older instance version", () => {
54
+ test("should throw a retryable error", async () => {
55
+ await assert.rejects(() => assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher("17.5.0-ee")), (error) => {
56
+ assert.ok(error instanceof Error);
57
+ assert.match(error.message, /GitLab 19\.2\+/);
58
+ assert.match(error.message, /17\.5\.0-ee/);
59
+ assert.match(error.message, /Omit reviewer_state/);
60
+ return true;
61
+ });
62
+ });
63
+ });
64
+ describe("with a new enough instance version", () => {
65
+ test("should resolve without throwing", async () => {
66
+ await assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher("19.2.0-ee"));
67
+ });
68
+ });
69
+ describe("with an unknown instance version", () => {
70
+ test("should fail open and resolve", async () => {
71
+ await assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher(null));
72
+ });
73
+ });
74
+ });
@@ -267,7 +267,7 @@ export const allTools = [
267
267
  },
268
268
  {
269
269
  name: "bulk_publish_draft_notes",
270
- description: "Publish all draft notes for a merge request",
270
+ description: "Publish all draft notes for a merge request. Optionally sets reviewer_state and posts a summary note (GitLab 19.2+). Can set reviewer_state even with no drafts.",
271
271
  inputSchema: toJSONSchema(BulkPublishDraftNotesSchema),
272
272
  },
273
273
  // --- Merge request emoji reaction tools ---
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Build the POST body. Omit no-op defaults (`internal: false`, empty `note`)
3
+ * so older GitLab instances are not sent unknown fields.
4
+ */
5
+ export function buildBulkPublishDraftNotesBody(options) {
6
+ const body = {};
7
+ if (options.reviewer_state !== undefined) {
8
+ body.reviewer_state = options.reviewer_state;
9
+ }
10
+ if (options.note) {
11
+ body.note = options.note;
12
+ }
13
+ if (options.internal === true) {
14
+ body.internal = true;
15
+ }
16
+ return body;
17
+ }
18
+ /** True when the body needs GitLab 19.2+ bulk_publish fields. */
19
+ export function needsGitLab19_2BulkPublish(body) {
20
+ return Object.keys(body).length > 0;
21
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Call-time GitLab instance version gate.
3
+ *
4
+ * Use when a tool exposes optional params that only exist on newer GitLab.
5
+ * Schemas stay static; check before the API call and throw a retryable error.
6
+ * Fail-open when the instance version cannot be fetched or parsed.
7
+ */
8
+ const VERSION_CORE = /^(\d+)\.(\d+)(?:\.(\d+))?/;
9
+ /** Parse strings like "19.2.0-ee" / "17.5.3". Returns null if unparseable. */
10
+ export function parseGitLabVersion(version) {
11
+ const match = VERSION_CORE.exec(version.trim());
12
+ if (!match)
13
+ return null;
14
+ const major = Number.parseInt(match[1], 10);
15
+ const minor = Number.parseInt(match[2], 10);
16
+ const patch = match[3] ? Number.parseInt(match[3], 10) : 0;
17
+ if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
18
+ return null;
19
+ }
20
+ return { major, minor, patch };
21
+ }
22
+ /**
23
+ * Compare a GitLab version string against a major.minor floor.
24
+ * Returns null when the version string cannot be parsed.
25
+ */
26
+ export function isGitLabVersionAtLeast(version, major, minor) {
27
+ const parsed = parseGitLabVersion(version);
28
+ if (!parsed)
29
+ return null;
30
+ if (parsed.major !== major)
31
+ return parsed.major > major;
32
+ return parsed.minor >= minor;
33
+ }
34
+ /**
35
+ * Throws when the instance is known to be older than `requirement`.
36
+ * Does nothing when the version is unknown (fail-open).
37
+ */
38
+ export async function assertGitLabVersionAtLeast(requirement, fetchVersion) {
39
+ const current = await fetchVersion();
40
+ if (current === null)
41
+ return;
42
+ const ok = isGitLabVersionAtLeast(current, requirement.major, requirement.minor);
43
+ if (ok === null || ok)
44
+ return;
45
+ throw new Error(`GitLab ${requirement.major}.${requirement.minor}+ required for ${requirement.feature} ` +
46
+ `(instance reports ${current}). ${requirement.retryHint}`);
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.40",
3
+ "version": "2.1.42",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [