@zereight/mcp-gitlab 2.1.11 → 2.1.12

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.md CHANGED
@@ -540,111 +540,112 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
540
540
  47. `my_issues` - List issues assigned to the authenticated user (defaults to open issues)
541
541
  48. `get_issue` - Get details of a specific issue in a GitLab project
542
542
  49. `update_issue` - Update an issue in a GitLab project
543
- 50. `delete_issue` - Delete an issue from a GitLab project
544
- 51. `list_todos` - List GitLab to-do items for the current user
545
- 52. `mark_todo_done` - Mark a GitLab to-do item as done
546
- 53. `mark_all_todos_done` - Mark all pending GitLab to-do items as done for the current user
547
- 54. `list_issue_links` - List all issue links for a specific issue
548
- 55. `list_issue_discussions` - List discussions for an issue in a GitLab project
549
- 56. `get_issue_link` - Get a specific issue link
550
- 57. `create_issue_link` - Create an issue link between two issues
551
- 58. `delete_issue_link` - Delete an issue link
552
- 59. `list_namespaces` - List all namespaces available to the current user
553
- 60. `get_namespace` - Get details of a namespace by ID or path
554
- 61. `verify_namespace` - Verify if a namespace path exists
555
- 62. `get_project` - Get details of a specific project
556
- 63. `list_projects` - List projects accessible by the current user
557
- 64. `list_project_members` - List members of a GitLab project
558
- 65. `list_group_projects` - List projects in a GitLab group with filtering options
559
- 66. `list_group_iterations` - List group iterations with filtering options
560
- 67. `list_labels` - List labels for a project
561
- 68. `get_label` - Get a single label from a project
562
- 69. `create_label` - Create a new label in a project
563
- 70. `update_label` - Update an existing label in a project
564
- 71. `delete_label` - Delete a label from a project
565
- 72. `list_pipelines` - List pipelines in a GitLab project with filtering options
566
- 73. `get_pipeline` - Get details of a specific pipeline in a GitLab project
567
- 74. `list_pipeline_jobs` - List all jobs in a specific pipeline
568
- 75. `list_pipeline_trigger_jobs` - List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines
569
- 76. `get_pipeline_job` - Get details of a GitLab pipeline job number
570
- 77. `get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage
571
- 78. `validate_ci_lint` - Validate provided GitLab CI/CD YAML content for a project
572
- 79. `validate_project_ci_lint` - Validate an existing `.gitlab-ci.yml` configuration for a project
573
- 80. `create_pipeline` - Create a new pipeline for a branch or tag
574
- 81. `retry_pipeline` - Retry a failed or canceled pipeline
575
- 82. `cancel_pipeline` - Cancel a running pipeline
576
- 83. `play_pipeline_job` - Run a manual pipeline job
577
- 84. `retry_pipeline_job` - Retry a failed or canceled pipeline job
578
- 85. `cancel_pipeline_job` - Cancel a running pipeline job
579
- 86. `list_deployments` - List deployments in a GitLab project with filtering options
580
- 87. `get_deployment` - Get details of a specific deployment in a GitLab project
581
- 88. `list_environments` - List environments in a GitLab project
582
- 89. `get_environment` - Get details of a specific environment in a GitLab project
583
- 90. `list_job_artifacts` - List artifact files in a job's artifacts archive. Returns file names, paths, types, and sizes
584
- 91. `download_job_artifacts` - Download the entire artifact archive (zip) for a job to a local path. Returns the saved file path
585
- 92. `get_job_artifact_file` - Get the content of a single file from a job's artifacts by its path within the archive
586
- 93. `list_milestones` - List milestones in a GitLab project with filtering options
587
- 94. `get_milestone` - Get details of a specific milestone
588
- 95. `create_milestone` - Create a new milestone in a GitLab project
589
- 96. `edit_milestone` - Edit an existing milestone in a GitLab project
590
- 97. `delete_milestone` - Delete a milestone from a GitLab project
591
- 98. `get_milestone_issue` - Get issues associated with a specific milestone
592
- 99. `get_milestone_merge_requests` - Get merge requests associated with a specific milestone
593
- 100. `promote_milestone` - Promote a milestone to the next stage
594
- 101. `get_milestone_burndown_events` - Get burndown events for a specific milestone
595
- 102. `list_wiki_pages` - List wiki pages in a GitLab project
596
- 103. `get_wiki_page` - Get details of a specific wiki page
597
- 104. `create_wiki_page` - Create a new wiki page in a GitLab project
598
- 105. `update_wiki_page` - Update an existing wiki page in a GitLab project
599
- 106. `delete_wiki_page` - Delete a wiki page from a GitLab project
600
- 107. `list_group_wiki_pages` - List wiki pages in a GitLab group
601
- 108. `get_group_wiki_page` - Get details of a specific group wiki page
602
- 109. `create_group_wiki_page` - Create a new wiki page in a GitLab group
603
- 110. `update_group_wiki_page` - Update an existing wiki page in a GitLab group
604
- 111. `delete_group_wiki_page` - Delete a wiki page from a GitLab group
605
- 112. `get_repository_tree` - Get the repository tree for a GitLab project (list files and directories)
606
- 113. `list_commits` - List repository commits with filtering options
607
- 114. `get_commit` - Get details of a specific commit
608
- 115. `get_commit_diff` - Get changes/diffs of a specific commit
609
- 116. `list_commit_statuses` - List statuses for a specific commit
610
- 117. `create_commit_status` - Create or update the status of a specific commit
611
- 118. `list_releases` - List all releases for a project
612
- 119. `get_release` - Get a release by tag name
613
- 120. `create_release` - Create a new release in a GitLab project
614
- 121. `update_release` - Update an existing release in a GitLab project
615
- 122. `delete_release` - Delete a release from a GitLab project (does not delete the associated tag)
616
- 123. `create_release_evidence` - Create release evidence for an existing release (GitLab Premium/Ultimate only)
617
- 124. `download_release_asset` - Download a release asset file by direct asset path
618
- 125. `list_tags` - List repository tags with filtering and pagination support
619
- 126. `get_tag` - Get details of a specific repository tag
620
- 127. `create_tag` - Create a new tag in the repository
621
- 128. `delete_tag` - Delete a tag from the repository
622
- 129. `get_tag_signature` - Get the signature of a signed tag
623
- 130. `get_users` - Get GitLab user details by usernames
624
- 131. `list_events` - List all events for the currently authenticated user
625
- 132. `get_project_events` - List all visible events for a specified project
626
- 133. `upload_markdown` - Upload a file to a GitLab project for use in markdown content
627
- 134. `download_attachment` - Download an uploaded file from a GitLab project by secret and filename
628
- 135. `get_work_item` - Get a single work item with full details including status, hierarchy (parent/children), type, labels, assignees, and all widgets
629
- 136. `list_work_items` - List work items in a project with filters (type, state, search, assignees, labels). Returns items with status and hierarchy info
630
- 137. `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
631
- 138. `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
632
- 139. `convert_work_item_type` - Convert a work item to a different type (e.g. issue to task, task to incident)
633
- 140. `list_work_item_statuses` - List available statuses for a work item type in a project. Requires GitLab Premium/Ultimate with configurable statuses
634
- 141. `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
635
- 142. `move_work_item` - Move a work item (issue, task, etc.) to a different project. Uses GitLab GraphQL issueMove mutation
636
- 143. `list_work_item_notes` - List notes and discussions on a work item. Returns threaded discussions with author, body, timestamps, and system/internal flags
637
- 144. `create_work_item_note` - Add a note/comment to a work item. Supports Markdown, internal notes, and threaded replies
638
- 145. `get_timeline_events` - List timeline events for an incident. Returns chronological events with notes, timestamps, and tags
639
- 146. `create_timeline_event` - Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'
640
- 147. `list_webhooks` - List all configured webhooks for a GitLab project or group. Provide either project_id or group_id
641
- 148. `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
642
- 149. `get_webhook_event` - Get full details of a specific webhook event by ID, including request/response payloads
643
- 150. `search_code` - Search for code across all projects on the GitLab instance (requires advanced search or exact code search to be enabled)
644
- 151. `search_project_code` - Search for code within a specific GitLab project (requires advanced search or exact code search to be enabled)
645
- 152. `search_group_code` - Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled)
646
- 153. `execute_graphql` - Execute a GitLab GraphQL query
647
- 154. `list_merge_request_pipelines` - List pipelines for a merge request with pagination support
543
+ 50. `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.
544
+ 51. `delete_issue` - Delete an issue from a GitLab project
545
+ 52. `list_todos` - List GitLab to-do items for the current user
546
+ 53. `mark_todo_done` - Mark a GitLab to-do item as done
547
+ 54. `mark_all_todos_done` - Mark all pending GitLab to-do items as done for the current user
548
+ 55. `list_issue_links` - List all issue links for a specific issue
549
+ 56. `list_issue_discussions` - List discussions for an issue in a GitLab project
550
+ 57. `get_issue_link` - Get a specific issue link
551
+ 58. `create_issue_link` - Create an issue link between two issues
552
+ 59. `delete_issue_link` - Delete an issue link
553
+ 60. `list_namespaces` - List all namespaces available to the current user
554
+ 61. `get_namespace` - Get details of a namespace by ID or path
555
+ 62. `verify_namespace` - Verify if a namespace path exists
556
+ 63. `get_project` - Get details of a specific project
557
+ 64. `list_projects` - List projects accessible by the current user
558
+ 65. `list_project_members` - List members of a GitLab project
559
+ 66. `list_group_projects` - List projects in a GitLab group with filtering options
560
+ 67. `list_group_iterations` - List group iterations with filtering options
561
+ 68. `list_labels` - List labels for a project
562
+ 69. `get_label` - Get a single label from a project
563
+ 70. `create_label` - Create a new label in a project
564
+ 71. `update_label` - Update an existing label in a project
565
+ 72. `delete_label` - Delete a label from a project
566
+ 73. `list_pipelines` - List pipelines in a GitLab project with filtering options
567
+ 74. `get_pipeline` - Get details of a specific pipeline in a GitLab project
568
+ 75. `list_pipeline_jobs` - List all jobs in a specific pipeline
569
+ 76. `list_pipeline_trigger_jobs` - List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines
570
+ 77. `get_pipeline_job` - Get details of a GitLab pipeline job number
571
+ 78. `get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage
572
+ 79. `validate_ci_lint` - Validate provided GitLab CI/CD YAML content for a project
573
+ 80. `validate_project_ci_lint` - Validate an existing `.gitlab-ci.yml` configuration for a project
574
+ 81. `create_pipeline` - Create a new pipeline for a branch or tag
575
+ 82. `retry_pipeline` - Retry a failed or canceled pipeline
576
+ 83. `cancel_pipeline` - Cancel a running pipeline
577
+ 84. `play_pipeline_job` - Run a manual pipeline job
578
+ 85. `retry_pipeline_job` - Retry a failed or canceled pipeline job
579
+ 86. `cancel_pipeline_job` - Cancel a running pipeline job
580
+ 87. `list_deployments` - List deployments in a GitLab project with filtering options
581
+ 88. `get_deployment` - Get details of a specific deployment in a GitLab project
582
+ 89. `list_environments` - List environments in a GitLab project
583
+ 90. `get_environment` - Get details of a specific environment in a GitLab project
584
+ 91. `list_job_artifacts` - List artifact files in a job's artifacts archive. Returns file names, paths, types, and sizes
585
+ 92. `download_job_artifacts` - Download the entire artifact archive (zip) for a job to a local path. Returns the saved file path
586
+ 93. `get_job_artifact_file` - Get the content of a single file from a job's artifacts by its path within the archive
587
+ 94. `list_milestones` - List milestones in a GitLab project with filtering options
588
+ 95. `get_milestone` - Get details of a specific milestone
589
+ 96. `create_milestone` - Create a new milestone in a GitLab project
590
+ 97. `edit_milestone` - Edit an existing milestone in a GitLab project
591
+ 98. `delete_milestone` - Delete a milestone from a GitLab project
592
+ 99. `get_milestone_issue` - Get issues associated with a specific milestone
593
+ 100. `get_milestone_merge_requests` - Get merge requests associated with a specific milestone
594
+ 101. `promote_milestone` - Promote a milestone to the next stage
595
+ 102. `get_milestone_burndown_events` - Get burndown events for a specific milestone
596
+ 103. `list_wiki_pages` - List wiki pages in a GitLab project
597
+ 104. `get_wiki_page` - Get details of a specific wiki page
598
+ 105. `create_wiki_page` - Create a new wiki page in a GitLab project
599
+ 106. `update_wiki_page` - Update an existing wiki page in a GitLab project
600
+ 107. `delete_wiki_page` - Delete a wiki page from a GitLab project
601
+ 108. `list_group_wiki_pages` - List wiki pages in a GitLab group
602
+ 109. `get_group_wiki_page` - Get details of a specific group wiki page
603
+ 110. `create_group_wiki_page` - Create a new wiki page in a GitLab group
604
+ 111. `update_group_wiki_page` - Update an existing wiki page in a GitLab group
605
+ 112. `delete_group_wiki_page` - Delete a wiki page from a GitLab group
606
+ 113. `get_repository_tree` - Get the repository tree for a GitLab project (list files and directories)
607
+ 114. `list_commits` - List repository commits with filtering options
608
+ 115. `get_commit` - Get details of a specific commit
609
+ 116. `get_commit_diff` - Get changes/diffs of a specific commit
610
+ 117. `list_commit_statuses` - List statuses for a specific commit
611
+ 118. `create_commit_status` - Create or update the status of a specific commit
612
+ 119. `list_releases` - List all releases for a project
613
+ 120. `get_release` - Get a release by tag name
614
+ 121. `create_release` - Create a new release in a GitLab project
615
+ 122. `update_release` - Update an existing release in a GitLab project
616
+ 123. `delete_release` - Delete a release from a GitLab project (does not delete the associated tag)
617
+ 124. `create_release_evidence` - Create release evidence for an existing release (GitLab Premium/Ultimate only)
618
+ 125. `download_release_asset` - Download a release asset file by direct asset path
619
+ 126. `list_tags` - List repository tags with filtering and pagination support
620
+ 127. `get_tag` - Get details of a specific repository tag
621
+ 128. `create_tag` - Create a new tag in the repository
622
+ 129. `delete_tag` - Delete a tag from the repository
623
+ 130. `get_tag_signature` - Get the signature of a signed tag
624
+ 131. `get_users` - Get GitLab user details by usernames
625
+ 132. `list_events` - List all events for the currently authenticated user
626
+ 133. `get_project_events` - List all visible events for a specified project
627
+ 134. `upload_markdown` - Upload a file to a GitLab project for use in markdown content
628
+ 135. `download_attachment` - Download an uploaded file from a GitLab project by secret and filename
629
+ 136. `get_work_item` - Get a single work item with full details including status, hierarchy (parent/children), type, labels, assignees, and all widgets
630
+ 137. `list_work_items` - List work items in a project with filters (type, state, search, assignees, labels). Returns items with status and hierarchy info
631
+ 138. `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
632
+ 139. `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
633
+ 140. `convert_work_item_type` - Convert a work item to a different type (e.g. issue to task, task to incident)
634
+ 141. `list_work_item_statuses` - List available statuses for a work item type in a project. Requires GitLab Premium/Ultimate with configurable statuses
635
+ 142. `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
636
+ 143. `move_work_item` - Move a work item (issue, task, etc.) to a different project. Uses GitLab GraphQL issueMove mutation
637
+ 144. `list_work_item_notes` - List notes and discussions on a work item. Returns threaded discussions with author, body, timestamps, and system/internal flags
638
+ 145. `create_work_item_note` - Add a note/comment to a work item. Supports Markdown, internal notes, and threaded replies
639
+ 146. `get_timeline_events` - List timeline events for an incident. Returns chronological events with notes, timestamps, and tags
640
+ 147. `create_timeline_event` - Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'
641
+ 148. `list_webhooks` - List all configured webhooks for a GitLab project or group. Provide either project_id or group_id
642
+ 149. `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
643
+ 150. `get_webhook_event` - Get full details of a specific webhook event by ID, including request/response payloads
644
+ 151. `search_code` - Search for code across all projects on the GitLab instance (requires advanced search or exact code search to be enabled)
645
+ 152. `search_project_code` - Search for code within a specific GitLab project (requires advanced search or exact code search to be enabled)
646
+ 153. `search_group_code` - Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled)
647
+ 154. `execute_graphql` - Execute a GitLab GraphQL query
648
+ 155. `list_merge_request_pipelines` - List pipelines for a merge request with pagination support
648
649
 
649
650
  <!-- TOOLS-END -->
650
651
 
package/build/index.js CHANGED
@@ -21,17 +21,18 @@ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
21
21
  import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
22
22
  import { normalizeGitLabApiUrl } from "./utils/url.js";
23
23
  import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents } from "./utils/helpers.js";
24
+ import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
24
25
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
25
26
  import { GitLabClientPool } from "./gitlab-client-pool.js";
26
27
  import { allTools, readOnlyTools, destructiveTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
27
28
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
28
- CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
29
+ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
29
30
  // Discussion Schemas
30
31
  GitLabDiscussionNoteSchema, // Added
31
32
  GitLabDiscussionSchema,
32
33
  // Draft Notes Schemas
33
- GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
34
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
34
+ GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
35
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
35
36
  import { randomUUID } from "node:crypto";
36
37
  import { pino } from "pino";
37
38
  const logger = pino({
@@ -6683,6 +6684,81 @@ async function handleToolCall(params) {
6683
6684
  content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
6684
6685
  };
6685
6686
  }
6687
+ case "update_issue_description_patch": {
6688
+ const args = UpdateIssueDescriptionPatchSchema.parse(params.arguments);
6689
+ const { project_id, issue_iid, patch_type, patch, dry_run, create_note, allow_multiple } = args;
6690
+ // Fetch current issue description
6691
+ const currentIssue = await getIssue(project_id, issue_iid);
6692
+ const currentDescription = currentIssue.description ?? "";
6693
+ // Apply the patch
6694
+ let result;
6695
+ if (patch_type === "search_replace") {
6696
+ const blocks = parseSearchReplaceBlocks(patch);
6697
+ if (blocks.length === 0) {
6698
+ throw new Error("No valid search/replace blocks found. Expected format: <<<<<<< SEARCH\\ntext\\n=======\\nnew text\\n>>>>>>> REPLACE");
6699
+ }
6700
+ result = applySearchReplace(currentDescription, blocks, allow_multiple);
6701
+ }
6702
+ else {
6703
+ // unified_diff
6704
+ result = applyUnifiedDiff(currentDescription, patch);
6705
+ }
6706
+ // Dry-run: return preview without updating
6707
+ if (dry_run) {
6708
+ return {
6709
+ content: [
6710
+ {
6711
+ type: "text",
6712
+ text: JSON.stringify({
6713
+ status: "preview",
6714
+ dry_run: true,
6715
+ changes: result.changes,
6716
+ summary: result.summary,
6717
+ preview: result.preview,
6718
+ }, null, 2),
6719
+ },
6720
+ ],
6721
+ };
6722
+ }
6723
+ // Apply the update
6724
+ const updatedIssue = await updateIssue(project_id, issue_iid, {
6725
+ description: result.description,
6726
+ });
6727
+ // Optionally create a note summarizing the change
6728
+ let noteResult = null;
6729
+ if (create_note) {
6730
+ try {
6731
+ const noteBody = `Updated issue description using patch-based tool.\n\n${result.summary}`;
6732
+ await createIssueNote(project_id, issue_iid, undefined, noteBody);
6733
+ noteResult = { status: "created" };
6734
+ }
6735
+ catch (noteError) {
6736
+ noteResult = {
6737
+ status: "failed",
6738
+ message: `Note creation failed: ${noteError.message ?? noteError}`,
6739
+ };
6740
+ }
6741
+ }
6742
+ return {
6743
+ content: [
6744
+ {
6745
+ type: "text",
6746
+ text: JSON.stringify({
6747
+ status: "success",
6748
+ changes: result.changes,
6749
+ summary: result.summary,
6750
+ note: noteResult,
6751
+ issue: {
6752
+ iid: updatedIssue.iid,
6753
+ title: updatedIssue.title,
6754
+ web_url: updatedIssue.web_url,
6755
+ updated_at: updatedIssue.updated_at,
6756
+ },
6757
+ }, null, 2),
6758
+ },
6759
+ ],
6760
+ };
6761
+ }
6686
6762
  case "delete_issue": {
6687
6763
  const args = DeleteIssueSchema.parse(params.arguments);
6688
6764
  await deleteIssue(args.project_id, args.issue_iid);
@@ -7612,6 +7688,56 @@ async function handleToolCall(params) {
7612
7688
  content: [{ type: "text", text: JSON.stringify({ status: authenticated ? "ok" : "error", authenticated, gitlab_url: getEffectiveApiUrl() }) }],
7613
7689
  };
7614
7690
  }
7691
+ case "get_branch": {
7692
+ const args = GetBranchSchema.parse(params.arguments);
7693
+ const projectId = decodeURIComponent(args.project_id);
7694
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches/${encodeURIComponent(args.branch_name)}`);
7695
+ const response = await fetch(url.toString(), {
7696
+ ...getFetchConfig(),
7697
+ });
7698
+ await handleGitLabError(response);
7699
+ const data = await response.json();
7700
+ const branch = GitLabBranchSchema.parse(data);
7701
+ return {
7702
+ content: [{ type: "text", text: JSON.stringify(branch, null, 2) }],
7703
+ };
7704
+ }
7705
+ case "list_branches": {
7706
+ const args = ListBranchesSchema.parse(params.arguments);
7707
+ const projectId = decodeURIComponent(args.project_id);
7708
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches`);
7709
+ if (args.search) {
7710
+ url.searchParams.append("search", args.search);
7711
+ }
7712
+ if (args.page) {
7713
+ url.searchParams.append("page", args.page.toString());
7714
+ }
7715
+ if (args.per_page) {
7716
+ url.searchParams.append("per_page", args.per_page.toString());
7717
+ }
7718
+ const response = await fetch(url.toString(), {
7719
+ ...getFetchConfig(),
7720
+ });
7721
+ await handleGitLabError(response);
7722
+ const data = await response.json();
7723
+ const branches = z.array(GitLabBranchSchema).parse(data);
7724
+ return {
7725
+ content: [{ type: "text", text: JSON.stringify(branches, null, 2) }],
7726
+ };
7727
+ }
7728
+ case "delete_branch": {
7729
+ const args = DeleteBranchSchema.parse(params.arguments);
7730
+ const projectId = decodeURIComponent(args.project_id);
7731
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches/${encodeURIComponent(args.branch_name)}`);
7732
+ const response = await fetch(url.toString(), {
7733
+ ...getFetchConfig(),
7734
+ method: "DELETE",
7735
+ });
7736
+ await handleGitLabError(response);
7737
+ return {
7738
+ content: [{ type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }, null, 2) }],
7739
+ };
7740
+ }
7615
7741
  default:
7616
7742
  throw new Error(`Unknown tool: ${params.name}`);
7617
7743
  }
package/build/schemas.js CHANGED
@@ -1472,6 +1472,37 @@ export const CreateBranchSchema = ProjectParamsSchema.extend({
1472
1472
  branch: z.string().describe("Name for the new branch"),
1473
1473
  ref: z.string().optional().describe("Source branch/commit for new branch"),
1474
1474
  });
1475
+ export const GetBranchSchema = ProjectParamsSchema.extend({
1476
+ branch_name: z.string().describe("Name of the branch"),
1477
+ });
1478
+ export const ListBranchesSchema = ProjectParamsSchema.extend({
1479
+ search: z.string().optional().describe("Search term to filter branches by name"),
1480
+ }).merge(PaginationOptionsSchema);
1481
+ export const DeleteBranchSchema = ProjectParamsSchema.extend({
1482
+ branch_name: z.string().describe("Name of the branch to delete"),
1483
+ });
1484
+ export const GitLabBranchSchema = z.object({
1485
+ name: z.string(),
1486
+ commit: z.object({
1487
+ id: z.string(),
1488
+ short_id: z.string(),
1489
+ title: z.string(),
1490
+ author_name: z.string(),
1491
+ author_email: z.string(),
1492
+ authored_date: z.string(),
1493
+ committer_name: z.string(),
1494
+ committer_email: z.string(),
1495
+ committed_date: z.string(),
1496
+ web_url: z.string(),
1497
+ }),
1498
+ merged: z.boolean(),
1499
+ protected: z.boolean(),
1500
+ developers_can_push: z.boolean(),
1501
+ developers_can_merge: z.boolean(),
1502
+ can_push: z.boolean(),
1503
+ default: z.boolean(),
1504
+ web_url: z.string().optional(),
1505
+ });
1475
1506
  export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
