@zereight/mcp-gitlab 2.1.6 → 2.1.8

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.
@@ -0,0 +1,191 @@
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-ci-lint-test-token";
6
+ const TEST_PROJECT_ID = "123";
7
+ async function callTool(toolName, args, env) {
8
+ return new Promise((resolve, reject) => {
9
+ const proc = spawn("node", ["build/index.js"], {
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ env: {
12
+ ...process.env,
13
+ ...env,
14
+ USE_PIPELINE: "true",
15
+ },
16
+ });
17
+ let output = "";
18
+ let errorOutput = "";
19
+ proc.stdout?.on("data", (d) => (output += d));
20
+ proc.stderr?.on("data", (d) => (errorOutput += d));
21
+ proc.on("close", code => {
22
+ if (code !== 0) {
23
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
24
+ }
25
+ const line = output.split("\n").find(l => l.startsWith("{"));
26
+ if (!line)
27
+ return reject(new Error("No JSON output found"));
28
+ try {
29
+ const response = JSON.parse(line);
30
+ if (response.error) {
31
+ reject(response.error);
32
+ }
33
+ else {
34
+ const content = response.result?.content?.[0]?.text;
35
+ resolve(content ? JSON.parse(content) : response.result);
36
+ }
37
+ }
38
+ catch (e) {
39
+ reject(e);
40
+ }
41
+ });
42
+ proc.stdin?.end(JSON.stringify({
43
+ jsonrpc: "2.0",
44
+ id: 1,
45
+ method: "tools/call",
46
+ params: { name: toolName, arguments: args },
47
+ }) + "\n");
48
+ });
49
+ }
50
+ async function listToolNames(env) {
51
+ return new Promise((resolve, reject) => {
52
+ const proc = spawn("node", ["build/index.js"], {
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ env: {
55
+ ...process.env,
56
+ ...env,
57
+ },
58
+ });
59
+ let output = "";
60
+ let errorOutput = "";
61
+ proc.stdout?.on("data", (d) => (output += d));
62
+ proc.stderr?.on("data", (d) => (errorOutput += d));
63
+ proc.on("close", code => {
64
+ if (code !== 0) {
65
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
66
+ }
67
+ const line = output.split("\n").find(l => l.startsWith("{"));
68
+ if (!line)
69
+ return reject(new Error("No JSON output found"));
70
+ try {
71
+ const response = JSON.parse(line);
72
+ if (response.error) {
73
+ reject(response.error);
74
+ }
75
+ else {
76
+ resolve(response.result.tools.map((tool) => tool.name));
77
+ }
78
+ }
79
+ catch (e) {
80
+ reject(e);
81
+ }
82
+ });
83
+ proc.stdin?.end(JSON.stringify({
84
+ jsonrpc: "2.0",
85
+ id: 1,
86
+ method: "tools/list",
87
+ params: {},
88
+ }) + "\n");
89
+ });
90
+ }
91
+ describe("GitLab CI lint tools", () => {
92
+ let mockGitLab;
93
+ let mockGitLabUrl;
94
+ before(async () => {
95
+ const mockPort = await findMockServerPort(9260);
96
+ mockGitLab = new MockGitLabServer({
97
+ port: mockPort,
98
+ validTokens: [MOCK_TOKEN],
99
+ });
100
+ mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/ci/lint`, (req, res) => {
101
+ assert.strictEqual(req.body.content, "stages: [test]\ntest:\n script: echo ok");
102
+ assert.strictEqual(req.body.dry_run, true);
103
+ assert.strictEqual(req.body.include_jobs, true);
104
+ assert.strictEqual(req.body.ref, "main");
105
+ res.json({
106
+ valid: true,
107
+ errors: [],
108
+ warnings: [],
109
+ merged_yaml: "test:\n script: echo ok\n",
110
+ includes: [],
111
+ jobs: [{ name: "test", stage: "test" }],
112
+ });
113
+ });
114
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/ci/lint`, (req, res) => {
115
+ assert.strictEqual(req.query.content_ref, "feature/test");
116
+ assert.strictEqual(req.query.dry_run, "true");
117
+ assert.strictEqual(req.query.dry_run_ref, "main");
118
+ assert.strictEqual(req.query.include_jobs, "true");
119
+ res.json({
120
+ valid: true,
121
+ errors: [],
122
+ warnings: [],
123
+ merged_yaml: "include-job:\n script: echo included\n",
124
+ includes: [{ type: "local", location: "include.yml" }],
125
+ jobs: [{ name: "include-job", stage: "test" }],
126
+ });
127
+ });
128
+ await mockGitLab.start();
129
+ mockGitLabUrl = mockGitLab.getUrl();
130
+ });
131
+ after(async () => {
132
+ await mockGitLab.stop();
133
+ });
134
+ test("validate_ci_lint posts CI YAML content and returns lint result", async () => {
135
+ const result = await callTool("validate_ci_lint", {
136
+ project_id: TEST_PROJECT_ID,
137
+ content: "stages: [test]\ntest:\n script: echo ok",
138
+ dry_run: true,
139
+ include_jobs: true,
140
+ ref: "main",
141
+ }, {
142
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
143
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
144
+ });
145
+ assert.strictEqual(result.valid, true);
146
+ assert.deepStrictEqual(result.errors, []);
147
+ assert.strictEqual(result.jobs[0].name, "test");
148
+ });
149
+ test("validate_ci_lint surfaces invalid lint responses", async () => {
150
+ mockGitLab.addMockHandler("post", `/projects/999/ci/lint`, (req, res) => {
151
+ res.json({
152
+ valid: false,
153
+ errors: ["jobs config should contain at least one visible job"],
154
+ warnings: [],
155
+ });
156
+ });
157
+ const result = await callTool("validate_ci_lint", {
158
+ project_id: "999",
159
+ content: ".hidden:\n script: echo hidden",
160
+ }, {
161
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
162
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
163
+ });
164
+ assert.strictEqual(result.valid, false);
165
+ assert.deepStrictEqual(result.errors, ["jobs config should contain at least one visible job"]);
166
+ });
167
+ test("validate_project_ci_lint sends GET query parameters", async () => {
168
+ const result = await callTool("validate_project_ci_lint", {
169
+ project_id: TEST_PROJECT_ID,
170
+ content_ref: "feature/test",
171
+ dry_run: true,
172
+ dry_run_ref: "main",
173
+ include_jobs: true,
174
+ }, {
175
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
176
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
177
+ });
178
+ assert.strictEqual(result.valid, true);
179
+ assert.strictEqual(result.includes[0].location, "include.yml");
180
+ assert.strictEqual(result.jobs[0].name, "include-job");
181
+ });
182
+ test("CI lint tools are visible by default in read-only mode", async () => {
183
+ const tools = await listToolNames({
184
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
185
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
186
+ GITLAB_READ_ONLY_MODE: "true",
187
+ });
188
+ assert.ok(tools.includes("validate_ci_lint"));
189
+ assert.ok(tools.includes("validate_project_ci_lint"));
190
+ });
191
+ });
@@ -0,0 +1,206 @@
1
+ import { after, before, describe, test } 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-tags";
6
+ const TEST_PROJECT_ID = "123";
7
+ const TEST_TAG_NAME = "v1.0.0";
8
+ function buildTag(overrides = {}) {
9
+ return {
10
+ name: TEST_TAG_NAME,
11
+ message: "Annotated release tag",
12
+ target: "abc123def4567890",
13
+ commit: {
14
+ id: "abc123def4567890",
15
+ short_id: "abc123de",
16
+ title: "Release v1.0.0",
17
+ created_at: "2026-03-13T10:00:00.000Z",
18
+ parent_ids: ["1111111111111111"],
19
+ message: "Release v1.0.0",
20
+ author_name: "Test User",
21
+ author_email: "test@example.com",
22
+ authored_date: "2026-03-13T09:55:00.000Z",
23
+ committer_name: "Test User",
24
+ committer_email: "test@example.com",
25
+ committed_date: "2026-03-13T10:00:00.000Z",
26
+ },
27
+ release: {
28
+ tag_name: TEST_TAG_NAME,
29
+ description: "Release notes",
30
+ },
31
+ protected: false,
32
+ created_at: "2026-03-13T10:00:00.000Z",
33
+ ...overrides,
34
+ };
35
+ }
36
+ async function callTool(toolName, args, env) {
37
+ return new Promise((resolve, reject) => {
38
+ const proc = spawn("node", ["build/index.js"], {
39
+ stdio: ["pipe", "pipe", "pipe"],
40
+ env: {
41
+ ...process.env,
42
+ ...env,
43
+ },
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
+ }
57
+ try {
58
+ const response = JSON.parse(line);
59
+ if (response.error) {
60
+ reject(response.error);
61
+ return;
62
+ }
63
+ const content = response.result?.content?.[0]?.text;
64
+ if (!content) {
65
+ resolve(response.result);
66
+ return;
67
+ }
68
+ try {
69
+ resolve(JSON.parse(content));
70
+ }
71
+ catch {
72
+ resolve(content);
73
+ }
74
+ }
75
+ catch (error) {
76
+ reject(error);
77
+ }
78
+ });
79
+ proc.stdin?.end(JSON.stringify({
80
+ jsonrpc: "2.0",
81
+ id: 1,
82
+ method: "tools/call",
83
+ params: { name: toolName, arguments: args },
84
+ }) + "\n");
85
+ });
86
+ }
87
+ describe("tag tools", () => {
88
+ let mockGitLab;
89
+ let mockGitLabUrl;
90
+ before(async () => {
91
+ const mockPort = await findMockServerPort(20000, 50);
92
+ mockGitLab = new MockGitLabServer({
93
+ port: mockPort,
94
+ validTokens: [MOCK_TOKEN],
95
+ });
96
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/tags`, (req, res) => {
97
+ assert.strictEqual(req.query.search, "^v");
98
+ assert.strictEqual(req.query.order_by, "version");
99
+ assert.strictEqual(req.query.sort, "desc");
100
+ assert.strictEqual(req.query.page, "2");
101
+ assert.strictEqual(req.query.per_page, "10");
102
+ res.json([buildTag()]);
103
+ });
104
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/tags/${encodeURIComponent(TEST_TAG_NAME)}`, (_req, res) => {
105
+ res.json(buildTag());
106
+ });
107
+ mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/repository/tags`, (req, res) => {
108
+ assert.deepStrictEqual(req.body, {
109
+ tag_name: TEST_TAG_NAME,
110
+ ref: "main",
111
+ message: "Release tag",
112
+ });
113
+ res.json(buildTag({ created_at: null }));
114
+ });
115
+ mockGitLab.addMockHandler("delete", `/projects/${TEST_PROJECT_ID}/repository/tags/${encodeURIComponent(TEST_TAG_NAME)}`, (_req, res) => {
116
+ res.status(204).send();
117
+ });
118
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/tags/${encodeURIComponent(TEST_TAG_NAME)}/signature`, (_req, res) => {
119
+ res.json({
120
+ signature_type: "X509",
121
+ verification_status: "unverified",
122
+ x509_certificate: {
123
+ id: 1,
124
+ subject: "CN=Test User,O=Example",
125
+ subject_key_identifier: "A1725E379E7B25B2",
126
+ email: null,
127
+ serial_number: 123456789,
128
+ certificate_status: "good",
129
+ x509_issuer: {
130
+ id: 1,
131
+ subject: "CN=Example CA,O=Example",
132
+ subject_key_identifier: "C6411E6F5B9E2CFD",
133
+ crl_url: null,
134
+ },
135
+ },
136
+ });
137
+ });
138
+ await mockGitLab.start();
139
+ mockGitLabUrl = mockGitLab.getUrl();
140
+ });
141
+ after(async () => {
142
+ await mockGitLab.stop();
143
+ });
144
+ const env = () => ({
145
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
146
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
147
+ GITLAB_TOOLSETS: "tags",
148
+ });
149
+ test("list_tags returns parsed tags with query filters", async () => {
150
+ const result = await callTool("list_tags", {
151
+ project_id: TEST_PROJECT_ID,
152
+ search: "^v",
153
+ order_by: "version",
154
+ sort: "desc",
155
+ page: 2,
156
+ per_page: 10,
157
+ }, env());
158
+ assert.ok(Array.isArray(result));
159
+ assert.strictEqual(result.length, 1);
160
+ assert.strictEqual(result[0].name, TEST_TAG_NAME);
161
+ });
162
+ test("get_tag returns a single tag", async () => {
163
+ const result = await callTool("get_tag", { project_id: TEST_PROJECT_ID, tag_name: TEST_TAG_NAME }, env());
164
+ assert.strictEqual(result.name, TEST_TAG_NAME);
165
+ assert.strictEqual(result.commit.short_id, "abc123de");
166
+ });
167
+ test("create_tag posts the expected payload", async () => {
168
+ const result = await callTool("create_tag", {
169
+ project_id: TEST_PROJECT_ID,
170
+ tag_name: TEST_TAG_NAME,
171
+ ref: "main",
172
+ message: "Release tag",
173
+ }, env());
174
+ assert.strictEqual(result.name, TEST_TAG_NAME);
175
+ assert.strictEqual(result.release.description, "Release notes");
176
+ assert.strictEqual(result.created_at, null);
177
+ });
178
+ test("delete_tag returns a success payload", async () => {
179
+ const result = await callTool("delete_tag", { project_id: TEST_PROJECT_ID, tag_name: TEST_TAG_NAME }, env());
180
+ assert.deepStrictEqual(result, {
181
+ status: "success",
182
+ message: `Tag '${TEST_TAG_NAME}' deleted successfully`,
183
+ });
184
+ });
185
+ test("get_tag_signature returns signature data", async () => {
186
+ const result = await callTool("get_tag_signature", { project_id: TEST_PROJECT_ID, tag_name: TEST_TAG_NAME }, env());
187
+ assert.deepStrictEqual(result, {
188
+ signature_type: "X509",
189
+ verification_status: "unverified",
190
+ x509_certificate: {
191
+ id: 1,
192
+ subject: "CN=Test User,O=Example",
193
+ subject_key_identifier: "A1725E379E7B25B2",
194
+ email: null,
195
+ serial_number: 123456789,
196
+ certificate_status: "good",
197
+ x509_issuer: {
198
+ id: 1,
199
+ subject: "CN=Example CA,O=Example",
200
+ subject_key_identifier: "C6411E6F5B9E2CFD",
201
+ crl_url: null,
202
+ },
203
+ },
204
+ });
205
+ });
206
+ });
@@ -0,0 +1,195 @@
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-todos-test-token";
6
+ async function callTool(toolName, args, env) {
7
+ return new Promise((resolve, reject) => {
8
+ const proc = spawn("node", ["build/index.js"], {
9
+ stdio: ["pipe", "pipe", "pipe"],
10
+ env: {
11
+ ...process.env,
12
+ ...env,
13
+ },
14
+ });
15
+ let output = "";
16
+ let errorOutput = "";
17
+ proc.stdout?.on("data", (d) => (output += d));
18
+ proc.stderr?.on("data", (d) => (errorOutput += d));
19
+ proc.on("close", code => {
20
+ if (code !== 0) {
21
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
22
+ }
23
+ const line = output.split("\n").find(l => l.startsWith("{"));
24
+ if (!line)
25
+ return reject(new Error("No JSON output found"));
26
+ try {
27
+ const response = JSON.parse(line);
28
+ if (response.error) {
29
+ reject(response.error);
30
+ }
31
+ else {
32
+ const content = response.result?.content?.[0]?.text;
33
+ resolve(content ? JSON.parse(content) : response.result);
34
+ }
35
+ }
36
+ catch (e) {
37
+ reject(e);
38
+ }
39
+ });
40
+ proc.stdin?.end(JSON.stringify({
41
+ jsonrpc: "2.0",
42
+ id: 1,
43
+ method: "tools/call",
44
+ params: args === undefined ? { name: toolName } : { name: toolName, arguments: args },
45
+ }) + "\n");
46
+ });
47
+ }
48
+ async function listToolNames(env) {
49
+ return new Promise((resolve, reject) => {
50
+ const proc = spawn("node", ["build/index.js"], {
51
+ stdio: ["pipe", "pipe", "pipe"],
52
+ env: {
53
+ ...process.env,
54
+ ...env,
55
+ },
56
+ });
57
+ let output = "";
58
+ let errorOutput = "";
59
+ proc.stdout?.on("data", (d) => (output += d));
60
+ proc.stderr?.on("data", (d) => (errorOutput += d));
61
+ proc.on("close", code => {
62
+ if (code !== 0) {
63
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
64
+ }
65
+ const line = output.split("\n").find(l => l.startsWith("{"));
66
+ if (!line)
67
+ return reject(new Error("No JSON output found"));
68
+ try {
69
+ const response = JSON.parse(line);
70
+ if (response.error) {
71
+ reject(response.error);
72
+ }
73
+ else {
74
+ resolve(response.result.tools.map((tool) => tool.name));
75
+ }
76
+ }
77
+ catch (e) {
78
+ reject(e);
79
+ }
80
+ });
81
+ proc.stdin?.end(JSON.stringify({
82
+ jsonrpc: "2.0",
83
+ id: 1,
84
+ method: "tools/list",
85
+ params: {},
86
+ }) + "\n");
87
+ });
88
+ }
89
+ function todoFixture(id, state = "pending") {
90
+ return {
91
+ id,
92
+ project: { id: 123, path_with_namespace: "group/project" },
93
+ author: { id: 1, username: "root" },
94
+ action_name: "marked",
95
+ target_type: "MergeRequest",
96
+ target: { id: 34, iid: 7, project_id: 123, title: "Review this MR" },
97
+ target_url: "https://gitlab.example.com/group/project/-/merge_requests/7",
98
+ body: "Review this MR",
99
+ state,
100
+ created_at: "2026-01-01T00:00:00.000Z",
101
+ updated_at: "2026-01-01T00:00:00.000Z",
102
+ };
103
+ }
104
+ describe("GitLab todos tools", () => {
105
+ let mockGitLab;
106
+ let mockGitLabUrl;
107
+ before(async () => {
108
+ const mockPort = await findMockServerPort(9250);
109
+ mockGitLab = new MockGitLabServer({
110
+ port: mockPort,
111
+ validTokens: [MOCK_TOKEN],
112
+ });
113
+ mockGitLab.addMockHandler("get", "/todos", (req, res) => {
114
+ assert.strictEqual(req.query.state, "pending");
115
+ assert.strictEqual(req.query.action, "assigned");
116
+ assert.strictEqual(req.query.project_id, "123");
117
+ assert.strictEqual(req.query.page, "2");
118
+ assert.strictEqual(req.query.per_page, "5");
119
+ res.json([todoFixture(102)]);
120
+ });
121
+ mockGitLab.addMockHandler("post", "/todos/102/mark_as_done", (req, res) => {
122
+ res.json(todoFixture(102, "done"));
123
+ });
124
+ mockGitLab.addMockHandler("post", "/todos/mark_as_done", (req, res) => {
125
+ res.status(204).send();
126
+ });
127
+ await mockGitLab.start();
128
+ mockGitLabUrl = mockGitLab.getUrl();
129
+ });
130
+ after(async () => {
131
+ await mockGitLab.stop();
132
+ });
133
+ test("list_todos sends filters and returns todos", async () => {
134
+ const result = await callTool("list_todos", {
135
+ state: "pending",
136
+ action: "assigned",
137
+ project_id: 123,
138
+ page: 2,
139
+ per_page: 5,
140
+ }, {
141
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
142
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
143
+ });
144
+ assert.ok(Array.isArray(result));
145
+ assert.strictEqual(result[0].id, 102);
146
+ assert.strictEqual(result[0].state, "pending");
147
+ });
148
+ test("mark_todo_done marks one todo done", async () => {
149
+ const result = await callTool("mark_todo_done", { id: 102 }, {
150
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
151
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
152
+ });
153
+ assert.strictEqual(result.id, 102);
154
+ assert.strictEqual(result.state, "done");
155
+ });
156
+ test("mark_all_todos_done reports success", async () => {
157
+ const result = await callTool("mark_all_todos_done", {}, {
158
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
159
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
160
+ });
161
+ assert.deepStrictEqual(result, {
162
+ status: "success",
163
+ message: "All pending to-do items marked as done",
164
+ });
165
+ });
166
+ test("mark_all_todos_done accepts omitted arguments", async () => {
167
+ const result = await callTool("mark_all_todos_done", undefined, {
168
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
169
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
170
+ });
171
+ assert.deepStrictEqual(result, {
172
+ status: "success",
173
+ message: "All pending to-do items marked as done",
174
+ });
175
+ });
176
+ test("todo tools are visible in the default issues toolset", async () => {
177
+ const tools = await listToolNames({
178
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
179
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
180
+ });
181
+ assert.ok(tools.includes("list_todos"));
182
+ assert.ok(tools.includes("mark_todo_done"));
183
+ assert.ok(tools.includes("mark_all_todos_done"));
184
+ });
185
+ test("read-only mode keeps list_todos and hides todo mutations", async () => {
186
+ const tools = await listToolNames({
187
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
188
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
189
+ GITLAB_READ_ONLY_MODE: "true",
190
+ });
191
+ assert.ok(tools.includes("list_todos"));
192
+ assert.ok(!tools.includes("mark_todo_done"));
193
+ assert.ok(!tools.includes("mark_all_todos_done"));
194
+ });
195
+ });
@@ -17,20 +17,23 @@ const MCP_PORT_BASE = 3200;
17
17
  // Known tool counts per toolset (from TOOLSET_DEFINITIONS)
