@zereight/mcp-gitlab 2.1.64 → 2.1.66

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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { describe, test, after, before } from "node:test";
9
9
  import assert from "node:assert";
10
+ import { allTools, deleteTools, destructiveTools } from "../tools/registry.js";
10
11
  import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
11
12
  import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
12
13
  import { CustomHeaderClient } from "./clients/custom-header-client.js";
@@ -50,6 +51,29 @@ async function getToolNames(server) {
50
51
  await client.disconnect();
51
52
  return result.tools.map((t) => t.name);
52
53
  }
54
+ /**
55
+ * Asserts that a query passes the modify-mode guard *and* is forwarded to GitLab. The mock
56
+ * server has no `/api/graphql` route, so the call comes back as a downstream
57
+ * "GraphQL request failed" — that failure is the evidence the guard let the query through,
58
+ * which a "does not throw" check alone would not provide.
59
+ */
60
+ async function assertQueryReachesGitLab(server, query) {
61
+ const client = await connectClient(server);
62
+ try {
63
+ let outcome;
64
+ try {
65
+ outcome = JSON.stringify(await client.callTool("execute_graphql", { query }));
66
+ }
67
+ catch (error) {
68
+ outcome = error instanceof Error ? error.message : String(error);
69
+ }
70
+ assert.ok(!outcome.includes("modify mode"), `the guard must not fire for "${query}", got: ${outcome}`);
71
+ assert.ok(outcome.includes("GraphQL request failed"), `"${query}" should be forwarded to GitLab after passing the guard, got: ${outcome}`);
72
+ }
73
+ finally {
74
+ await client.disconnect();
75
+ }
76
+ }
53
77
  const DELETE_SAMPLE_TOOLS = [
54
78
  "delete_issue",
55
79
  "delete_branch",
@@ -60,6 +84,16 @@ const DELETE_SAMPLE_TOOLS = [
60
84
  "delete_project_variable",
61
85
  "delete_webhook",
62
86
  ];
87
+ // Destructive teardown tools whose names do not start with `delete_`. All five are listed
88
+ // here (list-hiding) and exercised with explicit `callTool` literals below (call-rejection),
89
+ // so the tool-coverage detector sees the call path too.
90
+ const DESTRUCTIVE_SAMPLE_TOOLS = [
91
+ "cancel_pipeline",
92
+ "cancel_pipeline_job",
93
+ "stop_environment",
94
+ "stop_stale_environments",
95
+ "unprotect_branch",
96
+ ];
63
97
  const MODIFY_SAMPLE_TOOLS = [
64
98
  "create_issue",
65
99
  "update_issue",
@@ -67,9 +101,26 @@ const MODIFY_SAMPLE_TOOLS = [
67
101
  "create_branch",
68
102
  "push_files",
69
103
  "merge_merge_request",
70
- "stop_stale_environments",
71
104
  ];
72
105
  const READ_SAMPLE_TOOLS = ["list_issues", "get_project", "list_merge_requests"];
106
+ describe("Modify-mode deny list invariants", () => {
107
+ test("deleteTools is a subset of destructiveTools", () => {
108
+ for (const name of deleteTools) {
109
+ assert.ok(destructiveTools.has(name), `"${name}" is blocked in modify mode but missing from destructiveTools`);
110
+ }
111
+ });
112
+ test("every blocked tool exists in the registry", () => {
113
+ const names = new Set(allTools.map(tool => tool.name));
114
+ for (const name of deleteTools) {
115
+ assert.ok(names.has(name), `"${name}" is in deleteTools but not in allTools`);
116
+ }
117
+ });
118
+ test("every destructive sample tool is in the modify-mode deny list", () => {
119
+ for (const name of DESTRUCTIVE_SAMPLE_TOOLS) {
120
+ assert.ok(deleteTools.has(name), `"${name}" should be blocked in modify mode`);
121
+ }
122
+ });
123
+ });
73
124
  describe("Permission Mode", { concurrency: 1 }, () => {
74
125
  before(async () => {
75
126
  const mockPort = await findMockServerPort();
@@ -94,7 +145,12 @@ describe("Permission Mode", { concurrency: 1 }, () => {
94
145
  after(() => cleanupServers(servers));
95
146
  test("exposes read, modify, and delete tools", async () => {
96
147
  const names = await getToolNames(server);
97
- for (const name of [...READ_SAMPLE_TOOLS, ...MODIFY_SAMPLE_TOOLS, ...DELETE_SAMPLE_TOOLS]) {
148
+ for (const name of [
149
+ ...READ_SAMPLE_TOOLS,
150
+ ...MODIFY_SAMPLE_TOOLS,
151
+ ...DELETE_SAMPLE_TOOLS,
152
+ ...DESTRUCTIVE_SAMPLE_TOOLS,
153
+ ]) {
98
154
  assert.ok(names.includes(name), `full mode: expected "${name}" to be present`);
99
155
  }
100
156
  });
@@ -117,6 +173,28 @@ describe("Permission Mode", { concurrency: 1 }, () => {
117
173
  }
118
174
  assert.ok(!names.some(n => n.startsWith("delete_")), "no delete_* tool should be listed");
119
175
  });
176
+ test("hides destructive stop/cancel/unprotect tools", async () => {
177
+ const names = await getToolNames(server);
178
+ for (const name of DESTRUCTIVE_SAMPLE_TOOLS) {
179
+ assert.ok(!names.includes(name), `modify mode: expected "${name}" to be absent`);
180
+ }
181
+ });
182
+ test("rejects destructive stop/cancel/unprotect tool calls", async () => {
183
+ const client = await connectClient(server);
184
+ try {
185
+ await assert.rejects(() => client.callTool("cancel_pipeline", { project_id: "1", pipeline_id: 1 }), (error) => error.message.includes("not allowed in modify mode"), "cancel_pipeline should be rejected in modify mode");
186
+ await assert.rejects(() => client.callTool("cancel_pipeline_job", { project_id: "1", job_id: 1 }), (error) => error.message.includes("not allowed in modify mode"), "cancel_pipeline_job should be rejected in modify mode");
187
+ await assert.rejects(() => client.callTool("stop_environment", { project_id: "1", environment_id: 1 }), (error) => error.message.includes("not allowed in modify mode"), "stop_environment should be rejected in modify mode");
188
+ await assert.rejects(() => client.callTool("stop_stale_environments", {
189
+ project_id: "1",
190
+ before: "2026-01-01T00:00:00Z",
191
+ }), (error) => error.message.includes("not allowed in modify mode"), "stop_stale_environments should be rejected in modify mode");
192
+ await assert.rejects(() => client.callTool("unprotect_branch", { project_id: "1", branch: "main" }), (error) => error.message.includes("not allowed in modify mode"), "unprotect_branch should be rejected in modify mode");
193
+ }
194
+ finally {
195
+ await client.disconnect();
196
+ }
197
+ });
120
198
  test("hides purge_dependency_proxy_cache", async () => {
121
199
  const names = await getToolNames(server);
122
200
  assert.ok(!names.includes("purge_dependency_proxy_cache"), "modify mode: purge_dependency_proxy_cache should be absent");
@@ -136,7 +214,7 @@ describe("Permission Mode", { concurrency: 1 }, () => {
136
214
  try {
137
215
  await assert.rejects(() => client.callTool("execute_graphql", {
138
216
  query: 'mutation { issueDelete(input: { projectPath: "g/p", iid: "1" }) { errors } }',
139
- }), (error) => error.message.includes("delete mutations in modify mode"), "delete mutation should be rejected in modify mode");
217
+ }), (error) => error.message.includes("destructive mutations in modify mode"), "delete mutation should be rejected in modify mode");
140
218
  }
141
219
  finally {
142
220
  await client.disconnect();
@@ -171,17 +249,30 @@ describe("Permission Mode", { concurrency: 1 }, () => {
171
249
  }
172
250
  });
173
251
  test("does not apply the modify-mode guard to non-delete mutations", async () => {
252
+ // issueSetSeverity writes, but nothing about it is destructive.
253
+ await assertQueryReachesGitLab(server, "mutation { issueSetSeverity(input: { severity: HIGH }) { errors } }");
254
+ });
255
+ test("does not apply the modify-mode guard when the alias looks destructive", async () => {
256
+ // "stop" is only the caller-chosen label here; the field is issueSetSeverity.
257
+ await assertQueryReachesGitLab(server, "mutation { stop: issueSetSeverity(input: { severity: HIGH }) { errors } }");
258
+ });
259
+ test("rejects a destructive mutation hidden behind a harmless alias", async () => {
174
260
  const client = await connectClient(server);
175
261
  try {
176
- // The mock GitLab server has no GraphQL endpoint, so the call may fail
177
- // downstream — but it must not fail with the modify-mode guard error.
178
- await client.callTool("execute_graphql", {
179
- query: "mutation { issueSetSeverity(input: { severity: HIGH }) { errors } }",
180
- });
181
- }
182
- catch (error) {
183
- assert.ok(error instanceof Error);
184
- assert.ok(!error.message.includes("modify mode"), `non-delete mutation should pass the guard, got: ${error.message}`);
262
+ await assert.rejects(() => client.callTool("execute_graphql", {
263
+ query: "mutation { harmless: environmentStop(input: {}) { errors } }",
264
+ }), (error) => error.message.includes("destructive mutations in modify mode"), "a destructive field must be rejected even when aliased");
265
+ }
266
+ finally {
267
+ await client.disconnect();
268
+ }
269
+ });
270
+ test("rejects jobUnschedule", async () => {
271
+ const client = await connectClient(server);
272
+ try {
273
+ await assert.rejects(() => client.callTool("execute_graphql", {
274
+ query: 'mutation { jobUnschedule(input: { id: "gid://gitlab/Ci::Build/1" }) { errors } }',
275
+ }), (error) => error.message.includes("destructive mutations in modify mode"), "jobUnschedule must be rejected in modify mode");
185
276
  }
186
277
  finally {
187
278
  await client.disconnect();
@@ -30,6 +30,7 @@ const RFC_FORWARDED_HEADERS = {
30
30
  // Minimal 1x1 transparent PNG
31
31
  const MINIMAL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
32
32
  const LARGE_FILE_TOKEN = 'glpat-largefile-test-token';
33
+ const ATTACHMENT_TOKEN = 'glpat-attachment-test-token';
33
34
  const FAKE_ZIP = Buffer.from('PK\x03\x04fake-zip-content-for-testing');
34
35
  const MOCK_UPLOAD_RESPONSE = {
35
36
  id: 99,
@@ -52,7 +53,7 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
52
53
  const mockPort = await findMockServerPort();
53
54
  mockGitLab = new MockGitLabServer({
54
55
  port: mockPort,
55
- validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN],
56
+ validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN, ATTACHMENT_TOKEN],
56
57
  });
57
58
  // Mock artifact download
58
59
  mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (_req, res) => {
@@ -70,6 +71,13 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
70
71
  res.set('Content-Type', 'text/plain');
71
72
  res.send('hello document content');
72
73
  });
74
+ // Mock attachment whose name contains a slash. The uploads route takes one filename
75
+ // segment, so the proxy has to request it as `nested%2Fdocument.txt`; a handler on
76
+ // `nested/document.txt` would match the wrong route shape and must not be hit.
77
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/nested%2Fdocument.txt`, (_req, res) => {
78
+ res.set('Content-Type', 'text/plain');
79
+ res.send('nested document content');
80
+ });
73
81
  // Mock large artifact (2MB) to verify streaming works for big files
74
82
  mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/999/artifacts`, (_req, res) => {
75
83
  res.set('Content-Type', 'application/zip');
@@ -143,6 +151,11 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
143
151
  const expectedSize = 2 * 1024 * 1024;
144
152
  assert.strictEqual(buf.length, expectedSize, `Should receive full 2MB (got ${buf.length} bytes)`);
145
153
  });
154
+ test('keeps a slash-bearing attachment filename inside one path segment', async () => {
155
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/attachment?project_id=${TEST_PROJECT_ID}&secret=${TEST_SECRET}&filename=${encodeURIComponent('nested/document.txt')}`, { headers: { 'Private-Token': ATTACHMENT_TOKEN } });
156
+ assert.strictEqual(res.status, 200, 'attachment proxy should route to one encoded segment');
157
+ assert.strictEqual(await res.text(), 'nested document content');
158
+ });
146
159
  test('returns 429 after exceeding rate limit', async () => {
147
160
  // Use a different token to get a fresh rate limit counter
148
161
  const rateLimitToken = 'glpat-ratelimit-test-token';
@@ -89,8 +89,80 @@ describe("When graphqlQueryContainsDeleteOperation runs", () => {
89
89
  test("should detect delete mutations with variable definitions", () => {
90
90
  assert.equal(graphqlQueryContainsDeleteOperation("mutation DeleteIssue($id: IssueID!) { issueDelete(input: { id: $id }) { errors } }"), true);
91
91
  });
92
+ test("should detect comma-prefixed delete mutations", () => {
93
+ assert.equal(graphqlQueryContainsDeleteOperation(', mutation M { issueDelete(input: { iid: "1" }) { id } }'), true);
94
+ });
95
+ test("should detect comma-prefixed delete mutations without whitespace", () => {
96
+ assert.equal(graphqlQueryContainsDeleteOperation(',mutation{issueDelete(input:{iid:"1"}){id}}'), true);
97
+ });
98
+ test("should detect delete mutations after a comma-separated query", () => {
99
+ assert.equal(graphqlQueryContainsDeleteOperation("query HealthCheck { x }, mutation M { issueDelete(input: {}) { id } }"), true);
100
+ });
101
+ test("should allow comma-separated queries", () => {
102
+ assert.equal(graphqlQueryContainsDeleteOperation("query A { a }, query B { b }"), false);
103
+ });
92
104
  test("should conservatively block top-level fragment spreads in mutations", () => {
93
105
  assert.equal(graphqlQueryContainsDeleteOperation("mutation { ...f } fragment f on Mutation { issueDelete(input: {}) { errors } }"), true);
94
106
  });
95
107
  });
108
+ describe("with destructive mutations that are not named delete", () => {
109
+ test("should detect environmentStop", () => {
110
+ assert.equal(graphqlQueryContainsDeleteOperation('mutation { environmentStop(input: { environmentId: "gid://gitlab/Environment/1" }) { errors } }'), true);
111
+ });
112
+ test("should detect pipelineCancel", () => {
113
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { pipelineCancel(input: {}) { errors } }"), true);
114
+ });
115
+ test("should detect clusterAgentTokenRevoke", () => {
116
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { clusterAgentTokenRevoke(input: {}) { errors } }"), true);
117
+ });
118
+ test("should detect jobUnschedule", () => {
119
+ // GitLab moves the scheduled job to manual and clears its scheduled state, so it
120
+ // is a teardown action even though no teardown verb is part of its name.
121
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { jobUnschedule(input: {}) { errors } }"), true);
122
+ });
123
+ test("should detect aliased destructive mutations", () => {
124
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { s: environmentStop(input: {}) { errors } }"), true);
125
+ });
126
+ test("should not treat an alias as a destructive mutation field", () => {
127
+ for (const query of [
128
+ "mutation { stop: issueSetSeverity(input: {}) { errors } }",
129
+ "mutation { revoke: issueSetSeverity(input: {}) { errors } }",
130
+ "mutation { terminate: updateIssue(input: {}) { issue { id } } }",
131
+ ]) {
132
+ assert.equal(graphqlQueryContainsDeleteOperation(query), false, query);
133
+ }
134
+ });
135
+ test("should not treat a spaced or comma-separated alias as a destructive field", () => {
136
+ for (const query of [
137
+ "mutation { stop : issueSetSeverity(input: {}) { errors } }",
138
+ "mutation { revoke,: issueSetSeverity(input: {}) { errors } }",
139
+ "mutation { terminate\n: updateIssue(input: {}) { issue { id } } }",
140
+ ]) {
141
+ assert.equal(graphqlQueryContainsDeleteOperation(query), false, query);
142
+ }
143
+ });
144
+ test("should still detect a destructive field after a spaced alias", () => {
145
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { harmless : environmentStop(input: {}) { errors } }"), true);
146
+ });
147
+ test("should still detect a destructive field hidden behind a harmless alias", () => {
148
+ for (const query of [
149
+ "mutation { harmless: environmentStop(input: {}) { errors } }",
150
+ "mutation { step: pipelineCancel(input: {}) { errors } }",
151
+ ]) {
152
+ assert.equal(graphqlQueryContainsDeleteOperation(query), true, query);
153
+ }
154
+ });
155
+ test("should detect destructive mutations in multi-operation documents", () => {
156
+ assert.equal(graphqlQueryContainsDeleteOperation("query A { project { id } } mutation B { environmentStop(input: {}) { errors } }"), true);
157
+ });
158
+ test("should still allow mutations without a destructive verb", () => {
159
+ for (const query of [
160
+ "mutation { issueMove(input: {}) { errors } }",
161
+ "mutation { mergeRequestMerge(input: {}) { errors } }",
162
+ "mutation { todoMarkDone(input: {}) { errors } }",
163
+ ]) {
164
+ assert.equal(graphqlQueryContainsDeleteOperation(query), false, query);
165
+ }
166
+ });
167
+ });
96
168
  });
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { applyJmespathFilter, applyJmespathToToolResult, getJmespathSyntaxError, omitJmespathArgument, readJmespathExpression, shouldApplyJmespathFilter, } from "../../utils/jmespath-tool-result.js";
4
+ describe("jmespath tool result helpers", () => {
5
+ test("readJmespathExpression trims and rejects blank values", () => {
6
+ assert.equal(readJmespathExpression({ jmespath: " [].title " }), "[].title");
7
+ assert.equal(readJmespathExpression({ jmespath: " " }), undefined);
8
+ assert.equal(readJmespathExpression({ jmespath: 1 }), undefined);
9
+ });
10
+ test("omitJmespathArgument removes MCP-only key", () => {
11
+ assert.deepEqual(omitJmespathArgument({ project_id: "1", jmespath: "[].id" }), {
12
+ project_id: "1",
13
+ });
14
+ });
15
+ test("applyJmespathFilter projects array fields", () => {
16
+ const data = [
17
+ { title: "a", description: "long" },
18
+ { title: "b", description: "longer" },
19
+ ];
20
+ assert.deepEqual(applyJmespathFilter(data, "[].title"), ["a", "b"]);
21
+ });
22
+ test("applyJmespathToToolResult filters JSON text content", () => {
23
+ const result = applyJmespathToToolResult({
24
+ content: [
25
+ {
26
+ type: "text",
27
+ text: JSON.stringify([{ title: "mr-one", iid: 1 }]),
28
+ },
29
+ ],
30
+ }, "[].title");
31
+ assert.equal(result.content[0].text, JSON.stringify(["mr-one"]));
32
+ });
33
+ test("applyJmespathToToolResult applies maskValue before JMESPath", () => {
34
+ const result = applyJmespathToToolResult({
35
+ content: [
36
+ {
37
+ type: "text",
38
+ text: JSON.stringify([{ title: "ProjectOneSecret" }]),
39
+ },
40
+ ],
41
+ }, "[].starts_with(title, `ProjectOne`)", {
42
+ maskValue: value => {
43
+ if (!Array.isArray(value))
44
+ return value;
45
+ return value.map(entry => entry && typeof entry === "object" && "title" in entry
46
+ ? { ...entry, title: "[one]" }
47
+ : entry);
48
+ },
49
+ });
50
+ assert.deepEqual(JSON.parse(result.content[0].text), [false]);
51
+ });
52
+ test("shouldApplyJmespathFilter is false for isError and explicit skip", () => {
53
+ const errorResult = {
54
+ content: [{ type: "text", text: 'Unknown category "foo"' }],
55
+ isError: true,
56
+ };
57
+ assert.equal(shouldApplyJmespathFilter(errorResult, "[].title"), false);
58
+ assert.equal(shouldApplyJmespathFilter({ content: [{ type: "text", text: "requires confirmation" }] }, "[].title", { skipJmespath: true }), false);
59
+ assert.equal(shouldApplyJmespathFilter({ content: [{ type: "text", text: "[]" }] }, "[].title"), true);
60
+ });
61
+ test("applyJmespathToToolResult preserves isError tool results", () => {
62
+ const original = {
63
+ content: [{ type: "text", text: 'Unknown category "foo"' }],
64
+ isError: true,
65
+ };
66
+ assert.deepEqual(applyJmespathToToolResult(original, "[].title"), original);
67
+ });
68
+ test("applyJmespathToToolResult preserves approval-style plain text when skipJmespath", () => {
69
+ const original = {
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: 'Tool "create_issue" requires confirmation. Re-call with _confirmed: true to proceed.',
74
+ },
75
+ ],
76
+ };
77
+ assert.deepEqual(applyJmespathToToolResult(original, "[].title", { skipJmespath: true }), original);
78
+ });
79
+ test("applyJmespathToToolResult passes non-JSON success text through unchanged", () => {
80
+ const original = { content: [{ type: "text", text: "Created pipeline #1 for main." }] };
81
+ assert.deepEqual(applyJmespathToToolResult(original, "id"), original);
82
+ });
83
+ test("applyJmespathToToolResult returns the original result when evaluation fails", () => {
84
+ const original = { content: [{ type: "text", text: JSON.stringify({ title: 1 }) }] };
85
+ assert.deepEqual(applyJmespathToToolResult(original, "starts_with(title, `a`)"), original);
86
+ });
87
+ test("getJmespathSyntaxError rejects invalid expressions before execution", () => {
88
+ assert.match(getJmespathSyntaxError("[[[") ?? "", /Invalid jmespath expression/);
89
+ assert.equal(getJmespathSyntaxError("[].title"), undefined);
90
+ });
91
+ });