1476
1507
  from: z.string().describe("The base branch or commit SHA to compare from"),
1477
1508
  to: z.string().describe("The target branch or commit SHA to compare to"),
@@ -1839,6 +1870,22 @@ export const DeleteIssueSchema = z.object({
1839
1870
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1840
1871
  issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
1841
1872
  });
1873
+ export const UpdateIssueDescriptionPatchSchema = z.object({
1874
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1875
+ issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
1876
+ patch_type: z.enum(["search_replace", "unified_diff"]).describe("Type of patch format to apply"),
1877
+ patch: z
1878
+ .string()
1879
+ .min(1)
1880
+ .max(50000)
1881
+ .describe("The patch content to apply to the issue description"),
1882
+ dry_run: z.coerce.boolean().optional().describe("If true, preview changes without updating the issue"),
1883
+ create_note: z.coerce.boolean().optional().describe("If true, add a note summarizing the change after update"),
1884
+ allow_multiple: z
1885
+ .coerce.boolean()
1886
+ .optional()
1887
+ .describe("For search_replace: allow multiple matches to all be replaced (default: false — fail on duplicate)"),
1888
+ });
1842
1889
  // Issue links related schemas
1843
1890
  export const GitLabIssueLinkSchema = z.object({
1844
1891
  source_issue: GitLabIssueSchema,
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Tests for the update_issue_description_patch tool.
3
+ * Tests search_replace, unified_diff, dry_run, create_note, and edge cases.
4
+ */
5
+ import { describe, test, before, after } from "node:test";
6
+ import assert from "node:assert";
7
+ import { launchServer, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
8
+ import { MockGitLabServer, findMockServerPort, } from "./utils/mock-gitlab-server.js";
9
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
10
+ import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "../utils/patch-helper.js";
11
+ const MOCK_TOKEN = "glpat-patch-test-token-12345";
12
+ // ---- Unit tests for patch helper ----
13
+ describe("parseSearchReplaceBlocks", () => {
14
+ test("parses a single block", () => {
15
+ const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE");
16
+ assert.strictEqual(blocks.length, 1);
17
+ assert.strictEqual(blocks[0].search, "old text");
18
+ assert.strictEqual(blocks[0].replace, "new text");
19
+ });
20
+ test("parses multiple blocks", () => {
21
+ const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\nfirst\n=======\nfirst new\n>>>>>>> REPLACE\n<<<<<<< SEARCH\nsecond\n=======\nsecond new\n>>>>>>> REPLACE");
22
+ assert.strictEqual(blocks.length, 2);
23
+ });
24
+ test("handles empty content", () => {
25
+ const blocks = parseSearchReplaceBlocks("");
26
+ assert.strictEqual(blocks.length, 0);
27
+ });
28
+ test("ignores text outside blocks", () => {
29
+ const blocks = parseSearchReplaceBlocks("prefix\n<<<<<<< SEARCH\nx\n=======\ny\n>>>>>>> REPLACE\nsuffix");
30
+ assert.strictEqual(blocks.length, 1);
31
+ });
32
+ test("rejects malformed block with missing REPLACE marker", () => {
33
+ assert.throws(() => parseSearchReplaceBlocks("<<<<<<< SEARCH\nfirst\n=======\nfirst new\n>>>>>>> REPLACE\n" +
34
+ "<<<<<<< SEARCH\nsecond\n=======\nsecond new\n>>>>>>> TYPO"), /malformed|Marker|marker/);
35
+ });
36
+ test("rejects block with missing ======= marker", () => {
37
+ assert.throws(() => parseSearchReplaceBlocks("<<<<<<< SEARCH\nfoo\n\nbar\n>>>>>>> REPLACE"), /malformed|Marker|marker/);
38
+ });
39
+ test("allows prose around valid blocks", () => {
40
+ const blocks = parseSearchReplaceBlocks("# Some notes\n\n<<<<<<< SEARCH\nfoo\n=======\nbar\n>>>>>>> REPLACE\n\nMore context");
41
+ assert.strictEqual(blocks.length, 1);
42
+ });
43
+ });
44
+ describe("applySearchReplace", () => {
45
+ test("replaces single occurrence", () => {
46
+ const result = applySearchReplace("Status: In progress\nDone.", [
47
+ { search: "Status: In progress", replace: "Status: Done" },
48
+ ]);
49
+ assert.strictEqual(result.description, "Status: Done\nDone.");
50
+ assert.strictEqual(result.changes, 1);
51
+ assert.ok(result.summary.includes("Status: In progress"));
52
+ assert.ok(result.preview.includes("-Status: In progress"));
53
+ });
54
+ test("fails on no match", () => {
55
+ assert.throws(() => applySearchReplace("Some text.", [{ search: "Nonexistent", replace: "x" }]), /Search text not found/);
56
+ });
57
+ test("rejects empty SEARCH body", () => {
58
+ assert.throws(() => applySearchReplace("Some text.", [{ search: "", replace: "x" }], true), /Empty SEARCH/);
59
+ });
60
+ test("fails on duplicate without allowMultiple", () => {
61
+ assert.throws(() => applySearchReplace("x\ny\nx\n", [{ search: "x", replace: "z" }]), /matches 2 times/);
62
+ });
63
+ test("replaces all with allowMultiple", () => {
64
+ const result = applySearchReplace("x\ny\nx\n", [{ search: "x", replace: "z" }], true);
65
+ assert.strictEqual(result.changes, 2);
66
+ assert.strictEqual(result.description, "z\ny\nz\n");
67
+ });
68
+ test("fails on identical replacement", () => {
69
+ assert.throws(() => applySearchReplace("Keep this.", [{ search: "Keep this.", replace: "Keep this." }]), /did not change/);
70
+ });
71
+ test("preserves leading blank line in SEARCH block", () => {
72
+ const source = "\n\nStatus: In progress\n";
73
+ const result = applySearchReplace(source, [
74
+ { search: "\n\nStatus: In progress", replace: "\n\nStatus: Done" },
75
+ ]);
76
+ assert.strictEqual(result.description, "\n\nStatus: Done\n");
77
+ });
78
+ test("preserves leading blank line when patch starts with blank line", () => {
79
+ const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\n\nfoo\n=======\n\nbar\n>>>>>>> REPLACE");
80
+ assert.strictEqual(blocks.length, 1);
81
+ assert.strictEqual(blocks[0].search, "\nfoo");
82
+ assert.strictEqual(blocks[0].replace, "\nbar");
83
+ });
84
+ test("replacement with leading blank line works", () => {
85
+ const result = applySearchReplace("Header\n\nContent\n", [
86
+ { search: "Content", replace: "\nNewContent" },
87
+ ]);
88
+ assert.strictEqual(result.description, "Header\n\n\nNewContent\n");
89
+ });
90
+ });
91
+ describe("applyUnifiedDiff", () => {
92
+ test("applies simple diff", () => {
93
+ const source = "Line 1\nLine 2\nLine 3\n";
94
+ const patch = "--- old\n+++ new\n@@ -1,3 +1,3 @@\n Line 1\n-Line 2\n+Line 2 modified\n Line 3\n";
95
+ const result = applyUnifiedDiff(source, patch);
96
+ assert.ok(result.description.includes("Line 2 modified"));
97
+ });
98
+ test("fails on non-matching diff", () => {
99
+ const source = "AAA\nBBB\n";
100
+ const patch = "--- old\n+++ new\n@@ -1,2 +1,2 @@\n-XXX\n+YYY\n BBB\n";
101
+ assert.throws(() => applyUnifiedDiff(source, patch), /could not be applied/);
102
+ });
103
+ test("fails on malformed patch", () => {
104
+ assert.throws(() => applyUnifiedDiff("text", "not a patch"), /no valid hunks/i);
105
+ });
106
+ });
107
+ // ---- Integration tests via MCP client ----
108
+ describe("update_issue_description_patch MCP tool", () => {
109
+ let mockGitLab;
110
+ let server;
111
+ let client;
112
+ const MOCK_PORT_BASE = 9600;
113
+ const MCP_PORT_BASE = 3600;
114
+ let portCounter = 0;
115
+ async function launchMcp(mockGitLabUrl, extraEnv = {}) {
116
+ const port = MCP_PORT_BASE + portCounter++ * 10;
117
+ return launchServer({
118
+ mode: TransportMode.STREAMABLE_HTTP,
119
+ port,
120
+ timeout: 10000,
121
+ env: {
122
+ STREAMABLE_HTTP: "true",
123
+ REMOTE_AUTHORIZATION: "true",
124
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
125
+ GITLAB_ACCESS_TOKEN: MOCK_TOKEN,
126
+ ...extraEnv,
127
+ },
128
+ });
129
+ }
130
+ async function getClient(port) {
131
+ const client = new CustomHeaderClient({
132
+ headers: {
133
+ authorization: `Bearer ${MOCK_TOKEN}`,
134
+ },
135
+ });
136
+ await client.connect(`http://${HOST}:${port}/mcp`);
137
+ return client;
138
+ }
139
+ before(async () => {
140
+ const mockPort = await findMockServerPort(MOCK_PORT_BASE);
141
+ mockGitLab = new MockGitLabServer({
142
+ port: mockPort,
143
+ validTokens: [MOCK_TOKEN],
144
+ });
145
+ await mockGitLab.start();
146
+ const mockGitLabUrl = mockGitLab.getUrl();
147
+ server = await launchMcp(mockGitLabUrl, { GITLAB_TOOLSETS: "issues" });
148
+ client = await getClient(server.port ?? 0);
149
+ });
150
+ after(async () => {
151
+ await client?.disconnect();
152
+ cleanupServers([server]);
153
+ await mockGitLab?.stop();
154
+ });
155
+ test("tool appears in tool list", async () => {
156
+ const result = await client.listTools();
157
+ const names = result.tools.map((t) => t.name);
158
+ assert.ok(names.includes("update_issue_description_patch"), "tool should be in list");
159
+ });
160
+ test("dry_run: search_replace returns preview without modifying", async () => {
161
+ // Get current description
162
+ const getResult = await client.callTool("get_issue", {
163
+ project_id: "test/project",
164
+ issue_iid: "1",
165
+ });
166
+ const issue = JSON.parse(getResult.content[0]?.text || "{}");
167
+ const originalDesc = issue.description;
168
+ // Dry-run
169
+ const result = await client.callTool("update_issue_description_patch", {
170
+ project_id: "test/project",
171
+ issue_iid: "1",
172
+ patch_type: "search_replace",
173
+ patch: `<<<<<<< SEARCH\n${originalDesc}\n=======\nShould NOT persist\n>>>>>>> REPLACE`,
174
+ dry_run: true,
175
+ });
176
+ const data = JSON.parse(result.content[0]?.text || "{}");
177
+ assert.strictEqual(data.status, "preview");
178
+ assert.strictEqual(data.dry_run, true);
179
+ assert.strictEqual(data.changes, 1);
180
+ // Verify NOT updated
181
+ const getAgain = await client.callTool("get_issue", {
182
+ project_id: "test/project",
183
+ issue_iid: "1",
184
+ });
185
+ const issueAgain = JSON.parse(getAgain.content[0]?.text || "{}");
186
+ assert.strictEqual(issueAgain.description, originalDesc, "should be unchanged after dry_run");
187
+ });
188
+ test("search_replace: applies the patch", async () => {
189
+ const result = await client.callTool("update_issue_description_patch", {
190
+ project_id: "test/project",
191
+ issue_iid: "1",
192
+ patch_type: "search_replace",
193
+ patch: `<<<<<<< SEARCH\nDescription for issue 1\n=======\nPatched description\n>>>>>>> REPLACE`,
194
+ });
195
+ const data = JSON.parse(result.content[0]?.text || "{}");
196
+ assert.strictEqual(data.status, "success");
197
+ assert.strictEqual(data.changes, 1);
198
+ // Verify persisted
199
+ const getResult = await client.callTool("get_issue", {
200
+ project_id: "test/project",
201
+ issue_iid: "1",
202
+ });
203
+ const issue = JSON.parse(getResult.content[0]?.text || "{}");
204
+ assert.strictEqual(issue.description, "Patched description");
205
+ });
206
+ test("create_note: note is attempted after update", async () => {
207
+ const result = await client.callTool("update_issue_description_patch", {
208
+ project_id: "test/project",
209
+ issue_iid: "1",
210
+ patch_type: "search_replace",
211
+ patch: `<<<<<<< SEARCH\nPatched description\n=======\nDescription with note\n>>>>>>> REPLACE`,
212
+ create_note: true,
213
+ });
214
+ const data = JSON.parse(result.content[0]?.text || "{}");
215
+ assert.strictEqual(data.status, "success");
216
+ assert.ok(data.note !== undefined, "note result should be present");
217
+ });
218
+ test("search_replace: fails on no match", async () => {
219
+ try {
220
+ await client.callTool("update_issue_description_patch", {
221
+ project_id: "test/project",
222
+ issue_iid: "1",
223
+ patch_type: "search_replace",
224
+ patch: `<<<<<<< SEARCH\nNonExistentText_{UNIQUE}_\n=======\nShould fail\n>>>>>>> REPLACE`,
225
+ });
226
+ assert.fail("Should have thrown");
227
+ }
228
+ catch (err) {
229
+ const msg = err.message ?? String(err);
230
+ assert.ok(msg.includes("not found"), `Error should mention 'not found': ${msg}`);
231
+ }
232
+ });
233
+ test("unified_diff: applies the patch", async () => {
234
+ // Set known state
235
+ await client.callTool("update_issue_description_patch", {
236
+ project_id: "test/project",
237
+ issue_iid: "1",
238
+ patch_type: "search_replace",
239
+ patch: `<<<<<<< SEARCH\nDescription with note\n=======\nLine 1\nLine 2\nLine 3\n>>>>>>> REPLACE`,
240
+ });
241
+ const result = await client.callTool("update_issue_description_patch", {
242
+ project_id: "test/project",
243
+ issue_iid: "1",
244
+ patch_type: "unified_diff",
245
+ patch: "--- old\n+++ new\n@@ -1,3 +1,3 @@\n Line 1\n-Line 2\n+Line 2 changed\n Line 3\n",
246
+ });
247
+ const data = JSON.parse(result.content[0]?.text || "{}");
248
+ assert.strictEqual(data.status, "success");
249
+ const getResult = await client.callTool("get_issue", {
250
+ project_id: "test/project",
251
+ issue_iid: "1",
252
+ });
253
+ const issue = JSON.parse(getResult.content[0]?.text || "{}");
254
+ assert.ok(issue.description.includes("Line 2 changed"));
255
+ });
256
+ });
@@ -389,7 +389,7 @@ describe("Policy Edge Cases", { concurrency: 1 }, () => {
389
389
  test("hiding all toolset tools leaves only discover_tools", async () => {
390
390
  const allIssueTools = [
391
391
  "create_issue", "list_issues", "my_issues", "get_issue",
392
- "update_issue", "delete_issue", "create_issue_note", "update_issue_note",
392
+ "update_issue", "update_issue_description_patch", "delete_issue", "list_todos", "mark_todo_done", "mark_all_todos_done", "create_issue_note", "update_issue_note",
393
393
  "list_issue_links", "list_issue_discussions", "get_issue_link",
394
394
  "create_issue_link", "delete_issue_link", "create_note",
395
395
  "list_issue_emoji_reactions", "list_issue_note_emoji_reactions",
@@ -17,9 +17,9 @@ const MCP_PORT_BASE = 3200;
17
17
  // Known tool counts per toolset (from TOOLSET_DEFINITIONS)
18
18
  const TOOLSET_TOOL_COUNTS = {
19
19
  merge_requests: 41,
20
- issues: 23,
20
+ issues: 24,
21
21
  repositories: 7,
22
- branches: 6,
22
+ branches: 9,
23
23
  projects: 9,
24
24
  labels: 5,
25
25
  ci: 2,
@@ -63,7 +63,7 @@ const TOOLSET_SAMPLE_TOOLS = {
63
63
  merge_requests: ["merge_merge_request", "create_merge_request_thread", "list_draft_notes"],
64
64
  issues: ["create_issue", "list_issues", "create_note", "list_todos"],
65
65
  repositories: ["search_repositories", "get_file_contents", "push_files"],
66
- branches: ["create_branch", "list_commits", "list_commit_statuses", "create_commit_status"],
66
+ branches: ["create_branch", "get_branch", "list_branches", "delete_branch", "list_commits", "list_commit_statuses", "create_commit_status"],
67
67
  projects: ["get_project", "list_namespaces", "list_group_iterations"],
68
68
  labels: ["list_labels", "create_label"],
69
69
  ci: ["validate_ci_lint", "validate_project_ci_lint"],
@@ -308,6 +308,7 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
308
308
  const writeIssueTools = [
309
309
  "create_issue",
310
310
  "update_issue",
311
+ "update_issue_description_patch",
311
312
  "delete_issue",
312
313
  "mark_todo_done",
313
314
  "mark_all_todos_done",
@@ -13,6 +13,8 @@ export class MockGitLabServer {
13
13
  // Root-level dynamic router (for OAuth paths not under /api/v4)
14
14
  rootRouter;
15
15
  rootHandlers = new Map();
16
+ // In-memory store for mutable resources (issues, etc.)
17
+ issueStore = new Map();
16
18
  constructor(config) {
17
19
  this.config = config;
18
20
  this.app = express();
@@ -314,6 +316,12 @@ export class MockGitLabServer {
314
316
  this.app.get("/api/v4/projects/:projectId/issues/:issue_iid", (req, res) => {
315
317
  const issueIid = parseInt(req.params.issue_iid);
316
318
  const projectId = req.params.projectId;
319
+ const storeKey = `${projectId}:${issueIid}`;
320
+ const stored = this.issueStore.get(storeKey);
321
+ if (stored) {
322
+ res.json(stored);
323
+ return;
324
+ }
317
325
  res.json({
318
326
  id: issueIid,
319
327
  iid: issueIid,
@@ -337,6 +345,44 @@ export class MockGitLabServer {
337
345
  milestone: null,
338
346
  });
339
347
  });
348
+ // PUT /api/v4/projects/:projectId/issues/:issue_iid - Update issue
349
+ this.app.put("/api/v4/projects/:projectId/issues/:issue_iid", (req, res) => {
350
+ const issueIid = parseInt(req.params.issue_iid);
351
+ const projectId = req.params.projectId;
352
+ const storeKey = `${projectId}:${issueIid}`;
353
+ // Build response from stored data (if previously updated) or defaults
354
+ const stored = this.issueStore.get(storeKey) || {};
355
+ const description = req.body?.description ?? stored.description ?? `Description for issue ${issueIid}`;
356
+ const title = req.body?.title ?? stored.title ?? `Test Issue ${issueIid}`;
357
+ const state = req.body?.state_event === "close" ? "closed" :
358
+ req.body?.state_event === "reopen" ? "opened" :
359
+ (stored.state ?? "opened");
360
+ const updatedIssue = {
361
+ id: issueIid,
362
+ iid: issueIid,
363
+ project_id: projectId,
364
+ title,
365
+ description,
366
+ state,
367
+ created_at: stored.created_at ?? "2024-01-01T00:00:00Z",
368
+ updated_at: new Date().toISOString(),
369
+ closed_at: state === "closed" ? new Date().toISOString() : null,
370
+ web_url: `https://gitlab.mock/project/${projectId}/issues/${issueIid}`,
371
+ author: {
372
+ id: 1,
373
+ username: "test-user",
374
+ name: "Test User",
375
+ avatar_url: null,
376
+ web_url: "https://gitlab.mock/test-user",
377
+ },
378
+ assignees: [],
379
+ labels: [],
380
+ milestone: null,
381
+ };
382
+ // Store for subsequent GET requests
383
+ this.issueStore.set(storeKey, updatedIssue);
384
+ res.json(updatedIssue);
385
+ });
340
386
  // Mock blob search result
341
387
  const mockBlobResults = [
342
388
  {
@@ -1,7 +1,7 @@
1
1
  import { zodToJsonSchema } from "zod-to-json-schema";
2
2
  import { toJSONSchema } from "../utils/schema.js";
3
3
  import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, } from "../config.js";
4
- import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, } from "../schemas.js";
4
+ import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, } from "../schemas.js";
5
5
  // Define all available tools
6
6
  export const allTools = [
7
7
  {
@@ -84,6 +84,21 @@ export const allTools = [
84
84
  description: "Create a new branch",
85
85
  inputSchema: toJSONSchema(CreateBranchSchema),
86
86
  },
87
+ {
88
+ name: "get_branch",
89
+ description: "Get branch details (commit, protection status)",
90
+ inputSchema: toJSONSchema(GetBranchSchema),
91
+ },
92
+ {
93
+ name: "list_branches",
94
+ description: "List branches in project with search filter",
95
+ inputSchema: toJSONSchema(ListBranchesSchema),
96
+ },
97
+ {
98
+ name: "delete_branch",
99
+ description: "Delete branch from project",
100
+ inputSchema: toJSONSchema(DeleteBranchSchema),
101
+ },
87
102
  {
88
103
  name: "get_merge_request",
89
104
  description: "Get details of a merge request (mergeRequestIid or branchName required)",
@@ -316,6 +331,13 @@ export const allTools = [
316
331
  description: "Update an issue",
317
332
  inputSchema: toJSONSchema(UpdateIssueSchema),
318
333
  },
334
+ {
335
+ name: "update_issue_description_patch",
336
+ description: "Apply a patch (search/replace or unified diff) to an issue description. " +
337
+ "Reduces token usage by allowing small changes without sending the full description. " +
338
+ "Supports dry_run to preview changes and create_note to summarize updates.",
339
+ inputSchema: toJSONSchema(UpdateIssueDescriptionPatchSchema),
340
+ },
319
341
  {
320
342
  name: "delete_issue",
321
343
  description: "Delete an issue",
@@ -916,6 +938,8 @@ export const readOnlyTools = new Set([
916
938
  "get_merge_request_file_diff",
917
939
  "list_merge_request_versions",
918
940
  "get_merge_request_version",
941
+ "get_branch",
942
+ "list_branches",
919
943
  "get_branch_diffs",
920
944
  "list_merge_request_pipelines",
921
945
  "get_merge_request_note",
@@ -1020,8 +1044,10 @@ export const destructiveTools = new Set([
1020
1044
  "delete_issue_note_emoji_reaction",
1021
1045
  "delete_work_item_emoji_reaction",
1022
1046
  "delete_work_item_note_emoji_reaction",
1047
+ "delete_branch",
1023
1048
  "merge_merge_request",
1024
1049
  "push_files",
1050
+ "delete_branch",
1025
1051
  ]);
1026
1052
  // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
1027
1053
  export const wikiToolNames = new Set([
@@ -1082,6 +1108,8 @@ export const TOOLSET_DEFINITIONS = [
1082
1108
  "approve_merge_request",
1083
1109
  "unapprove_merge_request",
1084
1110
  "get_merge_request_approval_state",
1111
+ "get_branch",
1112
+ "list_branches",
1085
1113
  "get_merge_request_conflicts",
1086
1114
  "list_merge_request_pipelines",
1087
1115
  "get_merge_request",
@@ -1130,6 +1158,7 @@ export const TOOLSET_DEFINITIONS = [
1130
1158
  "my_issues",
1131
1159
  "get_issue",
1132
1160
  "update_issue",
1161
+ "update_issue_description_patch",
1133
1162
  "delete_issue",
1134
1163
  "list_todos",
1135
1164
  "mark_todo_done",
@@ -1168,6 +1197,9 @@ export const TOOLSET_DEFINITIONS = [
1168
1197
  isDefault: true,
1169
1198
  tools: new Set([
1170
1199
  "create_branch",
1200
+ "get_branch",
1201
+ "list_branches",
1202
+ "delete_branch",
1171
1203
  "list_commits",
1172
1204
  "get_commit",
1173
1205
  "get_commit_diff",
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Patch helper for issue description updates.
3
+ * Supports two patch formats:
4
+ * - search_replace: exact text search/replace blocks
5
+ * - unified_diff: standard unified diff via the `diff` library
6
+ */
7
+ import { applyPatch, createTwoFilesPatch, parsePatch } from "diff";
8
+ /**
9
+ * Parse a search/replace patch string into blocks.
10
+ * Format:
11
+ * <<<<<<< SEARCH
12
+ * text to find
13
+ * =======
14
+ * text to replace with
15
+ * >>>>>>> REPLACE
16
+ *
17
+ * Supports multiple blocks.
18
+ */
19
+ export function parseSearchReplaceBlocks(patch) {
20
+ const blocks = [];
21
+ // Match SEARCH...REPLACE blocks (greedy multiline)
22
+ const regex = /<<<<<<< SEARCH[^\S\n]*\n([\s\S]*?)=======[^\S\n]*\n([\s\S]*?)>>>>>>> REPLACE[^\S\n]*/g;
23
+ let match;
24
+ while ((match = regex.exec(patch)) !== null) {
25
+ let search = match[1];
26
+ let replace = match[2];
27
+ // Trim trailing newline from each side (the \n before ======= and before >>>>>>>)
28
+ if (search.endsWith("\n"))
29
+ search = search.slice(0, -1);
30
+ if (replace.endsWith("\n"))
31
+ replace = replace.slice(0, -1);
32
+ blocks.push({ search, replace });
33
+ }
34
+ // Detect malformed blocks: check if SEARCH/REPLACE markers exist but weren't captured
35
+ const searchMarkers = (patch.match(/<<<<<<<\s+SEARCH/g) || []).length;
36
+ const replaceMarkers = (patch.match(/>>>>>>>\s+REPLACE/g) || []).length;
37
+ if (searchMarkers !== blocks.length || replaceMarkers !== blocks.length) {
38
+ throw new Error(`Found ${searchMarkers} SEARCH marker(s) and ${replaceMarkers} REPLACE marker(s), ` +
39
+ `but only parsed ${blocks.length} valid block(s). ` +
40
+ "Some blocks may be malformed (e.g. missing ======= or >>>>>>> REPLACE). " +
41
+ "Each block must follow the exact format: <<<<<<< SEARCH\ntext\n=======\nnew text\n>>>>>>> REPLACE");
42
+ }
43
+ return blocks;
44
+ }
45
+ /**
46
+ * Apply search/replace blocks to source text.
47
+ *
48
+ * @param source - Current text
49
+ * @param blocks - Search/replace blocks
50
+ * @param allowMultiple - If true, replace all occurrences; if false, fail on duplicate match
51
+ * @returns PatchResult or throws on error
52
+ */
53
+ export function applySearchReplace(source, blocks, allowMultiple = false) {
54
+ let current = source;
55
+ let totalChanges = 0;
56
+ const changeLines = [];
57
+ for (const block of blocks) {
58
+ // Empty SEARCH body would produce an empty regex that corrupts the description
59
+ if (block.search.length === 0) {
60
+ throw new Error("Empty SEARCH block is not allowed. " +
61
+ "Each SEARCH block must contain the text to find. " +
62
+ "Block:\n---\n" + block.replace.slice(0, 200) + "\n---");
63
+ }
64
+ // Count occurrences
65
+ const escapedSearch = escapeRegex(block.search);
66
+ const regex = new RegExp(escapedSearch, "g");
67
+ const occurrences = current.match(regex);
68
+ if (!occurrences || occurrences.length === 0) {
69
+ throw new Error(`Search text not found in issue description. Search block:\n---\n${block.search.slice(0, 200)}${block.search.length > 200 ? "..." : ""}\n---`);
70
+ }
71
+ if (occurrences.length > 1 && !allowMultiple) {
72
+ throw new Error(`Search text matches ${occurrences.length} times (expected exactly 1). ` +
73
+ "Use 'allow_multiple: true' to replace all occurrences.\n" +
74
+ `Search block:\n---\n${block.search.slice(0, 200)}${block.search.length > 200 ? "..." : ""}\n---`);
75
+ }
76
+ // Apply replacement
77
+ const replacement = block.replace;
78
+ const replaced = current.replace(regex, () => replacement);
79
+ // Detect no-op
80
+ if (replaced === current) {
81
+ throw new Error(`Replacement did not change the description (identical result). Search block:\n---\n${block.search.slice(0, 200)}${block.search.length > 200 ? "..." : ""}\n---`);
82
+ }
83
+ const count = occurrences.length;
84
+ totalChanges += count;
85
+ changeLines.push(`Replaced ${count} occurrence(s): "${truncate(block.search, 60)}" → "${truncate(block.replace, 60)}"`);
86
+ current = replaced;
87
+ }
88
+ const summary = changeLines.join("\n");
89
+ // Generate preview diff
90
+ const preview = createTwoFilesPatch("current", "updated", source, current);
91
+ return {
92
+ description: current,
93
+ changes: totalChanges,
94
+ summary,
95
+ preview,
96
+ };
97
+ }
98
+ /**
99
+ * Apply a unified diff patch to source text.
100
+ * Delegates to the `diff` library's applyPatch.
101
+ */
102
+ export function applyUnifiedDiff(source, patch) {
103
+ // Validate the patch can be parsed first
104
+ const parsed = parsePatch(patch);
105
+ if (parsed.length === 0 || parsed.every((p) => p.hunks.length === 0)) {
106
+ throw new Error("Could not parse unified diff: no valid hunks found. " +
107
+ "Expected format: '--- old\\n+++ new\\n@@ -line,count +line,count @@\\n context\\n-old\\n+new\\n'");
108
+ }
109
+ const result = applyPatch(source, patch);
110
+ if (result === false) {
111
+ throw new Error("Unified diff could not be applied to the current issue description. " +
112
+ "The diff context may not match. Use 'dry_run: true' to debug.");
113
+ }
114
+ // Detect no-op: patch applied but nothing changed
115
+ if (result === source) {
116
+ throw new Error("Unified diff applied but did not change the issue description. " +
117
+ "The source text already matches the patched result.");
118
+ }
119
+ const changes = parsed.reduce((sum, p) => sum + p.hunks.reduce((hSum, h) => hSum + h.lines.filter((l) => l.startsWith("-") || l.startsWith("+")).length, 0), 0);
120
+ // Generate preview of what actually changed
121
+ const preview = createTwoFilesPatch("current", "updated", source, result);
122
+ // Build summary from hunk headers
123
+ const summaryLines = [];
124
+ for (const p of parsed) {
125
+ for (const hunk of p.hunks) {
126
+ const added = hunk.lines.filter((l) => l.startsWith("+")).length;
127
+ const removed = hunk.lines.filter((l) => l.startsWith("-")).length;
128
+ summaryLines.push(`Hunk at line ${hunk.oldStart}: ${removed} removed, ${added} added`);
129
+ }
130
+ }
131
+ return {
132
+ description: result,
133
+ changes,
134
+ summary: summaryLines.join("\n"),
135
+ preview,
136
+ };
137
+ }
138
+ function escapeRegex(str) {
139
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
140
+ }
141
+ function truncate(str, maxLen) {
142
+ if (str.length <= maxLen)
143
+ return str;
144
+ return str.slice(0, maxLen) + "...";
145
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.11",
3
+ "version": "2.1.12",
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": [
@@ -51,7 +51,7 @@
51
51
  "changelog": "auto-changelog -p",
52
52
  "test": "npm run test:all",
53
53
  "test:all": "npm run build && npm run test:mock && npm run test:live",
54
- "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
54
+ "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
55
55
  "test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
56
56
  "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
57
57
  "test:live": "node test/validate-api.js",
@@ -70,6 +70,7 @@
70
70
  "dependencies": {
71
71
  "@modelcontextprotocol/sdk": "^1.24.2",
72
72
  "@types/node-fetch": "^2.6.12",
73
+ "diff": "^9.0.0",
73
74
  "express": "^5.1.0",
74
75
  "fetch-cookie": "^3.1.0",
75
76
  "form-data": "^4.0.0",