@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 +1 -1
- package/README.md +212 -178
- package/README.zh-CN.md +1 -1
- package/build/index.js +86 -29
- package/build/schemas.js +24 -7
- package/build/test/draft-note-schema.test.js +44 -0
- package/build/test/path-segment-encoding.test.js +4 -0
- package/build/test/streamable-http-unauthenticated-discovery.test.js +15 -0
- package/build/test/test-download-attachment.js +8 -0
- package/build/test/test-geteffectiveprojectid.js +27 -1
- package/build/test/utils/bulk-publish-options.test.js +32 -0
- package/build/test/utils/gitlab-version-gate.test.js +74 -0
- package/build/tools/registry.js +1 -1
- package/build/utils/bulk-publish-options.js +21 -0
- package/build/utils/gitlab-version-gate.js +47 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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. `
|
|
545
|
-
3. `
|
|
546
|
-
4. `
|
|
547
|
-
5. `
|
|
548
|
-
6. `
|
|
549
|
-
7. `
|
|
550
|
-
8. `
|
|
551
|
-
9. `
|
|
552
|
-
10. `
|
|
553
|
-
11. `
|
|
554
|
-
12. `
|
|
555
|
-
13. `
|
|
556
|
-
14. `
|
|
557
|
-
15. `
|
|
558
|
-
16. `
|
|
559
|
-
17. `
|
|
560
|
-
18. `
|
|
561
|
-
19. `
|
|
562
|
-
20. `
|
|
563
|
-
21. `
|
|
564
|
-
22. `
|
|
565
|
-
23. `
|
|
566
|
-
24. `
|
|
567
|
-
25. `
|
|
568
|
-
26. `
|
|
569
|
-
27. `
|
|
570
|
-
28. `
|
|
571
|
-
29. `
|
|
572
|
-
30. `
|
|
573
|
-
31. `
|
|
574
|
-
32. `
|
|
575
|
-
33. `
|
|
576
|
-
34. `
|
|
577
|
-
35. `
|
|
578
|
-
36. `
|
|
579
|
-
37. `
|
|
580
|
-
38. `
|
|
581
|
-
39. `
|
|
582
|
-
40. `
|
|
583
|
-
41. `
|
|
584
|
-
42. `
|
|
585
|
-
43. `
|
|
586
|
-
44. `
|
|
587
|
-
45. `
|
|
588
|
-
46. `
|
|
589
|
-
47. `
|
|
590
|
-
48. `
|
|
591
|
-
49. `
|
|
592
|
-
50. `
|
|
593
|
-
51. `
|
|
594
|
-
52. `
|
|
595
|
-
53. `
|
|
596
|
-
54. `
|
|
597
|
-
55. `
|
|
598
|
-
56. `
|
|
599
|
-
57. `
|
|
600
|
-
58. `
|
|
601
|
-
59. `
|
|
602
|
-
60. `
|
|
603
|
-
61. `
|
|
604
|
-
62. `
|
|
605
|
-
63. `
|
|
606
|
-
64. `
|
|
607
|
-
65. `
|
|
608
|
-
66. `
|
|
609
|
-
67. `
|
|
610
|
-
68. `
|
|
611
|
-
69. `
|
|
612
|
-
70. `
|
|
613
|
-
71. `
|
|
614
|
-
72. `
|
|
615
|
-
73. `
|
|
616
|
-
74. `
|
|
617
|
-
75. `
|
|
618
|
-
76. `
|
|
619
|
-
77. `
|
|
620
|
-
78. `
|
|
621
|
-
79. `
|
|
622
|
-
80. `
|
|
623
|
-
81. `
|
|
624
|
-
82. `
|
|
625
|
-
83. `
|
|
626
|
-
84. `
|
|
627
|
-
85. `
|
|
628
|
-
86. `
|
|
629
|
-
87. `
|
|
630
|
-
88. `
|
|
631
|
-
89. `
|
|
632
|
-
90. `
|
|
633
|
-
91. `
|
|
634
|
-
92. `
|
|
635
|
-
93. `
|
|
636
|
-
94. `
|
|
637
|
-
95. `
|
|
638
|
-
96. `
|
|
639
|
-
97. `
|
|
640
|
-
98. `
|
|
641
|
-
99. `
|
|
642
|
-
100. `
|
|
643
|
-
101. `
|
|
644
|
-
102. `
|
|
645
|
-
103. `
|
|
646
|
-
104. `
|
|
647
|
-
105. `
|
|
648
|
-
106. `
|
|
649
|
-
107. `
|
|
650
|
-
108. `
|
|
651
|
-
109. `
|
|
652
|
-
110. `
|
|
653
|
-
111. `
|
|
654
|
-
112. `
|
|
655
|
-
113. `
|
|
656
|
-
114. `
|
|
657
|
-
115. `
|
|
658
|
-
116. `
|
|
659
|
-
117. `
|
|
660
|
-
118. `
|
|
661
|
-
119. `
|
|
662
|
-
120. `
|
|
663
|
-
121. `
|
|
664
|
-
122. `
|
|
665
|
-
123. `
|
|
666
|
-
124. `
|
|
667
|
-
125. `
|
|
668
|
-
126. `
|
|
669
|
-
127. `
|
|
670
|
-
128. `
|
|
671
|
-
129. `
|
|
672
|
-
130. `
|
|
673
|
-
131. `
|
|
674
|
-
132. `
|
|
675
|
-
133. `
|
|
676
|
-
134. `
|
|
677
|
-
135. `
|
|
678
|
-
136. `
|
|
679
|
-
137. `
|
|
680
|
-
138. `
|
|
681
|
-
139. `
|
|
682
|
-
140. `
|
|
683
|
-
141. `
|
|
684
|
-
142. `
|
|
685
|
-
143. `
|
|
686
|
-
144. `
|
|
687
|
-
145. `
|
|
688
|
-
146. `
|
|
689
|
-
147. `
|
|
690
|
-
148. `
|
|
691
|
-
149. `
|
|
692
|
-
150. `
|
|
693
|
-
151. `
|
|
694
|
-
152. `
|
|
695
|
-
153. `
|
|
696
|
-
154. `
|
|
697
|
-
155. `
|
|
698
|
-
156. `
|
|
699
|
-
157. `
|
|
700
|
-
158. `
|
|
701
|
-
159. `
|
|
702
|
-
160. `
|
|
703
|
-
161. `
|
|
704
|
-
162. `
|
|
705
|
-
163. `
|
|
706
|
-
164. `
|
|
707
|
-
165. `
|
|
708
|
-
166. `
|
|
709
|
-
167. `
|
|
710
|
-
168. `
|
|
711
|
-
169. `
|
|
712
|
-
170. `
|
|
713
|
-
171. `
|
|
714
|
-
172. `
|
|
715
|
-
173. `
|
|
716
|
-
174. `
|
|
717
|
-
175. `
|
|
718
|
-
176. `
|
|
719
|
-
177. `
|
|
720
|
-
178. `
|
|
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.
|
|
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
|
-
|
|
1129
|
+
decodedSegment = decodeURIComponent(segment);
|
|
1127
1130
|
}
|
|
1128
1131
|
catch {
|
|
1129
|
-
|
|
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
|
|
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(
|
|
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 =>
|
|
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
|
|
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(
|
|
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,
|
|
6562
|
+
savePath = path.join(normalizedLocalPath, safeFilename);
|
|
6514
6563
|
}
|
|
6515
6564
|
else {
|
|
6516
|
-
savePath =
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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
|
-
|
|
9540
|
-
|
|
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(), //
|
|
2556
|
-
|
|
2557
|
-
|
|
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
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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
|
+
});
|
package/build/tools/registry.js
CHANGED
|
@@ -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