@zereight/mcp-gitlab 2.1.51 → 2.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/schemas.js CHANGED
@@ -522,6 +522,226 @@ export const CancelPipelineSchema = z.object({
522
522
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
523
523
  pipeline_id: z.coerce.string().describe("The ID of the pipeline to cancel"),
524
524
  });
525
+ // --- Pipeline schedule related schemas ---
526
+ export const GitLabPipelineScheduleVariableSchema = z.object({
527
+ key: z.string(),
528
+ value: z.string().nullable(),
529
+ variable_type: z.enum(["env_var", "file"]).optional(),
530
+ raw: z.coerce.boolean().optional(),
531
+ });
532
+ export const GitLabPipelineScheduleSchema = z.object({
533
+ id: z.coerce.string(),
534
+ description: z.string(),
535
+ ref: z.string().nullable().optional(),
536
+ cron: z.string(),
537
+ cron_timezone: z.string().optional(),
538
+ next_run_at: z.string().nullable().optional(),
539
+ active: z.coerce.boolean(),
540
+ created_at: z.string(),
541
+ updated_at: z.string(),
542
+ owner: z
543
+ .object({
544
+ id: z.coerce.string(),
545
+ name: z.string().optional(),
546
+ username: z.string().optional(),
547
+ state: z.string().optional(),
548
+ avatar_url: z.string().nullable().optional(),
549
+ web_url: z.string().optional(),
550
+ })
551
+ .nullable()
552
+ .optional(),
553
+ last_pipeline: z
554
+ .object({
555
+ id: z.coerce.string(),
556
+ sha: z.string().optional(),
557
+ ref: z.string().optional(),
558
+ status: z.string().optional(),
559
+ })
560
+ .nullable()
561
+ .optional(),
562
+ // Only returned to Maintainers/Owners or the schedule owner.
563
+ variables: z.array(GitLabPipelineScheduleVariableSchema).optional(),
564
+ inputs: z
565
+ .array(z.object({
566
+ name: z.string(),
567
+ value: z.unknown(),
568
+ }))
569
+ .optional(),
570
+ });
571
+ // GitLab answers POST .../play with `{"message": "201 Created"}` rather than the pipeline.
572
+ export const GitLabPipelineSchedulePlayResultSchema = z.object({
573
+ message: z.string().optional(),
574
+ });
575
+ // A CI/CD input is typed string, number, boolean, or array — a map is not a
576
+ // top-level input type, though array items may themselves be maps or sequences.
577
+ const pipelineScheduleInputValue = z
578
+ .union([z.string(), z.number(), z.boolean(), z.array(z.unknown())])
579
+ .describe("The value of the input; a string, number, boolean, or array whose items may be scalars, sequences, or maps");
580
+ // Schema for listing pipeline schedules
581
+ export const ListPipelineSchedulesSchema = z
582
+ .object({
583
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
584
+ scope: z
585
+ .enum(["active", "inactive"])
586
+ .optional()
587
+ .describe("Return only active or only inactive pipeline schedules"),
588
+ })
589
+ .merge(PaginationOptionsSchema);
590
+ // Schema for getting a specific pipeline schedule
591
+ export const GetPipelineScheduleSchema = z.object({
592
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
593
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule"),
594
+ });
595
+ // Schema for listing the pipelines a schedule has triggered
596
+ export const ListPipelineSchedulePipelinesSchema = z
597
+ .object({
598
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
599
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule"),
600
+ scope: z
601
+ .enum(["running", "pending", "finished", "branches", "tags"])
602
+ .optional()
603
+ .describe("The scope of pipelines"),
604
+ status: z
605
+ .enum([
606
+ "created",
607
+ "waiting_for_resource",
608
+ "preparing",
609
+ "waiting_for_callback",
610
+ "pending",
611
+ "running",
612
+ "success",
613
+ "failed",
614
+ "canceling",
615
+ "canceled",
616
+ "skipped",
617
+ "manual",
618
+ "scheduled",
619
+ ])
620
+ .optional()
621
+ .describe("The status of pipelines"),
622
+ sort: z.enum(["asc", "desc"]).optional().describe("Sort pipelines (default: asc)"),
623
+ updated_after: z
624
+ .string()
625
+ .optional()
626
+ .describe("Return pipelines updated after the specified ISO 8601 date"),
627
+ updated_before: z
628
+ .string()
629
+ .optional()
630
+ .describe("Return pipelines updated before the specified ISO 8601 date"),
631
+ created_after: z
632
+ .string()
633
+ .optional()
634
+ .describe("Return pipelines created after the specified ISO 8601 date"),
635
+ created_before: z
636
+ .string()
637
+ .optional()
638
+ .describe("Return pipelines created before the specified ISO 8601 date"),
639
+ })
640
+ .merge(PaginationOptionsSchema);
641
+ // Schema for creating a pipeline schedule
642
+ export const CreatePipelineScheduleSchema = z.object({
643
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
644
+ description: z.string().describe("The description of the pipeline schedule"),
645
+ ref: z
646
+ .string()
647
+ .describe("The branch or tag the scheduled pipeline runs on. Use the full ref (e.g. 'refs/heads/main') when a branch and tag share a name"),
648
+ cron: z.string().describe("The cron schedule expression, for example `0 1 * * *`"),
649
+ cron_timezone: z
650
+ .string()
651
+ .optional()
652
+ .describe("The time zone for the cron expression, for example 'UTC' (default) or 'Asia/Tokyo'"),
653
+ active: coerceBooleanString
654
+ .optional()
655
+ .describe("Whether the pipeline schedule is active (default: true)"),
656
+ inputs: z
657
+ .array(z.object({
658
+ name: z.string().describe("The name of a 'spec:inputs' entry in the CI/CD configuration"),
659
+ value: pipelineScheduleInputValue,
660
+ }))
661
+ .optional()
662
+ .describe('Inputs passed to the scheduled pipeline, each an object with `name` (a `spec:inputs` entry) and `value` (a string, number, boolean, or array) — for example [{"name": "deploy_strategy", "value": "blue-green"}]. Requires GitLab 18.1 or later'),
663
+ });
664
+ // Schema for updating a pipeline schedule
665
+ export const UpdatePipelineScheduleSchema = z.object({
666
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
667
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule to update"),
668
+ description: z.string().optional().describe("The new description of the pipeline schedule"),
669
+ ref: z
670
+ .string()
671
+ .optional()
672
+ .describe("The new branch or tag the scheduled pipeline runs on. Use the full ref (e.g. 'refs/heads/main') when a branch and tag share a name"),
673
+ cron: z.string().optional().describe("The new cron schedule expression, for example `0 1 * * *`"),
674
+ cron_timezone: z
675
+ .string()
676
+ .optional()
677
+ .describe("The new time zone for the cron expression, for example 'UTC' or 'Asia/Tokyo'"),
678
+ active: coerceBooleanString.optional().describe("Whether the pipeline schedule is active"),
679
+ inputs: z
680
+ .array(z.object({
681
+ name: z.string().describe("The name of a 'spec:inputs' entry in the CI/CD configuration"),
682
+ value: pipelineScheduleInputValue.optional(),
683
+ destroy: coerceBooleanString
684
+ .optional()
685
+ .describe("Set to true to remove this input from the schedule"),
686
+ }))
687
+ .optional()
688
+ .describe('Inputs to add or change, each an object with `name` and `value`; to remove one, pass its `name` with `destroy` set to true — for example [{"name": "deploy_strategy", "destroy": true}]. Requires GitLab 18.1 or later'),
689
+ });
690
+ // Schema for deleting a pipeline schedule
691
+ export const DeletePipelineScheduleSchema = z.object({
692
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
693
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule to delete"),
694
+ });
695
+ // Schema for running a pipeline schedule immediately
696
+ export const PlayPipelineScheduleSchema = z.object({
697
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
698
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule to run"),
699
+ });
700
+ // Schema for taking ownership of a pipeline schedule
701
+ export const TakeOwnershipPipelineScheduleSchema = z.object({
702
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
703
+ pipeline_schedule_id: z.coerce
704
+ .string()
705
+ .describe("The ID of the pipeline schedule to take ownership of"),
706
+ });
707
+ // Schema for getting a pipeline schedule variable
708
+ export const GetPipelineScheduleVariableSchema = z.object({
709
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
710
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule"),
711
+ key: z.string().describe("The key of the variable"),
712
+ });
713
+ // Schema for creating a pipeline schedule variable
714
+ export const CreatePipelineScheduleVariableSchema = z.object({
715
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
716
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule"),
717
+ key: z
718
+ .string()
719
+ .max(255)
720
+ .regex(/^[A-Za-z0-9_]+$/)
721
+ .describe("The key of the variable (max 255 characters, must match /[a-zA-Z0-9_]+/)"),
722
+ value: z.string().describe("The value of the variable"),
723
+ variable_type: z
724
+ .enum(["env_var", "file"])
725
+ .optional()
726
+ .describe("The type of variable: 'env_var' (default) or 'file'"),
727
+ });
728
+ // Schema for updating a pipeline schedule variable
729
+ export const UpdatePipelineScheduleVariableSchema = z.object({
730
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
731
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule"),
732
+ key: z.string().describe("The key of the variable to update"),
733
+ value: z.string().describe("The new value of the variable"),
734
+ variable_type: z
735
+ .enum(["env_var", "file"])
736
+ .optional()
737
+ .describe("The type of variable: 'env_var' or 'file'"),
738
+ });
739
+ // Schema for deleting a pipeline schedule variable
740
+ export const DeletePipelineScheduleVariableSchema = z.object({
741
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
742
+ pipeline_schedule_id: z.coerce.string().describe("The ID of the pipeline schedule"),
743
+ key: z.string().describe("The key of the variable to delete"),
744
+ });
525
745
  // Schema for the input parameters for pipeline job operations