18
18
  const TOOLSET_TOOL_COUNTS = {
19
19
  merge_requests: 40,
20
- issues: 20,
20
+ issues: 23,
21
21
  repositories: 7,
22
22
  branches: 4,
23
23
  projects: 8,
24
24
  labels: 5,
25
+ ci: 2,
25
26
  pipelines: 19,
26
27
  milestones: 9,
27
28
  wiki: 10,
28
29
  releases: 7,
30
+ tags: 5,
29
31
  users: 5,
30
32
  search: 3,
31
33
  workitems: 18,
32
34
  webhooks: 3,
33
35
  };
36
+ const LEGACY_PIPELINE_TOOL_COUNT = TOOLSET_TOOL_COUNTS.pipelines + TOOLSET_TOOL_COUNTS.ci;
34
37
  const DEFAULT_TOOLSETS = [
35
38
  "merge_requests",
36
39
  "issues",
@@ -38,9 +41,19 @@ const DEFAULT_TOOLSETS = [
38
41
  "branches",
39
42
  "projects",
40
43
  "labels",
44
+ "ci",
41
45
  "users",
42
46
  ];
43
- const NON_DEFAULT_TOOLSETS = ["pipelines", "milestones", "wiki", "releases", "workitems", "webhooks", "search"];
47
+ const NON_DEFAULT_TOOLSETS = [
48
+ "pipelines",
49
+ "milestones",
50
+ "wiki",
51
+ "releases",
52
+ "tags",
53
+ "workitems",
54
+ "webhooks",
55
+ "search",
56
+ ];
44
57
  // discover_tools meta-tool is always force-injected (Step 5.5)
45
58
  const DISCOVER_TOOLS_COUNT = 1;
46
59
  const DEFAULT_TOOL_COUNT = DEFAULT_TOOLSETS.reduce((sum, id) => sum + TOOLSET_TOOL_COUNTS[id], 0) + DISCOVER_TOOLS_COUNT;
@@ -48,15 +61,17 @@ const ALL_TOOLSET_TOOL_COUNT = Object.values(TOOLSET_TOOL_COUNTS).reduce((sum, c
48
61
  // Representative tools per toolset for spot-checking
49
62
  const TOOLSET_SAMPLE_TOOLS = {
50
63
  merge_requests: ["merge_merge_request", "create_merge_request_thread", "list_draft_notes"],
51
- issues: ["create_issue", "list_issues", "create_note"],
64
+ issues: ["create_issue", "list_issues", "create_note", "list_todos"],
52
65
  repositories: ["search_repositories", "get_file_contents", "push_files"],
53
66
  branches: ["create_branch", "list_commits"],
54
67
  projects: ["get_project", "list_namespaces", "list_group_iterations"],
55
68
  labels: ["list_labels", "create_label"],
69
+ ci: ["validate_ci_lint", "validate_project_ci_lint"],
56
70
  pipelines: ["list_pipelines", "create_pipeline", "cancel_pipeline_job", "list_deployments", "list_job_artifacts"],
57
71
  milestones: ["list_milestones", "create_milestone", "get_milestone_burndown_events"],
58
72
  wiki: ["list_wiki_pages", "create_wiki_page", "list_group_wiki_pages", "create_group_wiki_page"],
59
73
  releases: ["list_releases", "create_release", "download_release_asset"],
74
+ tags: ["list_tags", "create_tag", "get_tag_signature"],
60
75
  users: ["get_users", "upload_markdown", "download_attachment"],
61
76
  search: ["search_code", "search_project_code", "search_group_code"],
62
77
  webhooks: ["list_webhooks", "list_webhook_events", "get_webhook_event"],
@@ -249,7 +264,7 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
249
264
  });
250
265
  after(() => cleanupServers([server]));
251
266
  test("returns issue tools + all pipeline tools + discover_tools", () => {
252
- assert.strictEqual(tools.length, TOOLSET_TOOL_COUNTS.issues + TOOLSET_TOOL_COUNTS.pipelines + DISCOVER_TOOLS_COUNT);
267
+ assert.strictEqual(tools.length, TOOLSET_TOOL_COUNTS.issues + LEGACY_PIPELINE_TOOL_COUNT + DISCOVER_TOOLS_COUNT);
253
268
  });
254
269
  test("includes all pipeline tools via legacy flag", () => {
255
270
  assertContainsAll(tools, TOOLSET_SAMPLE_TOOLS.pipelines, "pipelines");
@@ -286,6 +301,7 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
286
301
  "list_issue_links",
287
302
  "list_issue_discussions",
288
303
  "get_issue_link",
304
+ "list_todos",
289
305
  "list_issue_emoji_reactions",
290
306
  "list_issue_note_emoji_reactions",
291
307
  ];
@@ -293,6 +309,8 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
293
309
  "create_issue",
294
310
  "update_issue",
295
311
  "delete_issue",
312
+ "mark_todo_done",
313
+ "mark_all_todos_done",
296
314
  "create_issue_note",
297
315
  "update_issue_note",
298
316
  "create_issue_link",
@@ -385,7 +403,7 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
385
403
  });
386
404
  after(() => cleanupServers([server]));
387
405
  test("returns exactly pipeline tool count + discover_tools (no duplicates)", () => {
388
- assert.strictEqual(tools.length, TOOLSET_TOOL_COUNTS.pipelines + DISCOVER_TOOLS_COUNT);
406
+ assert.strictEqual(tools.length, LEGACY_PIPELINE_TOOL_COUNT + DISCOVER_TOOLS_COUNT);
389
407
  });
390
408
  });
391
409
  // ---- 12. GITLAB_TOOLS with tool already in enabled toolset (no dupes) ----