@zereight/mcp-gitlab 2.1.16 → 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
@@ -139,7 +139,7 @@ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMerg
139
139
  GitLabDiscussionNoteSchema, // Added
140
140
  GitLabDiscussionSchema,
141
141
  // Draft Notes Schemas
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, 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
143
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";
144
144
  import { randomUUID, createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
145
145
  import { pino } from "pino";
@@ -1389,16 +1389,9 @@ async function executeGraphQL(query, variables = {}) {
1389
1389
  * Resolve a project path and issue IID to a work item GraphQL GID.
1390
1390
  */
1391
1391
  async function resolveWorkItemGID(projectId, issueIid) {
1392
- projectId = decodeURIComponent(projectId);
1393
- const effectiveProjectId = getEffectiveProjectId(projectId);
1394
- // First get the project path via REST (needed for GraphQL namespace query)
1395
- const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
1396
- const projectResponse = await fetch(projectUrl.toString(), {
1397
- ...getFetchConfig(),
1398
- });
1399
- await handleGitLabError(projectResponse);
1400
- const project = await projectResponse.json();
1401
- 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);
1402
1395
  // Resolve work item GID via GraphQL
1403
1396
  const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
1404
1397
  namespace(fullPath: $path) {
@@ -1521,15 +1514,7 @@ async function removeIssueParent(projectId, issueIid) {
1521
1514
  * Requires Premium/Ultimate with configurable statuses enabled.
1522
1515
  */
1523
1516
  async function listIssueStatuses(projectId, workItemType = "issue") {
1524
- projectId = decodeURIComponent(projectId);
1525
- const effectiveProjectId = getEffectiveProjectId(projectId);
1526
- // Get project path
1527
- const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
1528
- const projectResponse = await fetch(projectUrl.toString(), {
1529
- ...getFetchConfig(),
1530
- });
1531
- await handleGitLabError(projectResponse);
1532
- const project = await projectResponse.json();
1517
+ const projectPath = await resolveProjectPath(projectId);
1533
1518
  const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
1534
1519
  const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
1535
1520
  namespace(fullPath: $path) {
@@ -1557,7 +1542,7 @@ async function listIssueStatuses(projectId, workItemType = "issue") {
1557
1542
  }
1558
1543
  }
1559
1544
  }
1560
- }`, { path: project.path_with_namespace, typeName: typeName.replace(/ /g, "_").toUpperCase() });
1545
+ }`, { path: projectPath, typeName: typeName.replace(/ /g, "_").toUpperCase() });
1561
1546
  const typeNodes = data.namespace?.workItemTypes?.nodes;
1562
1547
  if (!typeNodes || typeNodes.length === 0) {
1563
1548
  throw new Error(`Work item type '${typeName}' not found in project`);
@@ -1953,6 +1938,22 @@ async function resolveProjectPath(projectId) {
1953
1938
  const projectResponse = await fetch(projectUrl.toString(), {
1954
1939
  ...getFetchConfig(),
1955
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
+ }
1956
1957
  await handleGitLabError(projectResponse);
1957
1958
  const project = await projectResponse.json();
1958
1959
  return project.path_with_namespace;
@@ -5741,6 +5742,139 @@ async function listGroupIterations(groupId, options = {}) {
5741
5742
  const data = await response.json();
5742
5743
  return z.array(GroupIteration).parse(data);
5743
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
+ }
5744
5878
  /**
5745
5879
  * Upload a file to a GitLab project for use in markdown content.
5746
5880
  *
@@ -7711,6 +7845,82 @@ async function handleToolCall(params) {
7711
7845
  content: [{ type: "text", text: JSON.stringify(iterations, null, 2) }],
7712
7846
  };
7713
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
+ }
7714
7924
  case "upload_markdown": {
7715
7925
  if (IS_REMOTE) {
7716
7926
  const args = MarkdownUploadRemoteSchema.parse(params.arguments);
package/build/schemas.js CHANGED
@@ -3057,14 +3057,15 @@ export const GitLabTagSignatureSchema = z.object({
3057
3057
  // --- Work item schemas (GraphQL-based) ---
3058
3058
  // Case-insensitive work item type enum (accepts "ISSUE", "Issue", "issue")
3059
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)");
3060
3061
  // Common params for work item tools
3061
3062
  const WorkItemParamsSchema = z.object({
3062
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3063
+ project_id: ProjectIdOrPathSchema,
3063
3064
  iid: z.coerce.number().describe("The internal ID (IID) of the work item"),
3064
3065
  });
3065
3066
  export const GetWorkItemSchema = WorkItemParamsSchema;
3066
3067
  export const ListWorkItemsSchema = z.object({
3067
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3068
+ project_id: ProjectIdOrPathSchema,
3068
3069
  types: z
3069
3070
  .array(workItemTypeEnum)
3070
3071
  .optional()
@@ -3096,7 +3097,7 @@ export const ListWorkItemsSchema = z.object({
3096
3097
  .describe("Cursor for pagination (from previous response's endCursor)"),
3097
3098
  });
3098
3099
  export const CreateWorkItemSchema = z.object({
3099
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3100
+ project_id: ProjectIdOrPathSchema,
3100
3101
  title: z.string().describe("Title of the work item"),
3101
3102
  type: workItemTypeEnum
3102
3103
  .optional()
@@ -3166,38 +3167,38 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
3166
3167
  .describe("Incident only: set escalation status"),
3167
3168
  });
3168
3169
  export const ConvertWorkItemTypeSchema = z.object({
3169
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3170
+ project_id: ProjectIdOrPathSchema,
3170
3171
  iid: z.coerce.number().describe("The internal ID of the work item"),
3171
3172
  new_type: workItemTypeEnum.describe("The target work item type to convert to"),
3172
3173
  });
3173
3174
  export const ListWorkItemStatusesSchema = z.object({
3174
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3175
+ project_id: ProjectIdOrPathSchema,
3175
3176
  work_item_type: workItemTypeEnum
3176
3177
  .optional()
3177
3178
  .default("issue")
3178
3179
  .describe("The work item type to list available statuses for. Defaults to 'issue'."),
3179
3180
  });
3180
3181
  export const ListWorkItemNotesSchema = z.object({
3181
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3182
+ project_id: ProjectIdOrPathSchema,
3182
3183
  iid: z.coerce.number().describe("The internal ID of the work item"),
3183
3184
  page_size: z.coerce.number().optional().default(20).describe("Number of discussions to return (default 20)"),
3184
3185
  after: z.string().optional().describe("Cursor for pagination"),
3185
3186
  sort: z.enum(["CREATED_ASC", "CREATED_DESC"]).optional().default("CREATED_ASC").describe("Sort order for discussions"),
3186
3187
  });
3187
3188
  export const CreateWorkItemNoteSchema = z.object({
3188
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3189
+ project_id: ProjectIdOrPathSchema,
3189
3190
  iid: z.coerce.number().describe("The internal ID of the work item"),
3190
3191
  body: z.string().describe("Note body (Markdown supported)"),
3191
3192
  internal: z.coerce.boolean().optional().default(false).describe("Create as internal/confidential note (only visible to project members)"),
3192
3193
  discussion_id: z.string().optional().describe("Discussion ID to reply to (for threaded replies). If omitted, creates a new top-level note."),
3193
3194
  });
3194
3195
  export const MoveWorkItemSchema = z.object({
3195
- 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"),
3196
3197
  iid: z.coerce.number().describe("The internal ID of the work item to move"),
3197
- 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"),
3198
3199
  });
3199
3200
  export const ListCustomFieldDefinitionsSchema = z.object({
3200
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3201
+ project_id: ProjectIdOrPathSchema,
3201
3202
  work_item_type: workItemTypeEnum
3202
3203
  .optional()
3203
3204
  .default("issue")
@@ -3249,23 +3250,23 @@ export const DeleteIssueNoteEmojiReactionSchema = ProjectParamsSchema.extend({
3249
3250
  });
3250
3251
  // --- Emoji Reaction schemas (GraphQL: Work Items) ---
3251
3252
  export const CreateWorkItemEmojiReactionSchema = z.object({
3252
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3253
+ project_id: ProjectIdOrPathSchema,
3253
3254
  iid: z.coerce.number().describe("The internal ID of the work item"),
3254
3255
  name: emojiNameField,
3255
3256
  });
3256
3257
  export const DeleteWorkItemEmojiReactionSchema = z.object({
3257
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3258
+ project_id: ProjectIdOrPathSchema,
3258
3259
  iid: z.coerce.number().describe("The internal ID of the work item"),
3259
3260
  name: emojiNameField,
3260
3261
  });
3261
3262
  export const CreateWorkItemNoteEmojiReactionSchema = z.object({
3262
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3263
+ project_id: ProjectIdOrPathSchema,
3263
3264
  iid: z.coerce.number().describe("The internal ID of the work item"),
3264
3265
  note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
3265
3266
  name: emojiNameField,
3266
3267
  });
3267
3268
  export const DeleteWorkItemNoteEmojiReactionSchema = z.object({
3268
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3269
+ project_id: ProjectIdOrPathSchema,
3269
3270
  iid: z.coerce.number().describe("The internal ID of the work item"),
3270
3271
  note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
3271
3272
  name: emojiNameField,
@@ -3287,11 +3288,11 @@ export const ListIssueNoteEmojiReactionsSchema = ProjectParamsSchema.extend({
3287
3288
  discussion_id: noteEmojiDiscussionField,
3288
3289
  });
3289
3290
  export const ListWorkItemEmojiReactionsSchema = z.object({
3290
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3291
+ project_id: ProjectIdOrPathSchema,
3291
3292
  iid: z.coerce.number().describe("The internal ID of the work item"),
3292
3293
  });
3293
3294
  export const ListWorkItemNoteEmojiReactionsSchema = z.object({
3294
- project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
3295
+ project_id: ProjectIdOrPathSchema,
3295
3296
  iid: z.coerce.number().describe("The internal ID of the work item"),
3296
3297
  note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
3297
3298
  });
@@ -3377,3 +3378,128 @@ export const GetWebhookEventSchema = z
3377
3378
  message: "Provide exactly one of project_id or group_id",
3378
3379
  });
3379
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
+ });
@@ -0,0 +1,306 @@
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-ci-variables";
6
+ const TEST_PROJECT_ID = "123";
7
+ const TEST_GROUP_ID = "my-group";
8
+ const TEST_VAR_KEY = "DB_URL";
9
+ const TEST_GROUP_VAR_KEY = "SHARED_SECRET";
10
+ const TEST_HIDDEN_VAR_KEY = "HIDDEN_VAR";
11
+ const TEST_SCOPE = "production";
12
+ const MOCK_PROJECT_VARIABLE = {
13
+ variable_type: "env_var",
14
+ key: TEST_VAR_KEY,
15
+ value: "postgres://localhost/db",
16
+ protected: false,
17
+ masked: true,
18
+ raw: false,
19
+ environment_scope: "*",
20
+ description: "Database connection URL",
21
+ };
22
+ const MOCK_GROUP_VARIABLE = {
23
+ variable_type: "env_var",
24
+ key: TEST_GROUP_VAR_KEY,
25
+ value: "s3cr3t",
26
+ protected: false,
27
+ masked: true,
28
+ raw: false,
29
+ environment_scope: "*",
30
+ description: null,
31
+ };
32
+ const MOCK_HIDDEN_PROJECT_VARIABLE = {
33
+ ...MOCK_PROJECT_VARIABLE,
34
+ key: TEST_HIDDEN_VAR_KEY,
35
+ value: null,
36
+ hidden: true,
37
+ description: null,
38
+ };
39
+ async function callTool(toolName, args, env) {
40
+ return new Promise((resolve, reject) => {
41
+ const proc = spawn("node", ["build/index.js"], {
42
+ stdio: ["pipe", "pipe", "pipe"],
43
+ env: { ...process.env, ...env },
44
+ });
45
+ let output = "";
46
+ let errorOutput = "";
47
+ proc.stdout?.on("data", (d) => (output += d));
48
+ proc.stderr?.on("data", (d) => (errorOutput += d));
49
+ proc.on("close", code => {
50
+ if (code !== 0) {
51
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
52
+ }
53
+ const line = output.split("\n").find(l => l.startsWith("{"));
54
+ if (!line)
55
+ return reject(new Error("No JSON output found"));
56
+ try {
57
+ const response = JSON.parse(line);
58
+ if (response.error) {
59
+ reject(new Error(response.error?.message ?? String(response.error)));
60
+ }
61
+ else {
62
+ const content = response.result?.content?.[0]?.text;
63
+ resolve(content ? JSON.parse(content) : response.result);
64
+ }
65
+ }
66
+ catch (e) {
67
+ reject(e);
68
+ }
69
+ });
70
+ proc.stdin?.end(JSON.stringify({
71
+ jsonrpc: "2.0",
72
+ id: 1,
73
+ method: "tools/call",
74
+ params: { name: toolName, arguments: args },
75
+ }) + "\n");
76
+ });
77
+ }
78
+ describe("CI/CD variable tools", () => {
79
+ let mockServer;
80
+ let mockPort;
81
+ let baseEnv;
82
+ let lastReceivedFilterScope;
83
+ before(async () => {
84
+ mockPort = await findMockServerPort();
85
+ mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
86
+ // --- Project variable endpoints ---
87
+ mockServer.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/variables`, (_req, res) => {
88
+ res.json([MOCK_PROJECT_VARIABLE]);
89
+ });
90
+ mockServer.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/variables/${TEST_VAR_KEY}`, (req, res) => {
91
+ const scope = req.query["filter[environment_scope]"];
92
+ lastReceivedFilterScope = scope;
93
+ res.json({ ...MOCK_PROJECT_VARIABLE, environment_scope: scope ?? "*" });
94
+ });
95
+ mockServer.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/variables/${TEST_HIDDEN_VAR_KEY}`, (_req, res) => {
96
+ res.json(MOCK_HIDDEN_PROJECT_VARIABLE);
97
+ });
98
+ mockServer.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/variables`, (req, res) => {
99
+ res.status(201).json({ ...MOCK_PROJECT_VARIABLE, ...req.body });
100
+ });
101
+ mockServer.addMockHandler("put", `/projects/${TEST_PROJECT_ID}/variables/${TEST_VAR_KEY}`, (req, res) => {
102
+ const scope = req.query["filter[environment_scope]"];
103
+ lastReceivedFilterScope = scope;
104
+ res.json({ ...MOCK_PROJECT_VARIABLE, ...req.body, environment_scope: scope ?? "*" });
105
+ });
106
+ mockServer.addMockHandler("delete", `/projects/${TEST_PROJECT_ID}/variables/${TEST_VAR_KEY}`, (req, res) => {
107
+ lastReceivedFilterScope = req.query["filter[environment_scope]"];
108
+ res.status(204).send();
109
+ });
110
+ // --- Group variable endpoints ---
111
+ mockServer.addMockHandler("get", `/groups/${TEST_GROUP_ID}/variables`, (_req, res) => {
112
+ res.json([MOCK_GROUP_VARIABLE]);
113
+ });
114
+ mockServer.addMockHandler("get", `/groups/${TEST_GROUP_ID}/variables/${TEST_GROUP_VAR_KEY}`, (_req, res) => {
115
+ res.json(MOCK_GROUP_VARIABLE);
116
+ });
117
+ mockServer.addMockHandler("post", `/groups/${TEST_GROUP_ID}/variables`, (req, res) => {
118
+ res.status(201).json({ ...MOCK_GROUP_VARIABLE, ...req.body });
119
+ });
120
+ mockServer.addMockHandler("put", `/groups/${TEST_GROUP_ID}/variables/${TEST_GROUP_VAR_KEY}`, (req, res) => {
121
+ res.json({ ...MOCK_GROUP_VARIABLE, ...req.body });
122
+ });
123
+ mockServer.addMockHandler("delete", `/groups/${TEST_GROUP_ID}/variables/${TEST_GROUP_VAR_KEY}`, (_req, res) => {
124
+ res.status(204).send();
125
+ });
126
+ await mockServer.start();
127
+ baseEnv = {
128
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
129
+ GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
130
+ GITLAB_TOOLSETS: "variables",
131
+ };
132
+ });
133
+ after(async () => {
134
+ await mockServer.stop();
135
+ });
136
+ // --- Project variable tests ---
137
+ test("list_project_variables returns variable array", async () => {
138
+ const result = await callTool("list_project_variables", { project_id: TEST_PROJECT_ID }, baseEnv);
139
+ assert.ok(Array.isArray(result));
140
+ assert.strictEqual(result.length, 1);
141
+ assert.strictEqual(result[0].key, TEST_VAR_KEY);
142
+ assert.strictEqual(result[0].value, MOCK_PROJECT_VARIABLE.value);
143
+ assert.strictEqual(result[0].masked, true);
144
+ });
145
+ test("get_project_variable returns single variable", async () => {
146
+ const result = await callTool("get_project_variable", { project_id: TEST_PROJECT_ID, key: TEST_VAR_KEY }, baseEnv);
147
+ assert.strictEqual(result.key, TEST_VAR_KEY);
148
+ assert.strictEqual(result.environment_scope, "*");
149
+ assert.strictEqual(result.variable_type, "env_var");
150
+ });
151
+ test("get_project_variable returns hidden variable with null value", async () => {
152
+ const result = await callTool("get_project_variable", { project_id: TEST_PROJECT_ID, key: TEST_HIDDEN_VAR_KEY }, baseEnv);
153
+ assert.strictEqual(result.key, TEST_HIDDEN_VAR_KEY);
154
+ assert.strictEqual(result.value, null);
155
+ assert.strictEqual(result.hidden, true);
156
+ });
157
+ test("create_project_variable returns created variable", async () => {
158
+ const result = await callTool("create_project_variable", {
159
+ project_id: TEST_PROJECT_ID,
160
+ key: TEST_VAR_KEY,
161
+ value: "new-value",
162
+ masked: true,
163
+ }, baseEnv);
164
+ assert.strictEqual(result.key, TEST_VAR_KEY);
165
+ assert.strictEqual(result.value, "new-value");
166
+ });
167
+ test("update_project_variable returns updated variable", async () => {
168
+ const result = await callTool("update_project_variable", {
169
+ project_id: TEST_PROJECT_ID,
170
+ key: TEST_VAR_KEY,
171
+ value: "updated-value",
172
+ }, baseEnv);
173
+ assert.strictEqual(result.value, "updated-value");
174
+ });
175
+ test("delete_project_variable returns success status", async () => {
176
+ const result = await callTool("delete_project_variable", { project_id: TEST_PROJECT_ID, key: TEST_VAR_KEY }, baseEnv);
177
+ assert.strictEqual(result.status, "success");
178
+ assert.ok(result.message.includes(TEST_VAR_KEY));
179
+ });
180
+ // --- Group variable tests ---
181
+ test("list_group_variables returns variable array", async () => {
182
+ const result = await callTool("list_group_variables", { group_id: TEST_GROUP_ID }, baseEnv);
183
+ assert.ok(Array.isArray(result));
184
+ assert.strictEqual(result.length, 1);
185
+ assert.strictEqual(result[0].key, TEST_GROUP_VAR_KEY);
186
+ assert.strictEqual(result[0].masked, true);
187
+ });
188
+ test("get_group_variable returns single variable", async () => {
189
+ const result = await callTool("get_group_variable", { group_id: TEST_GROUP_ID, key: TEST_GROUP_VAR_KEY }, baseEnv);
190
+ assert.strictEqual(result.key, TEST_GROUP_VAR_KEY);
191
+ assert.strictEqual(result.variable_type, "env_var");
192
+ });
193
+ test("create_group_variable returns created variable", async () => {
194
+ const result = await callTool("create_group_variable", {
195
+ group_id: TEST_GROUP_ID,
196
+ key: TEST_GROUP_VAR_KEY,
197
+ value: "new-secret",
198
+ masked: true,
199
+ }, baseEnv);
200
+ assert.strictEqual(result.key, TEST_GROUP_VAR_KEY);
201
+ assert.strictEqual(result.value, "new-secret");
202
+ });
203
+ test("update_group_variable returns updated variable", async () => {
204
+ const result = await callTool("update_group_variable", {
205
+ group_id: TEST_GROUP_ID,
206
+ key: TEST_GROUP_VAR_KEY,
207
+ value: "updated-secret",
208
+ }, baseEnv);
209
+ assert.strictEqual(result.value, "updated-secret");
210
+ });
211
+ test("delete_group_variable returns success status", async () => {
212
+ const result = await callTool("delete_group_variable", { group_id: TEST_GROUP_ID, key: TEST_GROUP_VAR_KEY }, baseEnv);
213
+ assert.strictEqual(result.status, "success");
214
+ assert.ok(result.message.includes(TEST_GROUP_VAR_KEY));
215
+ });
216
+ // --- filter[environment_scope] tests ---
217
+ test("get_project_variable passes filter[environment_scope] to GitLab", async () => {
218
+ lastReceivedFilterScope = undefined;
219
+ const result = await callTool("get_project_variable", { project_id: TEST_PROJECT_ID, key: TEST_VAR_KEY, filter: { environment_scope: TEST_SCOPE } }, baseEnv);
220
+ assert.strictEqual(lastReceivedFilterScope, TEST_SCOPE);
221
+ assert.strictEqual(result.environment_scope, TEST_SCOPE);
222
+ });
223
+ test("update_project_variable passes filter[environment_scope] to GitLab", async () => {
224
+ lastReceivedFilterScope = undefined;
225
+ const result = await callTool("update_project_variable", {
226
+ project_id: TEST_PROJECT_ID,
227
+ key: TEST_VAR_KEY,
228
+ value: "scoped-value",
229
+ filter: { environment_scope: TEST_SCOPE },
230
+ }, baseEnv);
231
+ assert.strictEqual(lastReceivedFilterScope, TEST_SCOPE);
232
+ assert.strictEqual(result.environment_scope, TEST_SCOPE);
233
+ });
234
+ test("delete_project_variable passes filter[environment_scope] to GitLab", async () => {
235
+ lastReceivedFilterScope = undefined;
236
+ const result = await callTool("delete_project_variable", { project_id: TEST_PROJECT_ID, key: TEST_VAR_KEY, filter: { environment_scope: TEST_SCOPE } }, baseEnv);
237
+ assert.strictEqual(lastReceivedFilterScope, TEST_SCOPE);
238
+ assert.strictEqual(result.status, "success");
239
+ });
240
+ // --- Toolset behaviour ---
241
+ test("variables tools are absent when toolset is not activated", async () => {
242
+ return new Promise((resolve, reject) => {
243
+ const proc = spawn("node", ["build/index.js"], {
244
+ stdio: ["pipe", "pipe", "pipe"],
245
+ env: {
246
+ ...process.env,
247
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
248
+ GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
249
+ // No GITLAB_TOOLSETS — default toolsets only
250
+ },
251
+ });
252
+ let output = "";
253
+ proc.stdout?.on("data", (d) => (output += d));
254
+ proc.on("close", () => {
255
+ try {
256
+ const line = output.split("\n").find(l => l.startsWith("{"));
257
+ if (!line)
258
+ return reject(new Error("No JSON output found"));
259
+ const response = JSON.parse(line);
260
+ const names = (response.result?.tools ?? []).map((t) => t.name);
261
+ assert.ok(!names.includes("list_project_variables"), "tool should not be in default toolset");
262
+ assert.ok(!names.includes("create_project_variable"), "tool should not be in default toolset");
263
+ assert.ok(!names.includes("list_group_variables"), "tool should not be in default toolset");
264
+ resolve();
265
+ }
266
+ catch (e) {
267
+ reject(e);
268
+ }
269
+ });
270
+ proc.stdin?.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) + "\n");
271
+ });
272
+ });
273
+ test("write tools are absent from tools/list in read-only mode", async () => {
274
+ return new Promise((resolve, reject) => {
275
+ const proc = spawn("node", ["build/index.js"], {
276
+ stdio: ["pipe", "pipe", "pipe"],
277
+ env: { ...process.env, ...baseEnv, GITLAB_READ_ONLY_MODE: "true" },
278
+ });
279
+ let output = "";
280
+ proc.stdout?.on("data", (d) => (output += d));
281
+ proc.on("close", () => {
282
+ try {
283
+ const line = output.split("\n").find(l => l.startsWith("{"));
284
+ if (!line)
285
+ return reject(new Error("No JSON output found"));
286
+ const response = JSON.parse(line);
287
+ const names = (response.result?.tools ?? []).map((t) => t.name);
288
+ assert.ok(!names.includes("create_project_variable"), "create should be absent in read-only mode");
289
+ assert.ok(!names.includes("update_project_variable"), "update should be absent in read-only mode");
290
+ assert.ok(!names.includes("delete_project_variable"), "delete should be absent in read-only mode");
291
+ assert.ok(!names.includes("create_group_variable"), "create should be absent in read-only mode");
292
+ assert.ok(!names.includes("delete_group_variable"), "delete should be absent in read-only mode");
293
+ assert.ok(names.includes("list_project_variables"), "list should be present in read-only mode");
294
+ assert.ok(names.includes("get_project_variable"), "get should be present in read-only mode");
295
+ assert.ok(names.includes("list_group_variables"), "list should be present in read-only mode");
296
+ assert.ok(names.includes("get_group_variable"), "get should be present in read-only mode");
297
+ resolve();
298
+ }
299
+ catch (e) {
300
+ reject(e);
301
+ }
302
+ });
303
+ proc.stdin?.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) + "\n");
304
+ });
305
+ });
306
+ });
@@ -271,6 +271,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
271
271
  REMOTE_AUTHORIZATION: 'true',
272
272
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
273
273
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
274
+ GITLAB_TOOLSETS: 'variables',
274
275
  }
275
276
  });
276
277
  servers.push(server);
@@ -317,6 +318,26 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
317
318
  assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
318
319
  }
319
320
  });
321
+ test('should reject list_group_variables when GITLAB_PROJECT_ID is set', async () => {
322
+ try {
323
+ await client.callTool('list_group_variables', { group_id: 'my-group' });
324
+ assert.fail('Should have rejected list_group_variables');
325
+ }
326
+ catch (error) {
327
+ assert.ok(error instanceof Error);
328
+ assert.ok(error.message.includes('list_group_variables is not allowed'), 'Should mention list_group_variables');
329
+ }
330
+ });
331
+ test('should reject get_group_variable when GITLAB_PROJECT_ID is set', async () => {
332
+ try {
333
+ await client.callTool('get_group_variable', { group_id: 'my-group', key: 'SHARED_SECRET' });
334
+ assert.fail('Should have rejected get_group_variable');
335
+ }
336
+ catch (error) {
337
+ assert.ok(error instanceof Error);
338
+ assert.ok(error.message.includes('get_group_variable is not allowed'), 'Should mention get_group_variable');
339
+ }
340
+ });
320
341
  test('should allow get_project (non-mutator) when GITLAB_PROJECT_ID is set', async () => {
321
342
  const result = await client.callTool('get_project', { project_id: '' });
322
343
  assert.ok(result.content, 'Should have content');
@@ -348,6 +369,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
348
369
  REMOTE_AUTHORIZATION: 'true',
349
370
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
350
371
  GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
372
+ GITLAB_TOOLSETS: 'variables',
351
373
  }
352
374
  });
353
375
  servers.push(server);
@@ -394,6 +416,26 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
394
416
  assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
395
417
  }
396
418
  });
419
+ test('should reject list_group_variables with GITLAB_ALLOWED_PROJECT_IDS', async () => {
420
+ try {
421
+ await client.callTool('list_group_variables', { group_id: 'my-group' });
422
+ assert.fail('Should have rejected list_group_variables');
423
+ }
424
+ catch (error) {
425
+ assert.ok(error instanceof Error);
426
+ assert.ok(error.message.includes('list_group_variables is not allowed'), 'Should mention list_group_variables');
427
+ }
428
+ });
429
+ test('should reject get_group_variable with GITLAB_ALLOWED_PROJECT_IDS', async () => {
430
+ try {
431
+ await client.callTool('get_group_variable', { group_id: 'my-group', key: 'SHARED_SECRET' });
432
+ assert.fail('Should have rejected get_group_variable');
433
+ }
434
+ catch (error) {
435
+ assert.ok(error instanceof Error);
436
+ assert.ok(error.message.includes('get_group_variable is not allowed'), 'Should mention get_group_variable');
437
+ }
438
+ });
397
439
  test('should allow get_project (non-mutator) with GITLAB_ALLOWED_PROJECT_IDS', async () => {
398
440
  const result = await client.callTool('get_project', { project_id: '' });
399
441
  assert.ok(result.content, 'Should have content');
@@ -33,6 +33,7 @@ const TOOLSET_TOOL_COUNTS = {
33
33
  workitems: 18,
34
34
  webhooks: 3,
35
35
  groups: 1,
36
+ variables: 10,
36
37
  };
37
38
  const LEGACY_PIPELINE_TOOL_COUNT = TOOLSET_TOOL_COUNTS.pipelines + TOOLSET_TOOL_COUNTS.ci;
38
39
  const DEFAULT_TOOLSETS = [
@@ -55,6 +56,7 @@ const NON_DEFAULT_TOOLSETS = [
55
56
  "workitems",
56
57
  "webhooks",
57
58
  "search",
59
+ "variables",
58
60
  ];
59
61
  // discover_tools meta-tool is always force-injected (Step 5.5)
60
62
  const DISCOVER_TOOLS_COUNT = 1;
@@ -78,6 +80,7 @@ const TOOLSET_SAMPLE_TOOLS = {
78
80
  search: ["search_code", "search_project_code", "search_group_code"],
79
81
  webhooks: ["list_webhooks", "list_webhook_events", "get_webhook_event"],
80
82
  groups: ["create_group"],
83
+ variables: ["list_project_variables", "create_project_variable", "delete_project_variable", "list_group_variables", "create_group_variable", "delete_group_variable"],
81
84
  };
82
85
  // --- Helpers ---
83
86
  async function launchMcpServer(mockGitLabUrl, mcpPort, extraEnv = {}) {
@@ -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, SSE, STREAMABLE_HTTP, } from "../config.js";
4
- import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, 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, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, 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, MarkdownUploadRemoteSchema, 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";
4
+ import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, 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, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, 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, MarkdownUploadRemoteSchema, 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, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, } from "../schemas.js";
5
5
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
6
6
  // Define all available tools
7
7
  export const allTools = [
@@ -931,6 +931,57 @@ export const allTools = [
931
931
  description: "Search for code within a specific group (requires advanced search or Zoekt)",
932
932
  inputSchema: toJSONSchema(SearchGroupCodeSchema),
933
933
  },
934
+ // --- CI/CD Variable tools ---
935
+ {
936
+ name: "list_project_variables",
937
+ description: "List CI/CD variables for a project",
938
+ inputSchema: toJSONSchema(ListProjectVariablesSchema),
939
+ },
940
+ {
941
+ name: "get_project_variable",
942
+ description: "Get a single CI/CD variable from a project",
943
+ inputSchema: toJSONSchema(GetProjectVariableSchema),
944
+ },
945
+ {
946
+ name: "create_project_variable",
947
+ description: "Create a CI/CD variable for a project",
948
+ inputSchema: toJSONSchema(CreateProjectVariableSchema),
949
+ },
950
+ {
951
+ name: "update_project_variable",
952
+ description: "Update an existing CI/CD variable in a project",
953
+ inputSchema: toJSONSchema(UpdateProjectVariableSchema),
954
+ },
955
+ {
956
+ name: "delete_project_variable",
957
+ description: "Delete a CI/CD variable from a project",
958
+ inputSchema: toJSONSchema(DeleteProjectVariableSchema),
959
+ },
960
+ {
961
+ name: "list_group_variables",
962
+ description: "List CI/CD variables for a group",
963
+ inputSchema: toJSONSchema(ListGroupVariablesSchema),
964
+ },
965
+ {
966
+ name: "get_group_variable",
967
+ description: "Get a single CI/CD variable from a group",
968
+ inputSchema: toJSONSchema(GetGroupVariableSchema),
969
+ },
970
+ {
971
+ name: "create_group_variable",
972
+ description: "Create a CI/CD variable for a group",
973
+ inputSchema: toJSONSchema(CreateGroupVariableSchema),
974
+ },
975
+ {
976
+ name: "update_group_variable",
977
+ description: "Update an existing CI/CD variable in a group",
978
+ inputSchema: toJSONSchema(UpdateGroupVariableSchema),
979
+ },
980
+ {
981
+ name: "delete_group_variable",
982
+ description: "Delete a CI/CD variable from a group",
983
+ inputSchema: toJSONSchema(DeleteGroupVariableSchema),
984
+ },
934
985
  // --- Meta tool: Dynamic tool discovery ---
935
986
  {
936
987
  name: "discover_tools",
@@ -1050,6 +1101,10 @@ export const readOnlyTools = new Set([
1050
1101
  "list_webhooks",
1051
1102
  "list_webhook_events",
1052
1103
  "get_webhook_event",
1104
+ "list_project_variables",
1105
+ "get_project_variable",
1106
+ "list_group_variables",
1107
+ "get_group_variable",
1053
1108
  ]);
1054
1109
  // Define which tools are destructive (data loss potential)
1055
1110
  export const destructiveTools = new Set([
@@ -1073,7 +1128,8 @@ export const destructiveTools = new Set([
1073
1128
  "delete_branch",
1074
1129
  "merge_merge_request",
1075
1130
  "push_files",
1076
- "delete_branch",
1131
+ "delete_project_variable",
1132
+ "delete_group_variable",
1077
1133
  ]);
1078
1134
  // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
1079
1135
  export const wikiToolNames = new Set([
@@ -1401,6 +1457,22 @@ export const TOOLSET_DEFINITIONS = [
1401
1457
  isDefault: false,
1402
1458
  tools: new Set(["search_code", "search_project_code", "search_group_code"]),
1403
1459
  },
1460
+ {
1461
+ id: "variables",
1462
+ isDefault: false,
1463
+ tools: new Set([
1464
+ "list_project_variables",
1465
+ "get_project_variable",
1466
+ "create_project_variable",
1467
+ "update_project_variable",
1468
+ "delete_project_variable",
1469
+ "list_group_variables",
1470
+ "get_group_variable",
1471
+ "create_group_variable",
1472
+ "update_group_variable",
1473
+ "delete_group_variable",
1474
+ ]),
1475
+ },
1404
1476
  ];
1405
1477
  // Derived lookup: tool name → toolset ID
1406
1478
  export const TOOLSET_BY_TOOL_NAME = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.16",
3
+ "version": "2.1.17",
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-upload-markdown.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/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.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 && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.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-upload-markdown.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/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.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 && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.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",