526
746
  export const GetPipelineJobOutputSchema = z.object({
527
747
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -2135,6 +2355,19 @@ export const ListMergeRequestsSchema = z
2135
2355
  .describe("Return more details for each label"),
2136
2356
  })
2137
2357
  .merge(PaginationOptionsSchema);
2358
+ export const ListGroupMergeRequestsSchema = ListMergeRequestsSchema.omit({
2359
+ project_id: true,
2360
+ }).extend({
2361
+ group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2362
+ non_archived: z.coerce
2363
+ .boolean()
2364
+ .optional()
2365
+ .describe("Return merge requests from non-archived projects only. Defaults to true."),
2366
+ source_project_id: z.coerce
2367
+ .string()
2368
+ .optional()
2369
+ .describe("Return merge requests with the given source project ID"),
2370
+ });
2138
2371
  export const GetIssueSchema = z.object({
2139
2372
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2140
2373
  issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
@@ -1,4 +1,4 @@
1
- import { describe, test, before, after } from 'node:test';
1
+ import { describe, test, it, before, after } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { spawn } from 'child_process';
4
4
  import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
@@ -167,15 +167,19 @@ describe('job artifacts tools', () => {
167
167
  assert.strictEqual(result[1].name, 'logs');
168
168
  assert.strictEqual(result[1].type, 'directory');
169
169
  });
170
- test('list_job_artifacts returns artifact entries when mode is null', async () => {
171
- const result = await callTool('list_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: NULL_MODE_JOB_ID }, {
172
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
173
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
170
+ describe('When listing job artifacts', () => {
171
+ describe('with a null mode entry', () => {
172
+ it('should return the entries instead of failing', async () => {
173
+ const result = await callTool('list_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: NULL_MODE_JOB_ID }, {
174
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
175
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
176
+ });
177
+ assert.ok(Array.isArray(result), 'Response should be an array');
178
+ assert.strictEqual(result.length, 2);
179
+ assert.strictEqual(result[0].mode, null);
180
+ assert.strictEqual(result[1].size, null);
181
+ });
174
182
  });
175
- assert.ok(Array.isArray(result), 'Response should be an array');
176
- assert.strictEqual(result.length, 2);
177
- assert.strictEqual(result[0].mode, null);
178
- assert.strictEqual(result[1].size, null);
179
183
  });
180
184
  test('download_job_artifacts saves archive to disk', async () => {
181
185
  const result = await callTool('download_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: TEST_JOB_ID, local_path: tmpDir }, {
@@ -0,0 +1,165 @@
1
+ import { describe, test, before, after, beforeEach } from "node:test";
2
+ import assert from "node:assert";
3
+ import { spawn } from "child_process";
4
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
5
+ const MOCK_TOKEN = "glpat-mock-token-12345";
6
+ const TEST_GROUP_ID = "my-group/sub-group";
7
+ const groupMergeRequests = [
8
+ {
9
+ id: 1,
10
+ iid: 10,
11
+ project_id: 123,
12
+ title: "Group MR 1",
13
+ description: "First MR of the group",
14
+ state: "opened",
15
+ created_at: "2024-01-01T00:00:00Z",
16
+ updated_at: "2024-01-02T00:00:00Z",
17
+ merged_at: null,
18
+ closed_at: null,
19
+ target_branch: "main",
20
+ source_branch: "feature/one",
21
+ web_url: "https://gitlab.mock/my-group/project-a/-/merge_requests/10",
22
+ merge_commit_sha: null,
23
+ author: {
24
+ id: 1,
25
+ username: "test-user",
26
+ name: "Test User",
27
+ },
28
+ },
29
+ {
30
+ id: 2,
31
+ iid: 4,
32
+ project_id: 456,
33
+ title: "Group MR 2",
34
+ description: "Second MR of the group",
35
+ state: "opened",
36
+ created_at: "2024-01-03T00:00:00Z",
37
+ updated_at: "2024-01-04T00:00:00Z",
38
+ merged_at: null,
39
+ closed_at: null,
40
+ target_branch: "main",
41
+ source_branch: "feature/two",
42
+ web_url: "https://gitlab.mock/my-group/project-b/-/merge_requests/4",
43
+ merge_commit_sha: null,
44
+ author: {
45
+ id: 2,
46
+ username: "other-user",
47
+ name: "Other User",
48
+ },
49
+ },
50
+ ];
51
+ async function callListGroupMergeRequests(args, env) {
52
+ return new Promise((resolve, reject) => {
53
+ const proc = spawn("node", ["build/index.js"], {
54
+ stdio: ["pipe", "pipe", "pipe"],
55
+ env: {
56
+ ...process.env,
57
+ ...env,
58
+ GITLAB_READ_ONLY_MODE: "true",
59
+ },
60
+ });
61
+ let output = "";
62
+ let errorOutput = "";
63
+ proc.stdout?.on("data", d => (output += d));
64
+ proc.stderr?.on("data", d => (errorOutput += d));
65
+ proc.on("close", code => {
66
+ if (code !== 0)
67
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
68
+ const line = output.split("\n").find(l => l.startsWith("{"));
69
+ if (!line)
70
+ return reject(new Error("No JSON output found"));
71
+ try {
72
+ const response = JSON.parse(line);
73
+ if (response.error) {
74
+ reject(response.error);
75
+ }
76
+ else {
77
+ const content = response.result?.content?.[0]?.text;
78
+ if (content) {
79
+ try {
80
+ resolve(JSON.parse(content));
81
+ }
82
+ catch {
83
+ reject(new Error(`Failed to parse tool output JSON: ${content}`));
84
+ }
85
+ }
86
+ else {
87
+ resolve(response.result);
88
+ }
89
+ }
90
+ }
91
+ catch (e) {
92
+ reject(e);
93
+ }
94
+ });
95
+ proc.stdin?.end(JSON.stringify({
96
+ jsonrpc: "2.0",
97
+ id: 1,
98
+ method: "tools/call",
99
+ params: { name: "list_group_merge_requests", arguments: args },
100
+ }) + "\n");
101
+ });
102
+ }
103
+ describe("list_group_merge_requests", () => {
104
+ let mockGitLab;
105
+ let mockGitLabUrl;
106
+ let capturedUrl;
107
+ before(async () => {
108
+ const mockPort = await findMockServerPort();
109
+ mockGitLab = new MockGitLabServer({
110
+ port: mockPort,
111
+ validTokens: [MOCK_TOKEN],
112
+ });
113
+ await mockGitLab.start();
114
+ mockGitLabUrl = mockGitLab.getUrl();
115
+ mockGitLab.addMockHandler("get", `/groups/${encodeURIComponent(TEST_GROUP_ID)}/merge_requests`, (req, res) => {
116
+ capturedUrl = req.originalUrl;
117
+ res.json(groupMergeRequests);
118
+ });
119
+ });
120
+ beforeEach(() => {
121
+ capturedUrl = undefined;
122
+ });
123
+ after(async () => {
124
+ await mockGitLab.stop();
125
+ });
126
+ test("lists merge requests of every project in the group", async () => {
127
+ const mrs = await callListGroupMergeRequests({ group_id: TEST_GROUP_ID }, {
128
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
129
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
130
+ });
131
+ assert.ok(Array.isArray(mrs), "Response should be an array");
132
+ assert.strictEqual(mrs.length, 2, "Should return the 2 mock MRs");
133
+ assert.strictEqual(mrs[0].title, "Group MR 1");
134
+ assert.notStrictEqual(String(mrs[0].project_id), String(mrs[1].project_id), "MRs should come from different projects");
135
+ });
136
+ test("forwards group-specific and shared filters as query parameters", async () => {
137
+ await callListGroupMergeRequests({
138
+ group_id: TEST_GROUP_ID,
139
+ state: "opened",
140
+ non_archived: true,
141
+ source_project_id: "123",
142
+ labels: ["backend", "urgent"],
143
+ approved_by_usernames: ["alice", "bob"],
144
+ }, {
145
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
146
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
147
+ });
148
+ assert.ok(capturedUrl, "Mock handler should have received a request");
149
+ assert.match(capturedUrl, /state=opened/, "Request URL should carry state");
150
+ assert.match(capturedUrl, /non_archived=true/, "Request URL should carry non_archived");
151
+ assert.match(capturedUrl, /source_project_id=123/, "Request URL should carry source_project_id");
152
+ assert.match(capturedUrl, /labels=backend%2Curgent/, "Labels should be comma-joined");
153
+ assert.match(capturedUrl, /approved_by_usernames%5B%5D=alice/, "approved_by_usernames should use bracket-array form (URL-encoded)");
154
+ assert.match(capturedUrl, /approved_by_usernames%5B%5D=bob/);
155
+ });
156
+ test("drops the mutually exclusive *_id filter when a *_username is given", async () => {
157
+ await callListGroupMergeRequests({ group_id: TEST_GROUP_ID, author_id: "7", author_username: "alice" }, {
158
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
159
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
160
+ });
161
+ assert.ok(capturedUrl, "Mock handler should have received a request");
162
+ assert.match(capturedUrl, /author_username=alice/, "author_username should be forwarded");
163
+ assert.doesNotMatch(capturedUrl, /author_id=/, "author_id should be dropped");
164
+ });
165
+ });