@zereight/mcp-gitlab 2.1.15 → 2.1.17

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
@@ -647,6 +647,16 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
647
647
  154. `search_group_code` - Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled)
648
648
  155. `execute_graphql` - Execute a GitLab GraphQL query
649
649
  156. `list_merge_request_pipelines` - List pipelines for a merge request with pagination support
650
+ 157. `list_project_variables` - List CI/CD variables for a project with optional environment scope filter
651
+ 158. `get_project_variable` - Get a single CI/CD variable from a project by key, with optional environment scope filter
652
+ 159. `create_project_variable` - Create a new CI/CD variable in a project
653
+ 160. `update_project_variable` - Update an existing CI/CD variable in a project, with optional filter to disambiguate by environment scope
654
+ 161. `delete_project_variable` - Delete a CI/CD variable from a project, with optional filter to disambiguate by environment scope
655
+ 162. `list_group_variables` - List CI/CD variables for a group with optional environment scope filter
656
+ 163. `get_group_variable` - Get a single CI/CD variable from a group by key, with optional environment scope filter
657
+ 164. `create_group_variable` - Create a new CI/CD variable in a group
658
+ 165. `update_group_variable` - Update an existing CI/CD variable in a group, with optional filter to disambiguate by environment scope
659
+ 166. `delete_group_variable` - Delete a CI/CD variable from a group, with optional filter to disambiguate by environment scope
650
660
 
651
661
  <!-- TOOLS-END -->
652
662
 
package/build/index.js CHANGED
@@ -128,6 +128,7 @@ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
128
128
  import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
129
129
  import { normalizeGitLabApiUrl } from "./utils/url.js";
130
130
  import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents } from "./utils/helpers.js";
131
+ import { sanitizeToolArguments } from "./utils/tool-args.js";
131
132
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
132
133
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
133
134
  import { GitLabClientPool } from "./gitlab-client-pool.js";
@@ -138,7 +139,7 @@ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMerg
138
139
  GitLabDiscussionNoteSchema, // Added
139
140
  GitLabDiscussionSchema,
140
141
  // Draft Notes Schemas
141
- 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
142
+ 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, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
142
143
  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, MarkdownUploadRemoteSchema, 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";
