@zereight/mcp-gitlab 2.1.11 → 2.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -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, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, 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, GitLabGroupSchema, 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({
@@ -936,6 +937,14 @@ function getEffectiveProjectId(projectId) {
936
937
  }
937
938
  throw new Error("No project ID provided and GITLAB_PROJECT_ID is not set");
938
939
  }
940
+ function rejectIfProjectScopedDeployment(toolName) {
941
+ if (GITLAB_PROJECT_ID) {
942
+ throw new Error(`${toolName} is not allowed when GITLAB_PROJECT_ID is set (server is locked to a single project)`);
943
+ }
944
+ if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
945
+ throw new Error(`${toolName} is not allowed when GITLAB_ALLOWED_PROJECT_IDS is set (server access is restricted to configured projects)`);
946
+ }
947
+ }
939
948
  /**
940
949
  * Create a fork of a GitLab project
941
950
  * 프로젝트 포크 생성 (Create a project fork)
@@ -5415,6 +5424,33 @@ async function getCommitDiff(projectId, sha, full_diff) {
5415
5424
  }
5416
5425
  return allDiffs;
5417
5426
  }
5427
+ /**
5428
+ * Get blame for a file at a specific ref.
5429
+ *
5430
+ * Wraps GitLab REST endpoint
5431
+ * GET /projects/:id/repository/files/:file_path/blame?ref=
5432
+ * Returns an array of entries; each entry has `lines` (the source lines covered)
5433
+ * and `commit` (the commit that last changed those lines: id, author, message, ...).
5434
+ *
5435
+ * @param {string} projectId - Project ID or URL-encoded path
5436
+ * @param {Omit<GetFileBlameOptions,"project_id">} options - file_path, ref, optional range_start/range_end
5437
+ * @returns {Promise<GitLabBlameEntry[]>} Blame entries in source order.
5438
+ */
5439
+ async function getFileBlame(projectId, options) {
5440
+ projectId = decodeURIComponent(projectId);
5441
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodeURIComponent(options.file_path)}/blame`);
5442
+ url.searchParams.append("ref", options.ref);
5443
+ if (options.range_start !== undefined && options.range_end !== undefined) {
5444
+ url.searchParams.append("range[start]", options.range_start.toString());
5445
+ url.searchParams.append("range[end]", options.range_end.toString());
5446
+ }
5447
+ const response = await fetch(url.toString(), {
5448
+ ...getFetchConfig(),
5449
+ });
5450
+ await handleGitLabError(response);
5451
+ const data = await response.json();
5452
+ return z.array(GitLabBlameEntrySchema).parse(data);
5453
+ }
5418
5454
  /**
5419
5455
  * List statuses for a commit.
5420
5456
  *
@@ -5958,6 +5994,10 @@ async function handleToolCall(params) {
5958
5994
  delete args.work_item_iid;
5959
5995
  }
5960
5996
  }
5997
+ // Centralized read-only guard: reject write tools even if client bypasses list_tools filtering
5998
+ if (GITLAB_READ_ONLY_MODE && !readOnlyTools.has(params.name)) {
5999
+ throw new Error(`${params.name} is not allowed in read-only mode`);
6000
+ }
5961
6001
  logger.info({ tool: params.name, event: "tool_call_start" }, `tool_call_start: ${params.name}`);
5962
6002
  switch (params.name) {
5963
6003
  case "execute_graphql": {
@@ -6008,9 +6048,7 @@ async function handleToolCall(params) {
6008
6048
  }
6009
6049
  }
6010
6050
  case "fork_repository": {
6011
- if (GITLAB_PROJECT_ID) {
6012
- throw new Error("Direct project ID is set. So fork_repository is not allowed");
6013
- }
6051
+ rejectIfProjectScopedDeployment("fork_repository");
6014
6052
  const forkArgs = ForkRepositorySchema.parse(params.arguments);
6015
6053
  try {
6016
6054
  const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace);
@@ -6109,15 +6147,40 @@ async function handleToolCall(params) {
6109
6147
  };
6110
6148
  }
6111
6149
  case "create_repository": {
6112
- if (GITLAB_PROJECT_ID) {
6113
- throw new Error("Direct project ID is set. So fork_repository is not allowed");
6114
- }
6150
+ rejectIfProjectScopedDeployment("create_repository");
6115
6151
  const args = CreateRepositorySchema.parse(params.arguments);
6116
6152
  const repository = await createRepository(args);
6117
6153
  return {
6118
6154
  content: [{ type: "text", text: JSON.stringify(repository, null, 2) }],
6119
6155
  };
6120
6156
  }
6157
+ case "create_group": {
6158
+ rejectIfProjectScopedDeployment("create_group");
6159
+ const args = CreateGroupSchema.parse(params.arguments);
6160
+ const url = new URL(`${getEffectiveApiUrl()}/groups`);
6161
+ const body = {
6162
+ name: args.name,
6163
+ path: args.path,
6164
+ };
6165
+ if (args.description)
6166
+ body.description = args.description;
6167
+ if (args.visibility)
6168
+ body.visibility = args.visibility;
6169
+ if (args.parent_id)
6170
+ body.parent_id = args.parent_id;
6171
+ const response = await fetch(url.toString(), {
6172
+ ...getFetchConfig(),
6173
+ method: "POST",
6174
+ headers: { ...getFetchConfig().headers, "Content-Type": "application/json" },
6175
+ body: JSON.stringify(body),
6176
+ });
6177
+ await handleGitLabError(response);
6178
+ const data = await response.json();
6179
+ const group = GitLabGroupSchema.parse(data);
6180
+ return {
6181
+ content: [{ type: "text", text: JSON.stringify(group, null, 2) }],
6182
+ };
6183
+ }
6121
6184
  case "get_file_contents": {
6122
6185
  const args = GetFileContentsSchema.parse(params.arguments);
6123
6186
  const contents = await getFileContents(args.project_id, args.file_path, args.ref);
@@ -6683,6 +6746,81 @@ async function handleToolCall(params) {
6683
6746
  content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
6684
6747
  };
6685
6748
  }
6749
+ case "update_issue_description_patch": {
6750
+ const args = UpdateIssueDescriptionPatchSchema.parse(params.arguments);
6751
+ const { project_id, issue_iid, patch_type, patch, dry_run, create_note, allow_multiple } = args;
6752
+ // Fetch current issue description
6753
+ const currentIssue = await getIssue(project_id, issue_iid);
6754
+ const currentDescription = currentIssue.description ?? "";
6755
+ // Apply the patch
6756
+ let result;
6757
+ if (patch_type === "search_replace") {
6758
+ const blocks = parseSearchReplaceBlocks(patch);
6759
+ if (blocks.length === 0) {
6760
+ throw new Error("No valid search/replace blocks found. Expected format: <<<<<<< SEARCH\\ntext\\n=======\\nnew text\\n>>>>>>> REPLACE");
6761
+ }
6762
+ result = applySearchReplace(currentDescription, blocks, allow_multiple);
6763
+ }
6764
+ else {
6765
+ // unified_diff
6766
+ result = applyUnifiedDiff(currentDescription, patch);
6767
+ }
6768
+ // Dry-run: return preview without updating
6769
+ if (dry_run) {
6770
+ return {
6771
+ content: [
6772
+ {
6773
+ type: "text",
6774
+ text: JSON.stringify({
6775
+ status: "preview",
6776
+ dry_run: true,
6777
+ changes: result.changes,
6778
+ summary: result.summary,
6779
+ preview: result.preview,
6780
+ }, null, 2),
6781
+ },
6782
+ ],
6783
+ };
6784
+ }
6785
+ // Apply the update
6786
+ const updatedIssue = await updateIssue(project_id, issue_iid, {
6787
+ description: result.description,
6788
+ });
6789
+ // Optionally create a note summarizing the change
6790
+ let noteResult = null;
6791
+ if (create_note) {
6792
+ try {
6793
+ const noteBody = `Updated issue description using patch-based tool.\n\n${result.summary}`;
6794
+ await createIssueNote(project_id, issue_iid, undefined, noteBody);
6795
+ noteResult = { status: "created" };
6796
+ }
6797
+ catch (noteError) {
6798
+ noteResult = {
6799
+ status: "failed",
6800
+ message: `Note creation failed: ${noteError.message ?? noteError}`,
6801
+ };
6802
+ }
6803
+ }
6804
+ return {
6805
+ content: [
6806
+ {
6807
+ type: "text",
6808
+ text: JSON.stringify({
6809
+ status: "success",
6810
+ changes: result.changes,
6811
+ summary: result.summary,
6812
+ note: noteResult,
6813
+ issue: {
6814
+ iid: updatedIssue.iid,
6815
+ title: updatedIssue.title,
6816
+ web_url: updatedIssue.web_url,
6817
+ updated_at: updatedIssue.updated_at,
6818
+ },
6819
+ }, null, 2),
6820
+ },
6821
+ ],
6822
+ };
6823
+ }
6686
6824
  case "delete_issue": {
6687
6825
  const args = DeleteIssueSchema.parse(params.arguments);
6688
6826
  await deleteIssue(args.project_id, args.issue_iid);
@@ -7390,6 +7528,14 @@ async function handleToolCall(params) {
7390
7528
  content: [{ type: "text", text: JSON.stringify(diff, null, 2) }],
7391
7529
  };
7392
7530
  }
7531
+ case "get_file_blame": {
7532
+ const args = GetFileBlameSchema.parse(params.arguments);
7533
+ const { project_id, ...options } = args;
7534
+ const blame = await getFileBlame(project_id, options);
7535
+ return {
7536
+ content: [{ type: "text", text: JSON.stringify(blame, null, 2) }],
7537
+ };
7538
+ }
7393
7539
  case "list_commit_statuses": {
7394
7540
  const args = ListCommitStatusesSchema.parse(params.arguments);
7395
7541
  const { project_id, sha, ...options } = args;
@@ -7612,6 +7758,56 @@ async function handleToolCall(params) {
7612
7758
  content: [{ type: "text", text: JSON.stringify({ status: authenticated ? "ok" : "error", authenticated, gitlab_url: getEffectiveApiUrl() }) }],
7613
7759
  };
7614
7760
  }
7761
+ case "get_branch": {
7762
+ const args = GetBranchSchema.parse(params.arguments);
7763
+ const projectId = decodeURIComponent(args.project_id);
7764
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches/${encodeURIComponent(args.branch_name)}`);
7765
+ const response = await fetch(url.toString(), {
7766
+ ...getFetchConfig(),
7767
+ });
7768
+ await handleGitLabError(response);
7769
+ const data = await response.json();
7770
+ const branch = GitLabBranchSchema.parse(data);
7771
+ return {
7772
+ content: [{ type: "text", text: JSON.stringify(branch, null, 2) }],
7773
+ };
7774
+ }
7775
+ case "list_branches": {
7776
+ const args = ListBranchesSchema.parse(params.arguments);
7777
+ const projectId = decodeURIComponent(args.project_id);
7778
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches`);
7779
+ if (args.search) {
7780
+ url.searchParams.append("search", args.search);
7781
+ }
7782
+ if (args.page) {
7783
+ url.searchParams.append("page", args.page.toString());
7784
+ }
7785
+ if (args.per_page) {
7786
+ url.searchParams.append("per_page", args.per_page.toString());
7787
+ }
7788
+ const response = await fetch(url.toString(), {
7789
+ ...getFetchConfig(),
7790
+ });
7791
+ await handleGitLabError(response);
7792
+ const data = await response.json();
7793
+ const branches = z.array(GitLabBranchSchema).parse(data);
7794
+ return {
7795
+ content: [{ type: "text", text: JSON.stringify(branches, null, 2) }],
7796
+ };
7797
+ }
7798
+ case "delete_branch": {
7799
+ const args = DeleteBranchSchema.parse(params.arguments);
7800
+ const projectId = decodeURIComponent(args.project_id);
7801
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches/${encodeURIComponent(args.branch_name)}`);
7802
+ const response = await fetch(url.toString(), {
7803
+ ...getFetchConfig(),
7804
+ method: "DELETE",
7805
+ });
7806
+ await handleGitLabError(response);
7807
+ return {
7808
+ content: [{ type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }, null, 2) }],
7809
+ };
7810
+ }
7615
7811
  default:
7616
7812
  throw new Error(`Unknown tool: ${params.name}`);
7617
7813
  }
package/build/schemas.js CHANGED
@@ -595,6 +595,39 @@ export const GitLabCurrentUserSchema = z.object({
595
595
  extern_uid: z.string(),
596
596
  })).optional(),
597
597
  }).passthrough();
598
+ // Group related schemas
599
+ export const CreateGroupSchema = z.object({
600
+ name: z.string().describe("The name of the group"),
601
+ path: z.string().describe("The path of the group"),
602
+ description: z.string().optional().describe("The group's description"),
603
+ visibility: z.enum(["private", "internal", "public"]).optional().describe("The group's visibility level"),
604
+ parent_id: z.coerce.number().optional().describe("The parent group ID for creating a subgroup"),
605
+ });
606
+ export const GitLabGroupSchema = z.object({
607
+ id: z.coerce.string(),
608
+ name: z.string(),
609
+ path: z.string(),
610
+ description: z.string().nullable(),
611
+ visibility: z.string().optional(),
612
+ share_with_group_lock: z.boolean().optional(),
613
+ require_two_factor_authentication: z.boolean().optional(),
614
+ two_factor_grace_period: z.number().optional(),
615
+ project_creation_level: z.string().optional(),
616
+ auto_devops_enabled: z.boolean().nullable().optional(),
617
+ subgroup_creation_level: z.string().optional(),
618
+ emails_disabled: z.boolean().nullable().optional(),
619
+ mentions_disabled: z.boolean().nullable().optional(),
620
+ lfs_enabled: z.boolean().nullable().optional(),
621
+ avatar_url: z.string().nullable().optional(),
622
+ web_url: z.string(),
623
+ request_access_enabled: z.boolean().nullable().optional(),
624
+ full_name: z.string(),
625
+ full_path: z.string(),
626
+ file_template_project_id: z.number().nullable().optional(),
627
+ parent_id: z.coerce.string().nullable().optional(),
628
+ created_at: z.string().optional(),
629
+ statistics: z.any().optional(),
630
+ });
598
631
  // Namespace related schemas