143
144
  import { randomUUID, createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
144
145
  import { pino } from "pino";
@@ -1275,6 +1276,12 @@ async function listMergeRequests(projectId, options = {}) {
1275
1276
  // Handle array of labels
1276
1277
  url.searchParams.append(key, value.join(","));
1277
1278
  }
1279
+ else if (key === "approved_by_usernames" && Array.isArray(value)) {
1280
+ // GitLab expects array-bracket form: approved_by_usernames[]=alice&approved_by_usernames[]=bob
1281
+ for (const v of value) {
1282
+ url.searchParams.append(`${key}[]`, String(v));
1283
+ }
1284
+ }
1278
1285
  else {
1279
1286
  url.searchParams.append(key, String(value));
1280
1287
  }
@@ -1382,16 +1389,9 @@ async function executeGraphQL(query, variables = {}) {
1382
1389
  * Resolve a project path and issue IID to a work item GraphQL GID.
1383
1390
  */
1384
1391
  async function resolveWorkItemGID(projectId, issueIid) {
1385
- projectId = decodeURIComponent(projectId);
1386
- const effectiveProjectId = getEffectiveProjectId(projectId);
1387
- // First get the project path via REST (needed for GraphQL namespace query)
1388
- const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
1389
- const projectResponse = await fetch(projectUrl.toString(), {
1390
- ...getFetchConfig(),
1391
- });
1392
- await handleGitLabError(projectResponse);
1393
- const project = await projectResponse.json();
1394
- const projectPath = project.path_with_namespace;
1392
+ // resolveProjectPath handles both project and group paths (including the
1393
+ // group fallback), so work item tools work for group-level namespaces too.
1394
+ const projectPath = await resolveProjectPath(projectId);
1395
1395
  // Resolve work item GID via GraphQL
1396
1396
  const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
1397
1397
  namespace(fullPath: $path) {
@@ -1514,15 +1514,7 @@ async function removeIssueParent(projectId, issueIid) {
1514
1514
  * Requires Premium/Ultimate with configurable statuses enabled.
1515
1515
  */
1516
1516
  async function listIssueStatuses(projectId, workItemType = "issue") {
1517
- projectId = decodeURIComponent(projectId);
1518
- const effectiveProjectId = getEffectiveProjectId(projectId);
1519
- // Get project path
1520
- const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
1521
- const projectResponse = await fetch(projectUrl.toString(), {
1522
- ...getFetchConfig(),
1523
- });
1524
- await handleGitLabError(projectResponse);
1525
- const project = await projectResponse.json();
1517
+ const projectPath = await resolveProjectPath(projectId);
1526
1518
  const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
1527
1519
  const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
1528
1520
  namespace(fullPath: $path) {
@@ -1550,7 +1542,7 @@ async function listIssueStatuses(projectId, workItemType = "issue") {
1550
1542
  }
1551
1543
  }
1552
1544
  }
1553
- }`, { path: project.path_with_namespace, typeName: typeName.replace(/ /g, "_").toUpperCase() });
1545
+ }`, { path: projectPath, typeName: typeName.replace(/ /g, "_").toUpperCase() });
1554
1546
  const typeNodes = data.namespace?.workItemTypes?.nodes;
1555
1547
  if (!typeNodes || typeNodes.length === 0) {
1556
1548
  throw new Error(`Work item type '${typeName}' not found in project`);
@@ -1946,6 +1938,22 @@ async function resolveProjectPath(projectId) {
1946
1938
  const projectResponse = await fetch(projectUrl.toString(), {
1947
1939
  ...getFetchConfig(),
1948
1940
  });
1941
+ // On project 404, fall back to groups — but only for path-like identifiers.
1942
+ // Numeric IDs must not fall back: group and project IDs share no namespace,
1943
+ // so a numeric project 404 should fail immediately rather than silently
1944
+ // resolving to an unrelated group with the same integer ID.
1945
+ if (projectResponse.status === 404 && !/^\d+$/.test(effectiveProjectId)) {
1946
+ const groupUrl = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(effectiveProjectId)}`);
1947
+ const groupResponse = await fetch(groupUrl.toString(), {
1948
+ ...getFetchConfig(),
1949
+ });
1950
+ if (groupResponse.ok) {
1951
+ const group = await groupResponse.json();
1952
+ return group.full_path;
1953
+ }
1954
+ // Surface the group error
1955
+ await handleGitLabError(groupResponse);
1956
+ }
1949
1957
  await handleGitLabError(projectResponse);
1950
1958
  const project = await projectResponse.json();
1951
1959
  return project.path_with_namespace;
@@ -5734,6 +5742,139 @@ async function listGroupIterations(groupId, options = {}) {
5734
5742
  const data = await response.json();
5735
5743
  return z.array(GroupIteration).parse(data);
5736
5744
  }
5745
+ // --- CI/CD Variables ---
5746
+ async function listProjectVariables(projectId, options = {}) {
5747
+ projectId = decodeURIComponent(projectId);
5748
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/variables`);
5749
+ Object.entries(options).forEach(([key, value]) => {
5750
+ if (value === undefined)
5751
+ return;
5752
+ if (key === "filter" && typeof value === "object" && value !== null) {
5753
+ Object.entries(value).forEach(([fKey, fVal]) => {
5754
+ url.searchParams.append(`filter[${fKey}]`, fVal);
5755
+ });
5756
+ }
5757
+ else if (typeof value === "boolean") {
5758
+ url.searchParams.append(key, value ? "true" : "false");
5759
+ }
5760
+ else {
5761
+ url.searchParams.append(key, String(value));
5762
+ }
5763
+ });
5764
+ const response = await fetch(url.toString(), getFetchConfig());
5765
+ await handleGitLabError(response);
5766
+ const data = await response.json();
5767
+ return z.array(GitLabCiVariableSchema).parse(data);
5768
+ }
5769
+ async function getProjectVariable(projectId, key, filter) {
5770
+ projectId = decodeURIComponent(projectId);
5771
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/variables/${encodeURIComponent(key)}`);
5772
+ if (filter?.environment_scope) {
5773
+ url.searchParams.append("filter[environment_scope]", filter.environment_scope);
5774
+ }
5775
+ const response = await fetch(url.toString(), getFetchConfig());
5776
+ await handleGitLabError(response);
5777
+ const data = await response.json();
5778
+ return GitLabCiVariableSchema.parse(data);
5779
+ }
5780
+ async function createProjectVariable(projectId, options) {
5781
+ projectId = decodeURIComponent(projectId);
5782
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/variables`, { ...getFetchConfig(), method: "POST", body: JSON.stringify(options) });
5783
+ await handleGitLabError(response);
5784
+ const data = await response.json();
5785
+ return GitLabCiVariableSchema.parse(data);
5786
+ }
5787
+ async function updateProjectVariable(projectId, key, options) {
5788
+ projectId = decodeURIComponent(projectId);
5789
+ const { filter, ...body } = options;
5790
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/variables/${encodeURIComponent(key)}`);
5791
+ if (filter?.environment_scope) {
5792
+ url.searchParams.append("filter[environment_scope]", filter.environment_scope);
5793
+ }
5794
+ const response = await fetch(url.toString(), {
5795
+ ...getFetchConfig(),
5796
+ method: "PUT",
5797
+ body: JSON.stringify(body),
5798
+ });
5799
+ await handleGitLabError(response);
5800
+ const data = await response.json();
5801
+ return GitLabCiVariableSchema.parse(data);
5802
+ }
5803
+ async function deleteProjectVariable(projectId, key, filter) {
5804
+ projectId = decodeURIComponent(projectId);
5805
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/variables/${encodeURIComponent(key)}`);
5806
+ if (filter?.environment_scope) {
5807
+ url.searchParams.append("filter[environment_scope]", filter.environment_scope);
5808
+ }
5809
+ const response = await fetch(url.toString(), { ...getFetchConfig(), method: "DELETE" });
5810
+ await handleGitLabError(response);
5811
+ }
5812
+ async function listGroupVariables(groupId, options = {}) {
5813
+ const encoded = encodeURIComponent(decodeURIComponent(groupId));
5814
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encoded}/variables`);
5815
+ Object.entries(options).forEach(([key, value]) => {
5816
+ if (value === undefined)
5817
+ return;
5818
+ if (key === "filter" && typeof value === "object" && value !== null) {
5819
+ Object.entries(value).forEach(([fKey, fVal]) => {
5820
+ url.searchParams.append(`filter[${fKey}]`, fVal);
5821
+ });
5822
+ }
5823
+ else if (typeof value === "boolean") {
5824
+ url.searchParams.append(key, value ? "true" : "false");
5825
+ }
5826
+ else {
5827
+ url.searchParams.append(key, String(value));
5828
+ }
5829
+ });
5830
+ const response = await fetch(url.toString(), getFetchConfig());
5831
+ await handleGitLabError(response);
5832
+ const data = await response.json();
5833
+ return z.array(GitLabCiVariableSchema).parse(data);
5834
+ }
5835
+ async function getGroupVariable(groupId, key, filter) {
5836
+ const encoded = encodeURIComponent(decodeURIComponent(groupId));
5837
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encoded}/variables/${encodeURIComponent(key)}`);
5838
+ if (filter?.environment_scope) {
5839
+ url.searchParams.append("filter[environment_scope]", filter.environment_scope);
5840
+ }
5841
+ const response = await fetch(url.toString(), getFetchConfig());
5842
+ await handleGitLabError(response);
5843
+ const data = await response.json();
5844
+ return GitLabCiVariableSchema.parse(data);
5845
+ }
5846
+ async function createGroupVariable(groupId, options) {
5847
+ const encoded = encodeURIComponent(decodeURIComponent(groupId));
5848
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encoded}/variables`, {
5849
+ ...getFetchConfig(),
5850
+ method: "POST",
5851
+ body: JSON.stringify(options),
5852
+ });
5853
+ await handleGitLabError(response);
5854
+ const data = await response.json();
5855
+ return GitLabCiVariableSchema.parse(data);
5856
+ }
5857
+ async function updateGroupVariable(groupId, key, options) {
5858
+ const encoded = encodeURIComponent(decodeURIComponent(groupId));
5859
+ const { filter, ...body } = options;
5860
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encoded}/variables/${encodeURIComponent(key)}`);
5861
+ if (filter?.environment_scope) {
5862
+ url.searchParams.append("filter[environment_scope]", filter.environment_scope);
5863
+ }
5864
+ const response = await fetch(url.toString(), { ...getFetchConfig(), method: "PUT", body: JSON.stringify(body) });
5865
+ await handleGitLabError(response);
5866
+ const data = await response.json();
5867
+ return GitLabCiVariableSchema.parse(data);
5868
+ }
5869
+ async function deleteGroupVariable(groupId, key, filter) {
5870
+ const encoded = encodeURIComponent(decodeURIComponent(groupId));
5871
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encoded}/variables/${encodeURIComponent(key)}`);
5872
+ if (filter?.environment_scope) {
5873
+ url.searchParams.append("filter[environment_scope]", filter.environment_scope);
5874
+ }
5875
+ const response = await fetch(url.toString(), { ...getFetchConfig(), method: "DELETE" });
5876
+ await handleGitLabError(response);
5877
+ }
5737
5878
  /**
5738
5879
  * Upload a file to a GitLab project for use in markdown content.
5739
5880
  *
@@ -6126,6 +6267,9 @@ async function handleToolCall(params) {
6126
6267
  args.iid = args.work_item_iid;
6127
6268
  delete args.work_item_iid;
6128
6269
  }
6270
+ if (!Array.isArray(args)) {
6271
+ params.arguments = sanitizeToolArguments(params.name, args);
6272
+ }
6129
6273
  }
6130
6274
  // Centralized read-only guard: reject write tools even if client bypasses list_tools filtering
6131
6275
  if (GITLAB_READ_ONLY_MODE && !readOnlyTools.has(params.name)) {
@@ -7701,6 +7845,82 @@ async function handleToolCall(params) {
7701
7845
  content: [{ type: "text", text: JSON.stringify(iterations, null, 2) }],
7702
7846
  };
7703
7847
  }
7848
+ // --- CI/CD Variables ---
7849
+ case "list_project_variables": {
7850
+ const args = ListProjectVariablesSchema.parse(params.arguments);
7851
+ const { project_id, ...options } = args;
7852
+ const variables = await listProjectVariables(project_id, options);
7853
+ return { content: [{ type: "text", text: JSON.stringify(variables, null, 2) }] };
7854
+ }
7855
+ case "get_project_variable": {
7856
+ const args = GetProjectVariableSchema.parse(params.arguments);
7857
+ const variable = await getProjectVariable(args.project_id, args.key, args.filter);
7858
+ return { content: [{ type: "text", text: JSON.stringify(variable, null, 2) }] };
7859
+ }
7860
+ case "create_project_variable": {
7861
+ const args = CreateProjectVariableSchema.parse(params.arguments);
7862
+ const { project_id, ...options } = args;
7863
+ const variable = await createProjectVariable(project_id, options);
7864
+ return { content: [{ type: "text", text: JSON.stringify(variable, null, 2) }] };
7865
+ }
7866
+ case "update_project_variable": {
7867
+ const args = UpdateProjectVariableSchema.parse(params.arguments);
7868
+ const { project_id, key, ...options } = args;
7869
+ const variable = await updateProjectVariable(project_id, key, options);
7870
+ return { content: [{ type: "text", text: JSON.stringify(variable, null, 2) }] };
7871
+ }
7872
+ case "delete_project_variable": {
7873
+ const args = DeleteProjectVariableSchema.parse(params.arguments);
7874
+ await deleteProjectVariable(args.project_id, args.key, args.filter);
7875
+ return {
7876
+ content: [
7877
+ {
7878
+ type: "text",
7879
+ text: JSON.stringify({ status: "success", message: `Variable '${args.key}' deleted from project` }, null, 2),
7880
+ },
7881
+ ],
7882
+ };
7883
+ }
7884
+ case "list_group_variables": {
7885
+ rejectIfProjectScopedDeployment("list_group_variables");
7886
+ const args = ListGroupVariablesSchema.parse(params.arguments);
7887
+ const { group_id, ...options } = args;
7888
+ const variables = await listGroupVariables(group_id, options);
7889
+ return { content: [{ type: "text", text: JSON.stringify(variables, null, 2) }] };
7890
+ }
7891
+ case "get_group_variable": {
7892
+ rejectIfProjectScopedDeployment("get_group_variable");
7893
+ const args = GetGroupVariableSchema.parse(params.arguments);
7894
+ const variable = await getGroupVariable(args.group_id, args.key, args.filter);
7895
+ return { content: [{ type: "text", text: JSON.stringify(variable, null, 2) }] };
7896
+ }
7897
+ case "create_group_variable": {
7898
+ rejectIfProjectScopedDeployment("create_group_variable");
7899
+ const args = CreateGroupVariableSchema.parse(params.arguments);
7900
+ const { group_id, ...options } = args;
7901
+ const variable = await createGroupVariable(group_id, options);
7902
+ return { content: [{ type: "text", text: JSON.stringify(variable, null, 2) }] };
7903
+ }
7904
+ case "update_group_variable": {
7905
+ rejectIfProjectScopedDeployment("update_group_variable");
7906
+ const args = UpdateGroupVariableSchema.parse(params.arguments);
7907
+ const { group_id, key, ...options } = args;
7908
+ const variable = await updateGroupVariable(group_id, key, options);
7909
+ return { content: [{ type: "text", text: JSON.stringify(variable, null, 2) }] };
7910
+ }
7911
+ case "delete_group_variable": {
7912
+ rejectIfProjectScopedDeployment("delete_group_variable");
7913
+ const args = DeleteGroupVariableSchema.parse(params.arguments);
7914
+ await deleteGroupVariable(args.group_id, args.key, args.filter);
7915
+ return {
7916
+ content: [
7917
+ {
7918
+ type: "text",
7919
+ text: JSON.stringify({ status: "success", message: `Variable '${args.key}' deleted from group` }, null, 2),
7920
+ },
7921
+ ],
7922
+ };
7923
+ }
7704
7924
  case "upload_markdown": {
7705
7925
  if (IS_REMOTE) {
7706
7926
  const args = MarkdownUploadRemoteSchema.parse(params.arguments);
package/build/schemas.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { omitIncompleteMergeRequestPosition } from "./utils/merge-request-position.js";
2
3
  // Helper: coerce a JSON-stringified array to an actual array.
3
4
  // LLMs sometimes send '["a", "b"]' (string) instead of ["a", "b"] (array).
4
5
  const coerceStringArray = z.preprocess((val) => {
@@ -1110,7 +1111,7 @@ export const GitLabMergeRequestSchema = z.object({
1110
1111
  .describe("Whether rebase is currently in progress for this merge request"),
1111
1112
  merge_when_pipeline_succeeds: z.coerce.boolean().optional(),
1112
1113
  squash: z.coerce.boolean().optional(),
1113
- labels: z.array(z.string()).optional(),
1114
+ labels: z.array(GitLabLabelSchema).or(z.array(z.string())).optional(), // Support both label objects and strings
1114
1115
  });
1115
1116
  export const LineRangeSchema = z
1116
1117
  .object({
@@ -1823,6 +1824,9 @@ export const ListMergeRequestsSchema = z
1823
1824
  .string()
1824
1825
  .optional()
1825
1826
  .describe("Returns merge requests which have the user as a reviewer by username. Mutually exclusive with reviewer_id."),
1827
+ approved_by_usernames: coerceStringArray
1828
+ .optional()
1829
+ .describe("Returns merge requests approved by the given usernames (array)."),
1826
1830
  created_after: z
1827
1831
  .string()
1828
1832
  .optional()
@@ -2254,6 +2258,7 @@ export const MergeRequestThreadPositionSchema = z.object({
2254
2258
  .optional()
2255
2259
  .describe("IMAGE DIFFS ONLY: Y coordinate on the image (for position_type='image')."),
2256
2260
  });
2261
+ const optionalMergeRequestThreadPosition = z.preprocess(omitIncompleteMergeRequestPosition, MergeRequestThreadPositionSchema.optional());
2257
2262
  // Draft Notes API schemas
2258
2263
  export const GitLabDraftNoteSchema = z
2259
2264
  .object({
@@ -2295,7 +2300,7 @@ export const CreateDraftNoteSchema = ProjectParamsSchema.extend({
2295
2300
  .string()
2296
2301
  .optional()
2297
2302
  .describe("The ID of a discussion the draft note replies to"),
2298
- position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
2303
+ position: optionalMergeRequestThreadPosition.describe("Position when creating a diff note"),
2299
2304
  resolve_discussion: z
2300
2305
  .coerce.boolean()
2301
2306
  .optional()
@@ -2306,7 +2311,7 @@ export const UpdateDraftNoteSchema = ProjectParamsSchema.extend({
2306
2311
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2307
2312
  draft_note_id: z.coerce.string().describe("The ID of the draft note"),
2308
2313
  body: z.string().optional().describe("The content of the draft note"),
2309
- position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
2314
+ position: optionalMergeRequestThreadPosition.describe("Position when creating a diff note"),
2310
2315
  resolve_discussion: z
2311
2316
  .coerce.boolean()
2312
2317
  .optional()
@@ -2330,7 +2335,7 @@ export const BulkPublishDraftNotesSchema = ProjectParamsSchema.extend({
2330
2335
  export const CreateMergeRequestThreadSchema = ProjectParamsSchema.extend({
2331
2336
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2332
2337
  body: z.string().describe("The content of the thread"),
2333
- position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
2338
+ position: optionalMergeRequestThreadPosition.describe("Position when creating a diff note"),
2334
2339
  created_at: z.string().optional().describe("Date the thread was created at (ISO 8601 format)"),
2335
2340
  });
2336
2341
  export const ResolveMergeRequestThreadSchema = ProjectParamsSchema.extend({
@@ -3052,14 +3057,15 @@ export const GitLabTagSignatureSchema = z.object({
3052
3057
  // --- Work item schemas (GraphQL-based) ---
3053
3058
  // Case-insensitive work item type enum (accepts "ISSUE", "Issue", "issue")
3054
3059
  const workItemTypeEnum = z.string().transform(v => v.toLowerCase()).pipe(z.enum(["issue", "task", "incident", "test_case", "epic", "key_result", "objective", "requirement", "ticket"]));
3060
+ const ProjectIdOrPathSchema = z.coerce.string().describe("Project ID, URL-encoded project path, or group path (e.g. 'group/subgroup' for group-level work items)");
3055
3061
  // Common params for work item tools
3056
3062
  const WorkItemParamsSchema = z.object({
3057
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3063
+ project_id: ProjectIdOrPathSchema,
3058
3064
  iid: z.coerce.number().describe("The internal ID (IID) of the work item"),
3059
3065
  });
3060
3066
  export const GetWorkItemSchema = WorkItemParamsSchema;
3061
3067
  export const ListWorkItemsSchema = z.object({
3062
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3068
+ project_id: ProjectIdOrPathSchema,
3063
3069
  types: z
3064
3070
  .array(workItemTypeEnum)
3065
3071
  .optional()
@@ -3091,7 +3097,7 @@ export const ListWorkItemsSchema = z.object({
3091
3097
  .describe("Cursor for pagination (from previous response's endCursor)"),
3092
3098
  });
3093
3099
  export const CreateWorkItemSchema = z.object({
3094
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3100
+ project_id: ProjectIdOrPathSchema,
3095
3101
  title: z.string().describe("Title of the work item"),
3096
3102
  type: workItemTypeEnum
3097
3103
  .optional()
@@ -3161,38 +3167,38 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
3161
3167
  .describe("Incident only: set escalation status"),
3162
3168
  });
3163
3169
  export const ConvertWorkItemTypeSchema = z.object({
3164
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3170
+ project_id: ProjectIdOrPathSchema,
3165
3171
  iid: z.coerce.number().describe("The internal ID of the work item"),
3166
3172
  new_type: workItemTypeEnum.describe("The target work item type to convert to"),
3167
3173
  });
3168
3174
  export const ListWorkItemStatusesSchema = z.object({
3169
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3175
+ project_id: ProjectIdOrPathSchema,
3170
3176
  work_item_type: workItemTypeEnum
3171
3177
  .optional()
3172
3178
  .default("issue")
3173
3179
  .describe("The work item type to list available statuses for. Defaults to 'issue'."),
3174
3180
  });
3175
3181
  export const ListWorkItemNotesSchema = z.object({
3176
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3182
+ project_id: ProjectIdOrPathSchema,
3177
3183
  iid: z.coerce.number().describe("The internal ID of the work item"),
3178
3184
  page_size: z.coerce.number().optional().default(20).describe("Number of discussions to return (default 20)"),
3179
3185
  after: z.string().optional().describe("Cursor for pagination"),
3180
3186
  sort: z.enum(["CREATED_ASC", "CREATED_DESC"]).optional().default("CREATED_ASC").describe("Sort order for discussions"),
3181
3187
  });
3182
3188
  export const CreateWorkItemNoteSchema = z.object({
3183
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3189
+ project_id: ProjectIdOrPathSchema,
3184
3190
  iid: z.coerce.number().describe("The internal ID of the work item"),
3185
3191
  body: z.string().describe("Note body (Markdown supported)"),
3186
3192
  internal: z.coerce.boolean().optional().default(false).describe("Create as internal/confidential note (only visible to project members)"),
3187
3193
  discussion_id: z.string().optional().describe("Discussion ID to reply to (for threaded replies). If omitted, creates a new top-level note."),
3188
3194
  });
3189
3195
  export const MoveWorkItemSchema = z.object({
3190
- project_id: z.coerce.string().describe("Project ID or URL-encoded path of the source project"),
3196
+ project_id: z.coerce.string().describe("Project ID, URL-encoded project path, or group path of the source namespace"),
3191
3197
  iid: z.coerce.number().describe("The internal ID of the work item to move"),
3192
- target_project_id: z.coerce.string().describe("Project ID or URL-encoded path of the target project"),
3198
+ target_project_id: z.coerce.string().describe("Project ID, URL-encoded project path, or group path of the target namespace"),
3193
3199
  });
3194
3200
  export const ListCustomFieldDefinitionsSchema = z.object({
3195
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3201
+ project_id: ProjectIdOrPathSchema,
3196
3202
  work_item_type: workItemTypeEnum
3197
3203
  .optional()
3198
3204
  .default("issue")
@@ -3244,23 +3250,23 @@ export const DeleteIssueNoteEmojiReactionSchema = ProjectParamsSchema.extend({
3244
3250
  });
3245
3251
  // --- Emoji Reaction schemas (GraphQL: Work Items) ---
3246
3252
  export const CreateWorkItemEmojiReactionSchema = z.object({
3247
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3253
+ project_id: ProjectIdOrPathSchema,
3248
3254
  iid: z.coerce.number().describe("The internal ID of the work item"),
3249
3255
  name: emojiNameField,
3250
3256
  });
3251
3257
  export const DeleteWorkItemEmojiReactionSchema = z.object({
3252
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3258
+ project_id: ProjectIdOrPathSchema,
3253
3259
  iid: z.coerce.number().describe("The internal ID of the work item"),
3254
3260
  name: emojiNameField,
3255
3261
  });
3256
3262
  export const CreateWorkItemNoteEmojiReactionSchema = z.object({
3257
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3263
+ project_id: ProjectIdOrPathSchema,
3258
3264
  iid: z.coerce.number().describe("The internal ID of the work item"),
3259
3265
  note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
3260
3266
  name: emojiNameField,
3261
3267
  });
3262
3268
  export const DeleteWorkItemNoteEmojiReactionSchema = z.object({
3263
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3269
+ project_id: ProjectIdOrPathSchema,
3264
3270
  iid: z.coerce.number().describe("The internal ID of the work item"),
3265
3271
  note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
3266
3272
  name: emojiNameField,
@@ -3282,11 +3288,11 @@ export const ListIssueNoteEmojiReactionsSchema = ProjectParamsSchema.extend({
3282
3288
  discussion_id: noteEmojiDiscussionField,
3283
3289
  });
3284
3290
  export const ListWorkItemEmojiReactionsSchema = z.object({
3285
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3291
+ project_id: ProjectIdOrPathSchema,
3286
3292
  iid: z.coerce.number().describe("The internal ID of the work item"),
3287
3293
  });
3288
3294
  export const ListWorkItemNoteEmojiReactionsSchema = z.object({
3289
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3295
+ project_id: ProjectIdOrPathSchema,
3290
3296
  iid: z.coerce.number().describe("The internal ID of the work item"),
3291
3297
  note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
3292
3298
  });
@@ -3372,3 +3378,128 @@ export const GetWebhookEventSchema = z
3372
3378
  message: "Provide exactly one of project_id or group_id",
3373
3379
  });
3374
3380
  export const HealthCheckSchema = z.object({});
3381
+ // --- CI/CD Variable types ---
3382
+ export const GitLabCiVariableSchema = z.object({
3383
+ variable_type: z.enum(["env_var", "file"]).optional(),
3384
+ key: z.string(),
3385
+ value: z.string().nullable(),
3386
+ protected: z.boolean().optional(),
3387
+ masked: z.boolean().optional(),
3388
+ hidden: z.boolean().optional(),
3389
+ raw: z.boolean().optional(),
3390
+ environment_scope: z.string().optional(),
3391
+ description: z.string().nullable().optional(),
3392
+ });
3393
+ const ciVariableFields = {
3394
+ key: z.string().describe("The key of the variable (must match /[a-zA-Z0-9_]+/)"),
3395
+ value: z.string().describe("The value of the variable"),
3396
+ variable_type: z
3397
+ .enum(["env_var", "file"])
3398
+ .optional()
3399
+ .describe("The type of variable: 'env_var' (default) or 'file'"),
3400
+ protected: z
3401
+ .boolean()
3402
+ .optional()
3403
+ .describe("Whether the variable is only available on protected branches/tags"),
3404
+ masked: z.boolean().optional().describe("Whether the variable value is masked in job logs"),
3405
+ raw: z.boolean().optional().describe("Whether the variable is not expanded (treated as raw string)"),
3406
+ environment_scope: z
3407
+ .string()
3408
+ .optional()
3409
+ .describe("Environment scope (e.g. '*', 'production'). Default: '*'"),
3410
+ description: z.string().optional().describe("Description of the variable"),
3411
+ };
3412
+ export const ListProjectVariablesSchema = z
3413
+ .object({
3414
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3415
+ filter: z
3416
+ .object({ environment_scope: z.string() })
3417
+ .optional()
3418
+ .describe("Filter by environment scope (e.g. '*', 'production')"),
3419
+ })
3420
+ .merge(PaginationOptionsSchema);
3421
+ export const GetProjectVariableSchema = z.object({
3422
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3423
+ key: z.string().describe("The key of the variable"),
3424
+ filter: z
3425
+ .object({ environment_scope: z.string() })
3426
+ .optional()
3427
+ .describe("Filter by environment scope"),
3428
+ });
3429
+ export const CreateProjectVariableSchema = z.object({
3430
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3431
+ ...ciVariableFields,
3432
+ });
3433
+ export const UpdateProjectVariableSchema = z.object({
3434
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3435
+ key: z.string().describe("The key of the variable to update"),
3436
+ value: z.string().describe("The new value of the variable"),
3437
+ variable_type: z.enum(["env_var", "file"]).optional().describe("The type of variable"),
3438
+ protected: z.boolean().optional().describe("Whether the variable is protected"),
3439
+ masked: z.boolean().optional().describe("Whether the variable value is masked in job logs"),
3440
+ raw: z.boolean().optional().describe("Whether the variable is not expanded"),
3441
+ environment_scope: z
3442
+ .string()
3443
+ .optional()
3444
+ .describe("New environment scope to assign to the variable (renames the scope, e.g. '*', 'production'). Use filter.environment_scope to identify which variable to update when multiple share the same key."),
3445
+ description: z.string().optional().describe("Description of the variable"),
3446
+ filter: z
3447
+ .object({ environment_scope: z.string() })
3448
+ .optional()
3449
+ .describe("Identifies which variable to update when multiple variables share the same key across different environment scopes"),
3450
+ });
3451
+ export const DeleteProjectVariableSchema = z.object({
3452
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3453
+ key: z.string().describe("The key of the variable to delete"),
3454
+ filter: z
3455
+ .object({ environment_scope: z.string() })
3456
+ .optional()
3457
+ .describe("Filter by environment scope to disambiguate when multiple variables share the same key"),
3458
+ });
3459
+ export const ListGroupVariablesSchema = z
3460
+ .object({
3461
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
3462
+ filter: z
3463
+ .object({ environment_scope: z.string() })
3464
+ .optional()
3465
+ .describe("Filter by environment scope (e.g. '*', 'production')"),
3466
+ })
3467
+ .merge(PaginationOptionsSchema);
3468
+ export const GetGroupVariableSchema = z.object({
3469
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
3470
+ key: z.string().describe("The key of the variable"),
3471
+ filter: z
3472
+ .object({ environment_scope: z.string() })
3473
+ .optional()
3474
+ .describe("Filter by environment scope"),
3475
+ });
3476
+ export const CreateGroupVariableSchema = z.object({
3477
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
3478
+ ...ciVariableFields,
3479
+ });
3480
+ export const UpdateGroupVariableSchema = z.object({
3481
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
3482
+ key: z.string().describe("The key of the variable to update"),
3483
+ value: z.string().describe("The new value of the variable"),
3484
+ variable_type: z.enum(["env_var", "file"]).optional().describe("The type of variable"),
3485
+ protected: z.boolean().optional().describe("Whether the variable is protected"),
3486
+ masked: z.boolean().optional().describe("Whether the variable value is masked in job logs"),
3487
+ raw: z.boolean().optional().describe("Whether the variable is not expanded"),
3488
+ environment_scope: z
3489
+ .string()
3490
+ .optional()
3491
+ .describe("New environment scope to assign to the variable (renames the scope, e.g. '*', 'production'). Use filter.environment_scope to identify which variable to update when multiple share the same key."),
3492
+ description: z.string().optional().describe("Description of the variable"),
3493
+ filter: z
3494
+ .object({ environment_scope: z.string() })
3495
+ .optional()
3496
+ .describe("Identifies which variable to update when multiple variables share the same key across different environment scopes"),
3497
+ });
3498
+ export const DeleteGroupVariableSchema = z.object({
3499
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
3500
+ key: z.string().describe("The key of the variable to delete"),
3501
+ filter: z
3502
+ .object({ environment_scope: z.string() })
3503
+ .optional()
3504
+ .describe("Filter by environment scope to disambiguate when multiple variables share the same key"),
3505
+ });