599
632
  // Base schema for project-related operations
600
633
  const ProjectParamsSchema = z.object({
@@ -1472,6 +1505,37 @@ export const CreateBranchSchema = ProjectParamsSchema.extend({
1472
1505
  branch: z.string().describe("Name for the new branch"),
1473
1506
  ref: z.string().optional().describe("Source branch/commit for new branch"),
1474
1507
  });
1508
+ export const GetBranchSchema = ProjectParamsSchema.extend({
1509
+ branch_name: z.string().describe("Name of the branch"),
1510
+ });
1511
+ export const ListBranchesSchema = ProjectParamsSchema.extend({
1512
+ search: z.string().optional().describe("Search term to filter branches by name"),
1513
+ }).merge(PaginationOptionsSchema);
1514
+ export const DeleteBranchSchema = ProjectParamsSchema.extend({
1515
+ branch_name: z.string().describe("Name of the branch to delete"),
1516
+ });
1517
+ export const GitLabBranchSchema = z.object({
1518
+ name: z.string(),
1519
+ commit: z.object({
1520
+ id: z.string(),
1521
+ short_id: z.string(),
1522
+ title: z.string(),
1523
+ author_name: z.string(),
1524
+ author_email: z.string(),
1525
+ authored_date: z.string(),
1526
+ committer_name: z.string(),
1527
+ committer_email: z.string(),
1528
+ committed_date: z.string(),
1529
+ web_url: z.string(),
1530
+ }),
1531
+ merged: z.boolean(),
1532
+ protected: z.boolean(),
1533
+ developers_can_push: z.boolean(),
1534
+ developers_can_merge: z.boolean(),
1535
+ can_push: z.boolean(),
1536
+ default: z.boolean(),
1537
+ web_url: z.string().optional(),
1538
+ });
1475
1539
  export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
1476
1540
  from: z.string().describe("The base branch or commit SHA to compare from"),
1477
1541
  to: z.string().describe("The target branch or commit SHA to compare to"),
@@ -1839,6 +1903,22 @@ export const DeleteIssueSchema = z.object({
1839
1903
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1840
1904
  issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
1841
1905
  });
1906
+ export const UpdateIssueDescriptionPatchSchema = z.object({
1907
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1908
+ issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
1909
+ patch_type: z.enum(["search_replace", "unified_diff"]).describe("Type of patch format to apply"),
1910
+ patch: z
1911
+ .string()
1912
+ .min(1)
1913
+ .max(50000)
1914
+ .describe("The patch content to apply to the issue description"),
1915
+ dry_run: z.coerce.boolean().optional().describe("If true, preview changes without updating the issue"),
1916
+ create_note: z.coerce.boolean().optional().describe("If true, add a note summarizing the change after update"),
1917
+ allow_multiple: z
1918
+ .coerce.boolean()
1919
+ .optional()
1920
+ .describe("For search_replace: allow multiple matches to all be replaced (default: false — fail on duplicate)"),
1921
+ });
1842
1922
  // Issue links related schemas
1843
1923
  export const GitLabIssueLinkSchema = z.object({
1844
1924
  source_issue: GitLabIssueSchema,
@@ -2357,6 +2437,50 @@ export const GetCommitDiffSchema = z.object({
2357
2437
  .optional()
2358
2438
  .describe("Whether to return the full diff or only first page (default: false)"),
2359
2439
  });
2440
+ export const GetFileBlameSchema = z
2441
+ .object({
2442
+ project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
2443
+ file_path: z.string().describe("The full path of the file to blame, relative to repo root"),
2444
+ ref: z
2445
+ .string()
2446
+ .describe("The name of branch, tag or commit (required by GitLab blame API)"),
2447
+ range_start: z
2448
+ .coerce.number()
2449
+ .int()
2450
+ .optional()
2451
+ .describe("First line of the blame range (inclusive, 1-based). Both range[start] and range[end] must be set together."),
2452
+ range_end: z
2453
+ .coerce.number()
2454
+ .int()
2455
+ .optional()
2456
+ .describe("Last line of the blame range (inclusive, 1-based). Both range[start] and range[end] must be set together."),
2457
+ })
2458
+ .refine((v) => (v.range_start === undefined) === (v.range_end === undefined), {
2459
+ message: "range_start and range_end must be provided together (both or neither). Passing only one silently returned full-file blame on GitLab side.",
2460
+ path: ["range_end"],
2461
+ })
2462
+ .refine((v) => v.range_start === undefined ||
2463
+ v.range_end === undefined ||
2464
+ v.range_start <= v.range_end, {
2465
+ message: "range_start must be less than or equal to range_end.",
2466
+ path: ["range_start"],
2467
+ });
2468
+ export const GitLabBlameEntrySchema = z.object({
2469
+ lines: z.array(z.string()).describe("Source lines covered by this blame range"),
2470
+ commit: z
2471
+ .object({
2472
+ id: z.string(),
2473
+ parent_ids: z.array(z.string()).optional(),
2474
+ message: z.string().optional(),
2475
+ authored_date: z.string().optional(),
2476
+ author_name: z.string().optional(),
2477
+ author_email: z.string().optional(),
2478
+ committed_date: z.string().optional(),
2479
+ committer_name: z.string().optional(),
2480
+ committer_email: z.string().optional(),
2481
+ })
2482
+ .passthrough(),
2483
+ });
2360
2484
  export const ListCommitStatusesSchema = z
2361
2485
  .object({
2362
2486
  project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
@@ -0,0 +1,145 @@
1
+ import { describe, test, before, after } from "node:test";
2
+ import assert from "node:assert";
3
+ import { spawn } from "child_process";
4
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
5
+ const MOCK_TOKEN = "glpat-mock-token-12345";
6
+ const TEST_PROJECT_ID = "123";
7
+ const MOCK_BLAME = [
8
+ {
9
+ lines: ["line one", ""],
10
+ commit: {
11
+ id: "1111111111111111111111111111111111111111",
12
+ message: "feat: initial commit",
13
+ authored_date: "2024-01-01T00:00:00.000Z",
14
+ author_name: "Alice",
15
+ author_email: "alice@example.com",
16
+ },
17
+ },
18
+ {
19
+ lines: ["line three"],
20
+ commit: {
21
+ id: "2222222222222222222222222222222222222222",
22
+ message: "feat: add second change",
23
+ authored_date: "2024-02-02T00:00:00.000Z",
24
+ author_name: "Bob",
25
+ author_email: "bob@example.com",
26
+ },
27
+ },
28
+ ];
29
+ async function callGetFileBlame(args, env) {
30
+ return new Promise((resolve, reject) => {
31
+ const proc = spawn("node", ["build/index.js"], {
32
+ stdio: ["pipe", "pipe", "pipe"],
33
+ env: {
34
+ ...process.env,
35
+ ...env,
36
+ GITLAB_READ_ONLY_MODE: "true",
37
+ },
38
+ });
39
+ let output = "";
40
+ let errorOutput = "";
41
+ proc.stdout?.on("data", (d) => (output += d));
42
+ proc.stderr?.on("data", (d) => (errorOutput += d));
43
+ proc.on("close", (code) => {
44
+ if (code !== 0)
45
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
46
+ const line = output.split("\n").find((l) => l.startsWith("{"));
47
+ if (!line)
48
+ return reject(new Error("No JSON output found"));
49
+ try {
50
+ const response = JSON.parse(line);
51
+ if (response.error)
52
+ return reject(response.error);
53
+ const content = response.result?.content?.[0]?.text;
54
+ if (content)
55
+ return resolve(JSON.parse(content));
56
+ resolve(response.result);
57
+ }
58
+ catch (e) {
59
+ reject(e);
60
+ }
61
+ });
62
+ proc.stdin?.end(JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ id: 1,
65
+ method: "tools/call",
66
+ params: { name: "get_file_blame", arguments: args },
67
+ }) + "\n");
68
+ });
69
+ }
70
+ describe("get_file_blame", () => {
71
+ let mockGitLab;
72
+ let mockGitLabUrl;
73
+ let lastQuery = {};
74
+ before(async () => {
75
+ const mockPort = await findMockServerPort(9000);
76
+ mockGitLab = new MockGitLabServer({
77
+ port: mockPort,
78
+ validTokens: [MOCK_TOKEN],
79
+ });
80
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/src%2Fexample.txt/blame`, (req, res) => {
81
+ lastQuery = req.query;
82
+ res.json(MOCK_BLAME);
83
+ });
84
+ await mockGitLab.start();
85
+ mockGitLabUrl = mockGitLab.getUrl();
86
+ });
87
+ after(async () => {
88
+ await mockGitLab.stop();
89
+ });
90
+ test("returns blame entries for a file at ref", async () => {
91
+ const blame = await callGetFileBlame({
92
+ project_id: TEST_PROJECT_ID,
93
+ file_path: "src/example.txt",
94
+ ref: "main",
95
+ }, {
96
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
97
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
98
+ });
99
+ assert.ok(Array.isArray(blame), "Response should be an array");
100
+ assert.strictEqual(blame.length, 2, "Two blame entries expected");
101
+ assert.strictEqual(blame[1].commit.id, "2222222222222222222222222222222222222222", "second entry commit id matches");
102
+ assert.deepStrictEqual(blame[1].lines, ["line three"]);
103
+ assert.strictEqual(lastQuery.ref, "main", "ref propagated to GitLab API");
104
+ assert.ok(!("range[start]" in lastQuery) && !("range[end]" in lastQuery), "no range params when omitted");
105
+ });
106
+ test("passes range[start]/range[end] when both set", async () => {
107
+ await callGetFileBlame({
108
+ project_id: TEST_PROJECT_ID,
109
+ file_path: "src/example.txt",
110
+ ref: "main",
111
+ range_start: 10,
112
+ range_end: 20,
113
+ }, {
114
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
115
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
116
+ });
117
+ assert.strictEqual(lastQuery["range[start]"], "10");
118
+ assert.strictEqual(lastQuery["range[end]"], "20");
119
+ });
120
+ test("rejects partial range (range_start only) at schema layer", async () => {
121
+ await assert.rejects(() => callGetFileBlame({
122
+ project_id: TEST_PROJECT_ID,
123
+ file_path: "src/example.txt",
124
+ ref: "main",
125
+ range_start: 10,
126
+ }, {
127
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
128
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
129
+ }), (e) => typeof e?.message === "string" &&
130
+ e.message.includes("range_start and range_end must be provided together"));
131
+ });
132
+ test("rejects inverted range (start > end) at schema layer", async () => {
133
+ await assert.rejects(() => callGetFileBlame({
134
+ project_id: TEST_PROJECT_ID,
135
+ file_path: "src/example.txt",
136
+ ref: "main",
137
+ range_start: 20,
138
+ range_end: 10,
139
+ }, {
140
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
141
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
142
+ }), (e) => typeof e?.message === "string" &&
143
+ e.message.includes("range_start must be less than or equal to range_end"));
144
+ });
145
+ });