@zereight/mcp-gitlab 2.1.29 → 2.1.30

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.
Files changed (67) hide show
  1. package/README.ko.md +71 -20
  2. package/README.md +44 -15
  3. package/README.zh-CN.md +71 -20
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +485 -424
  7. package/build/schemas.js +30 -9
  8. package/build/scripts/check-skill-sync.js +137 -0
  9. package/build/scripts/generate-tool-docs.js +7 -2
  10. package/build/server/metrics.js +84 -0
  11. package/build/server/transport-mode.js +25 -0
  12. package/build/test/callback-proxy-tests.js +1 -1
  13. package/build/test/client-pool-test.js +1 -1
  14. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  15. package/build/test/dynamic-api-url-test.js +4 -4
  16. package/build/test/dynamic-routing-tests.js +4 -4
  17. package/build/test/mcp-oauth-tests.js +17 -4
  18. package/build/test/multi-server-test.js +3 -3
  19. package/build/test/no-proxy-integration-test.js +1 -1
  20. package/build/test/remote-auth-simple-test.js +226 -199
  21. package/build/test/server/metrics.test.js +47 -0
  22. package/build/test/sse-auth-guard.test.js +2 -2
  23. package/build/test/stateless/session-id-integration.test.js +3 -3
  24. package/build/test/streamable-http-concurrent-session.test.js +1 -1
  25. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  26. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  27. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  28. package/build/test/test-ci-catalog.js +1 -1
  29. package/build/test/test-ci-lint.js +1 -1
  30. package/build/test/test-ci-variables.js +8 -5
  31. package/build/test/test-dependency-proxy.js +11 -7
  32. package/build/test/test-deployment-tools.js +16 -2
  33. package/build/test/test-download-attachment.js +1 -1
  34. package/build/test/test-get-file-blame.js +1 -1
  35. package/build/test/test-geteffectiveprojectid.js +7 -7
  36. package/build/test/test-issue-description-patch.js +1 -1
  37. package/build/test/test-job-artifacts.js +1 -1
  38. package/build/test/test-list-issues.js +1 -1
  39. package/build/test/test-list-merge-requests.js +1 -1
  40. package/build/test/test-list-project-members.js +1 -1
  41. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  42. package/build/test/test-merge-request-pipelines.js +1 -1
  43. package/build/test/test-mr-diffs-filter.js +1 -1
  44. package/build/test/test-mr-file-diffs.js +2 -2
  45. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  46. package/build/test/test-permission-mode.js +215 -0
  47. package/build/test/test-protected-branches.js +1 -1
  48. package/build/test/test-remote-downloads.js +2 -2
  49. package/build/test/test-search-code.js +1 -1
  50. package/build/test/test-tags.js +1 -1
  51. package/build/test/test-todos.js +1 -1
  52. package/build/test/test-token-optimizations.js +3 -3
  53. package/build/test/test-toolset-filtering.js +1 -1
  54. package/build/test/test-update-issue-slim.js +141 -0
  55. package/build/test/test-upload-markdown.js +1 -1
  56. package/build/test/utils/download-token.test.js +35 -0
  57. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  58. package/build/test/utils/graphql-query.test.js +64 -1
  59. package/build/test/utils/mock-gitlab-server.js +24 -31
  60. package/build/test/utils/server-launcher.js +1 -2
  61. package/build/test/utils/version-check.test.js +52 -0
  62. package/build/tools/registry.js +30 -5
  63. package/build/utils/download-token.js +71 -0
  64. package/build/utils/forwarded-public-base-url.js +22 -0
  65. package/build/utils/graphql-query.js +109 -12
  66. package/build/utils/version-check.js +40 -0
  67. package/package.json +5 -4
@@ -115,7 +115,7 @@ describe('list_merge_request_changed_files', () => {
115
115
  let mockGitLab;
116
116
  let mockGitLabUrl;
117
117
  before(async () => {
118
- const mockPort = await findMockServerPort(9150);
118
+ const mockPort = await findMockServerPort();
119
119
  mockGitLab = new MockGitLabServer({
120
120
  port: mockPort,
121
121
  validTokens: [MOCK_TOKEN]
@@ -177,7 +177,7 @@ describe('get_merge_request_file_diff', () => {
177
177
  let mockGitLab;
178
178
  let mockGitLabUrl;
179
179
  before(async () => {
180
- const mockPort = await findMockServerPort(9200);
180
+ const mockPort = await findMockServerPort();
181
181
  mockGitLab = new MockGitLabServer({
182
182
  port: mockPort,
183
183
  validTokens: [MOCK_TOKEN]
@@ -38,7 +38,7 @@ describe("When MCP OAuth runs behind a trusted proxy", () => {
38
38
  let mockGitLab;
39
39
  let servers = [];
40
40
  before(async () => {
41
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
41
+ const mockPort = await findMockServerPort();
42
42
  mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [] });
43
43
  await mockGitLab.start();
44
44
  const mockGitLabUrl = mockGitLab.getUrl();
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Permission Mode Test Suite
3
+ *
4
+ * Tests GITLAB_PERMISSION_MODE (readonly / modify / full), its interaction with
5
+ * the legacy GITLAB_READ_ONLY_MODE flag, and enforcement at list_tools,
6
+ * call_tool, and execute_graphql levels.
7
+ */
8
+ import { describe, test, after, before } from "node:test";
9
+ import assert from "node:assert";
10
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
11
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
12
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
13
+ const MOCK_TOKEN = "glpat-permission-mode-test";
14
+ const MOCK_PORT_BASE = 9700;
15
+ const MCP_PORT_BASE = 3700;
16
+ let mockGitLab;
17
+ let mockGitLabUrl;
18
+ let portCounter = 0;
19
+ async function nextMcpPort() {
20
+ return findAvailablePort(MCP_PORT_BASE + portCounter++ * 10);
21
+ }
22
+ async function launchMcpServer(extraEnv = {}) {
23
+ const port = await nextMcpPort();
24
+ return launchServer({
25
+ mode: TransportMode.STREAMABLE_HTTP,
26
+ port,
27
+ timeout: 10000,
28
+ env: {
29
+ STREAMABLE_HTTP: "true",
30
+ REMOTE_AUTHORIZATION: "true",
31
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
32
+ GITLAB_TOOLSETS: "all",
33
+ ...extraEnv,
34
+ },
35
+ });
36
+ }
37
+ function mcpUrl(server) {
38
+ return `http://${HOST}:${server.port}/mcp`;
39
+ }
40
+ async function connectClient(server) {
41
+ const client = new CustomHeaderClient({
42
+ authorization: `Bearer ${MOCK_TOKEN}`,
43
+ });
44
+ await client.connect(mcpUrl(server));
45
+ return client;
46
+ }
47
+ async function getToolNames(server) {
48
+ const client = await connectClient(server);
49
+ const result = await client.listTools();
50
+ await client.disconnect();
51
+ return result.tools.map((t) => t.name);
52
+ }
53
+ const DELETE_SAMPLE_TOOLS = [
54
+ "delete_issue",
55
+ "delete_branch",
56
+ "delete_label",
57
+ "delete_wiki_page",
58
+ "delete_milestone",
59
+ "delete_project_variable",
60
+ ];
61
+ const MODIFY_SAMPLE_TOOLS = [
62
+ "create_issue",
63
+ "update_issue",
64
+ "create_merge_request",
65
+ "create_branch",
66
+ "push_files",
67
+ "merge_merge_request",
68
+ ];
69
+ const READ_SAMPLE_TOOLS = ["list_issues", "get_project", "list_merge_requests"];
70
+ describe("Permission Mode", { concurrency: 1 }, () => {
71
+ before(async () => {
72
+ const mockPort = await findMockServerPort();
73
+ mockGitLab = new MockGitLabServer({
74
+ port: mockPort,
75
+ validTokens: [MOCK_TOKEN],
76
+ });
77
+ await mockGitLab.start();
78
+ mockGitLabUrl = mockGitLab.getUrl();
79
+ });
80
+ after(async () => {
81
+ if (mockGitLab)
82
+ await mockGitLab.stop();
83
+ });
84
+ describe("full mode (default)", () => {
85
+ let server;
86
+ const servers = [];
87
+ before(async () => {
88
+ server = await launchMcpServer();
89
+ servers.push(server);
90
+ });
91
+ after(() => cleanupServers(servers));
92
+ test("exposes read, modify, and delete tools", async () => {
93
+ const names = await getToolNames(server);
94
+ for (const name of [...READ_SAMPLE_TOOLS, ...MODIFY_SAMPLE_TOOLS, ...DELETE_SAMPLE_TOOLS]) {
95
+ assert.ok(names.includes(name), `full mode: expected "${name}" to be present`);
96
+ }
97
+ });
98
+ });
99
+ describe("modify mode", () => {
100
+ let server;
101
+ const servers = [];
102
+ before(async () => {
103
+ server = await launchMcpServer({ GITLAB_PERMISSION_MODE: "modify" });
104
+ servers.push(server);
105
+ });
106
+ after(() => cleanupServers(servers));
107
+ test("hides delete tools but keeps read and modify tools", async () => {
108
+ const names = await getToolNames(server);
109
+ for (const name of DELETE_SAMPLE_TOOLS) {
110
+ assert.ok(!names.includes(name), `modify mode: expected "${name}" to be absent`);
111
+ }
112
+ for (const name of [...READ_SAMPLE_TOOLS, ...MODIFY_SAMPLE_TOOLS]) {
113
+ assert.ok(names.includes(name), `modify mode: expected "${name}" to be present`);
114
+ }
115
+ assert.ok(!names.some(n => n.startsWith("delete_")), "no delete_* tool should be listed");
116
+ });
117
+ test("hides purge_dependency_proxy_cache", async () => {
118
+ const names = await getToolNames(server);
119
+ assert.ok(!names.includes("purge_dependency_proxy_cache"), "modify mode: purge_dependency_proxy_cache should be absent");
120
+ });
121
+ test("rejects direct delete tool calls", async () => {
122
+ const client = await connectClient(server);
123
+ try {
124
+ await assert.rejects(() => client.callTool("delete_issue", { project_id: "1", issue_iid: 1 }), (error) => error.message.includes("not allowed in modify mode"), "delete_issue should be rejected in modify mode");
125
+ await assert.rejects(() => client.callTool("purge_dependency_proxy_cache", { group_id: "my-group" }), (error) => error.message.includes("not allowed in modify mode"), "purge_dependency_proxy_cache should be rejected in modify mode");
126
+ }
127
+ finally {
128
+ await client.disconnect();
129
+ }
130
+ });
131
+ test("rejects delete mutations via execute_graphql", async () => {
132
+ const client = await connectClient(server);
133
+ try {
134
+ await assert.rejects(() => client.callTool("execute_graphql", {
135
+ query: 'mutation { issueDelete(input: { projectPath: "g/p", iid: "1" }) { errors } }',
136
+ }), (error) => error.message.includes("delete mutations in modify mode"), "delete mutation should be rejected in modify mode");
137
+ }
138
+ finally {
139
+ await client.disconnect();
140
+ }
141
+ });
142
+ test("does not apply the modify-mode guard to non-delete mutations", async () => {
143
+ const client = await connectClient(server);
144
+ try {
145
+ // The mock GitLab server has no GraphQL endpoint, so the call may fail
146
+ // downstream — but it must not fail with the modify-mode guard error.
147
+ await client.callTool("execute_graphql", {
148
+ query: "mutation { issueSetSeverity(input: { severity: HIGH }) { errors } }",
149
+ });
150
+ }
151
+ catch (error) {
152
+ assert.ok(error instanceof Error);
153
+ assert.ok(!error.message.includes("modify mode"), `non-delete mutation should pass the guard, got: ${error.message}`);
154
+ }
155
+ finally {
156
+ await client.disconnect();
157
+ }
158
+ });
159
+ });
160
+ describe("readonly mode via GITLAB_PERMISSION_MODE", () => {
161
+ let server;
162
+ const servers = [];
163
+ before(async () => {
164
+ server = await launchMcpServer({ GITLAB_PERMISSION_MODE: "readonly" });
165
+ servers.push(server);
166
+ });
167
+ after(() => cleanupServers(servers));
168
+ test("exposes only read-only tools", async () => {
169
+ const names = await getToolNames(server);
170
+ for (const name of READ_SAMPLE_TOOLS) {
171
+ assert.ok(names.includes(name), `readonly mode: expected "${name}" to be present`);
172
+ }
173
+ for (const name of [...MODIFY_SAMPLE_TOOLS, ...DELETE_SAMPLE_TOOLS]) {
174
+ assert.ok(!names.includes(name), `readonly mode: expected "${name}" to be absent`);
175
+ }
176
+ });
177
+ test("rejects write tool calls", async () => {
178
+ const client = await connectClient(server);
179
+ try {
180
+ await assert.rejects(() => client.callTool("create_issue", { project_id: "1", title: "t" }), (error) => error.message.includes("not allowed in read-only mode"), "create_issue should be rejected in readonly mode");
181
+ }
182
+ finally {
183
+ await client.disconnect();
184
+ }
185
+ });
186
+ });
187
+ describe("legacy GITLAB_READ_ONLY_MODE interaction", () => {
188
+ const servers = [];
189
+ after(() => cleanupServers(servers));
190
+ test("GITLAB_READ_ONLY_MODE=true still behaves as readonly", async () => {
191
+ const server = await launchMcpServer({ GITLAB_READ_ONLY_MODE: "true" });
192
+ servers.push(server);
193
+ const names = await getToolNames(server);
194
+ assert.ok(names.includes("list_issues"), "read tool should be present");
195
+ assert.ok(!names.includes("create_issue"), "write tool should be absent");
196
+ assert.ok(!names.includes("delete_issue"), "delete tool should be absent");
197
+ });
198
+ test("GITLAB_READ_ONLY_MODE=true wins over GITLAB_PERMISSION_MODE=full", async () => {
199
+ const server = await launchMcpServer({
200
+ GITLAB_READ_ONLY_MODE: "true",
201
+ GITLAB_PERMISSION_MODE: "full",
202
+ });
203
+ servers.push(server);
204
+ const names = await getToolNames(server);
205
+ assert.ok(!names.includes("create_issue"), "legacy readonly should take precedence");
206
+ assert.ok(!names.includes("delete_issue"), "legacy readonly should take precedence");
207
+ });
208
+ test("rejects invalid GITLAB_PERMISSION_MODE even when GITLAB_READ_ONLY_MODE=true", async () => {
209
+ await assert.rejects(launchMcpServer({
210
+ GITLAB_READ_ONLY_MODE: "true",
211
+ GITLAB_PERMISSION_MODE: "write",
212
+ }), (error) => error.message.includes("Server process exited with code 1"), "invalid permission mode should fail startup before legacy override applies");
213
+ });
214
+ });
215
+ });
@@ -71,7 +71,7 @@ describe("protected branch tools", () => {
71
71
  let mockGitLab;
72
72
  let mockGitLabUrl;
73
73
  before(async () => {
74
- const mockPort = await findMockServerPort(20500, 50);
74
+ const mockPort = await findMockServerPort();
75
75
  mockGitLab = new MockGitLabServer({
76
76
  port: mockPort,
77
77
  validTokens: [MOCK_TOKEN],
@@ -49,7 +49,7 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
49
49
  let server;
50
50
  let serverPort;
51
51
  before(async () => {
52
- const mockPort = await findMockServerPort(9300);
52
+ const mockPort = await findMockServerPort();
53
53
  mockGitLab = new MockGitLabServer({
54
54
  port: mockPort,
55
55
  validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN],
@@ -222,7 +222,7 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000
222
222
  let serverPort;
223
223
  let sessionId;
224
224
  before(async () => {
225
- const mockPort = await findMockServerPort(9310);
225
+ const mockPort = await findMockServerPort();
226
226
  mockGitLab = new MockGitLabServer({
227
227
  port: mockPort,
228
228
  validTokens: [MOCK_TOKEN],
@@ -58,7 +58,7 @@ let mockGitLab;
58
58
  let mockGitLabUrl;
59
59
  describe("Search Code Tools", () => {
60
60
  before(async () => {
61
- const mockPort = await findMockServerPort(MOCK_PORT_BASE);
61
+ const mockPort = await findMockServerPort();
62
62
  mockGitLab = new MockGitLabServer({
63
63
  port: mockPort,
64
64
  validTokens: [MOCK_TOKEN],
@@ -88,7 +88,7 @@ describe("tag tools", () => {
88
88
  let mockGitLab;
89
89
  let mockGitLabUrl;
90
90
  before(async () => {
91
- const mockPort = await findMockServerPort(20000, 50);
91
+ const mockPort = await findMockServerPort();
92
92
  mockGitLab = new MockGitLabServer({
93
93
  port: mockPort,
94
94
  validTokens: [MOCK_TOKEN],
@@ -105,7 +105,7 @@ describe("GitLab todos tools", () => {
105
105
  let mockGitLab;
106
106
  let mockGitLabUrl;
107
107
  before(async () => {
108
- const mockPort = await findMockServerPort(9250);
108
+ const mockPort = await findMockServerPort();
109
109
  mockGitLab = new MockGitLabServer({
110
110
  port: mockPort,
111
111
  validTokens: [MOCK_TOKEN],
@@ -129,7 +129,7 @@ describe("Tool Policy (Proposal F)", { concurrency: 1 }, () => {
129
129
  let mockGitLab;
130
130
  let mockGitLabUrl;
131
131
  before(async () => {
132
- const mockPort = await findMockServerPort(MOCK_PORT_BASE);
132
+ const mockPort = await findMockServerPort();
133
133
  mockGitLab = new MockGitLabServer({
134
134
  port: mockPort,
135
135
  validTokens: [MOCK_TOKEN],
@@ -277,7 +277,7 @@ describe("Policy Edge Cases", { concurrency: 1 }, () => {
277
277
  let mockGitLab;
278
278
  let mockGitLabUrl;
279
279
  before(async () => {
280
- const mockPort = await findMockServerPort(MOCK_PORT_BASE + 50);
280
+ const mockPort = await findMockServerPort();
281
281
  mockGitLab = new MockGitLabServer({
282
282
  port: mockPort,
283
283
  validTokens: [MOCK_TOKEN],
@@ -557,7 +557,7 @@ describe("Dynamic Discovery (Proposal H)", { concurrency: 1 }, () => {
557
557
  let mockGitLab;
558
558
  let mockGitLabUrl;
559
559
  before(async () => {
560
- const mockPort = await findMockServerPort(MOCK_PORT_BASE + 100);
560
+ const mockPort = await findMockServerPort();
561
561
  mockGitLab = new MockGitLabServer({
562
562
  port: mockPort,
563
563
  validTokens: [MOCK_TOKEN],
@@ -128,7 +128,7 @@ async function nextMcpPort() {
128
128
  }
129
129
  describe("Toolset Filtering", { concurrency: 1 }, () => {
130
130
  before(async () => {
131
- const mockPort = await findMockServerPort(MOCK_PORT_BASE);
131
+ const mockPort = await findMockServerPort();
132
132
  mockGitLab = new MockGitLabServer({
133
133
  port: mockPort,
134
134
  validTokens: [MOCK_TOKEN],
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests for the update_issue slim response (issue #563).
3
+ * By default update_issue returns a slim confirmation; full_response=true
4
+ * returns the complete updated issue object.
5
+ */
6
+ import { describe, test, before, after } from "node:test";
7
+ import assert from "node:assert";
8
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
9
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
10
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
11
+ const MOCK_TOKEN = "glpat-slim-update-test-token";
12
+ describe("update_issue slim response", () => {
13
+ let mockGitLab;
14
+ let server;
15
+ let client;
16
+ const MOCK_PORT_BASE = 9650;
17
+ const MCP_PORT_BASE = 3650;
18
+ before(async () => {
19
+ const mockPort = await findMockServerPort();
20
+ mockGitLab = new MockGitLabServer({
21
+ port: mockPort,
22
+ validTokens: [MOCK_TOKEN],
23
+ });
24
+ await mockGitLab.start();
25
+ server = await launchServer({
26
+ mode: TransportMode.STREAMABLE_HTTP,
27
+ port: await findAvailablePort(MCP_PORT_BASE),
28
+ timeout: 10000,
29
+ env: {
30
+ STREAMABLE_HTTP: "true",
31
+ REMOTE_AUTHORIZATION: "true",
32
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
33
+ GITLAB_ACCESS_TOKEN: MOCK_TOKEN,
34
+ GITLAB_TOOLSETS: "issues",
35
+ },
36
+ });
37
+ client = new CustomHeaderClient({
38
+ headers: {
39
+ authorization: `Bearer ${MOCK_TOKEN}`,
40
+ },
41
+ });
42
+ await client.connect(`http://${HOST}:${server.port ?? 0}/mcp`);
43
+ });
44
+ after(async () => {
45
+ await client?.disconnect();
46
+ cleanupServers([server]);
47
+ await mockGitLab?.stop();
48
+ });
49
+ test("default response is slim: no description echo", async () => {
50
+ const result = await client.callTool("update_issue", {
51
+ project_id: "test/project",
52
+ issue_iid: "1",
53
+ description: "A very long description that should not be echoed back",
54
+ });
55
+ const data = JSON.parse(result.content[0]?.text || "{}");
56
+ assert.strictEqual(data.iid, "1");
57
+ assert.strictEqual(data.state, "opened");
58
+ assert.ok(data.title, "title should be present");
59
+ assert.ok(data.web_url, "web_url should be present");
60
+ assert.ok(data.updated_at, "updated_at should be present");
61
+ assert.strictEqual(data.description, undefined, "description must not be echoed");
62
+ assert.strictEqual(data.author, undefined, "author must not be included");
63
+ assert.strictEqual(data.milestone, undefined, "milestone must not be included");
64
+ });
65
+ test("slim response reflects state changes", async () => {
66
+ const result = await client.callTool("update_issue", {
67
+ project_id: "test/project",
68
+ issue_iid: "2",
69
+ state_event: "close",
70
+ });
71
+ const data = JSON.parse(result.content[0]?.text || "{}");
72
+ assert.strictEqual(data.state, "closed");
73
+ assert.strictEqual(data.description, undefined);
74
+ });
75
+ test('full_response="false" string keeps the response slim', async () => {
76
+ const result = await client.callTool("update_issue", {
77
+ project_id: "test/project",
78
+ issue_iid: "3",
79
+ description: "A long description that should still not be echoed",
80
+ full_response: "false",
81
+ });
82
+ const data = JSON.parse(result.content[0]?.text || "{}");
83
+ assert.strictEqual(data.description, undefined);
84
+ assert.strictEqual(data.author, undefined);
85
+ assert.strictEqual(data.iid, "3");
86
+ });
87
+ test("full_response=true returns the complete issue object", async () => {
88
+ const result = await client.callTool("update_issue", {
89
+ project_id: "test/project",
90
+ issue_iid: "4",
91
+ description: "Full description expected in response",
92
+ full_response: true,
93
+ });
94
+ const data = JSON.parse(result.content[0]?.text || "{}");
95
+ assert.strictEqual(data.description, "Full description expected in response");
96
+ assert.ok(data.author, "author should be present in full response");
97
+ assert.strictEqual(data.iid, "4");
98
+ });
99
+ test("full_response is not forwarded to the GitLab API", async () => {
100
+ const result = await client.callTool("update_issue", {
101
+ project_id: "test/project",
102
+ issue_iid: "5",
103
+ title: "Updated title",
104
+ full_response: true,
105
+ });
106
+ const data = JSON.parse(result.content[0]?.text || "{}");
107
+ assert.strictEqual(data.title, "Updated title");
108
+ assert.strictEqual(data.full_response, undefined, "full_response must not leak into the issue");
109
+ });
110
+ test("get_issue slims the milestone by default", async () => {
111
+ const result = await client.callTool("get_issue", {
112
+ project_id: "test/project",
113
+ issue_iid: "100",
114
+ });
115
+ const data = JSON.parse(result.content[0]?.text || "{}");
116
+ assert.ok(data.milestone, "milestone should be present");
117
+ assert.strictEqual(data.milestone.title, "Sprint 42");
118
+ assert.ok(data.milestone.web_url, "milestone web_url should be present");
119
+ assert.strictEqual(data.milestone.description, undefined, "milestone description must not be echoed by default");
120
+ assert.ok(data.description, "issue description should still be present");
121
+ });
122
+ test("get_issue full_response=true returns the full milestone", async () => {
123
+ const result = await client.callTool("get_issue", {
124
+ project_id: "test/project",
125
+ issue_iid: "100",
126
+ full_response: true,
127
+ });
128
+ const data = JSON.parse(result.content[0]?.text || "{}");
129
+ assert.ok(data.milestone, "milestone should be present");
130
+ assert.ok(typeof data.milestone.description === "string" && data.milestone.description.length > 0, "milestone description should be included with full_response");
131
+ });
132
+ test("get_issue without milestone is unaffected", async () => {
133
+ const result = await client.callTool("get_issue", {
134
+ project_id: "test/project",
135
+ issue_iid: "6",
136
+ });
137
+ const data = JSON.parse(result.content[0]?.text || "{}");
138
+ assert.strictEqual(data.milestone, null);
139
+ assert.strictEqual(data.iid, "6");
140
+ });
141
+ });
@@ -68,7 +68,7 @@ describe('upload_markdown', () => {
68
68
  lastRawBody = undefined;
69
69
  });
70
70
  before(async () => {
71
- const port = await findMockServerPort(9200);
71
+ const port = await findMockServerPort();
72
72
  mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
73
73
  await mockGitLab.start();
74
74
  env = {
@@ -0,0 +1,35 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { createDownloadToken, decryptDownloadToken } from "../../utils/download-token.js";
4
+ describe("download token helpers", () => {
5
+ test("round-trips auth, api URL, and resource binding", () => {
6
+ const token = createDownloadToken("Authorization", "Bearer glpat-example-token", "https://gitlab.example.com/api/v4", {
7
+ type: "attachment",
8
+ params: { project_id: "group/project", secret: "abc", filename: "file.txt" },
9
+ });
10
+ const decrypted = decryptDownloadToken(token);
11
+ assert.deepStrictEqual(decrypted, {
12
+ header: "Authorization",
13
+ token: "Bearer glpat-example-token",
14
+ apiUrl: "https://gitlab.example.com/api/v4",
15
+ resourceType: "attachment",
16
+ resourceParams: { project_id: "group/project", secret: "abc", filename: "file.txt" },
17
+ });
18
+ });
19
+ test("returns null for invalid tokens", () => {
20
+ assert.strictEqual(decryptDownloadToken("not-a-valid-token"), null);
21
+ });
22
+ test("returns null for expired tokens", () => {
23
+ const originalNow = Date.now;
24
+ const issuedAt = 1_700_000_000_000;
25
+ try {
26
+ Date.now = () => issuedAt;
27
+ const token = createDownloadToken("Private-Token", "glpat-example-token");
28
+ Date.now = () => issuedAt + 301_000;
29
+ assert.strictEqual(decryptDownloadToken(token), null);
30
+ }
31
+ finally {
32
+ Date.now = originalNow;
33
+ }
34
+ });
35
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from "node:test";
2
2
  import assert from "node:assert";
3
- import { getForwardedPublicBaseUrl } from "../../utils/forwarded-public-base-url.js";
3
+ import { getForwardedPublicBaseUrl, getForwardedRequestHost } from "../../utils/forwarded-public-base-url.js";
4
4
  function req(headers) {
5
5
  return { headers };
6
6
  }
@@ -36,3 +36,11 @@ describe("getForwardedPublicBaseUrl", () => {
36
36
  }), true), "https://mcp.example.com");
37
37
  });
38
38
  });
39
+ describe("getForwardedRequestHost", () => {
40
+ test("returns undefined unless proxy headers are trusted", () => {
41
+ assert.strictEqual(getForwardedRequestHost(req({ "x-forwarded-host": "mcp.example.com" }), false), undefined);
42
+ });
43
+ test("returns the forwarded host when proxy headers are trusted", () => {
44
+ assert.strictEqual(getForwardedRequestHost(req({ "x-forwarded-host": "mcp.example.com" }), true), "mcp.example.com");
45
+ });
46
+ });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
- import { graphqlQueryContainsWriteOperation } from "../../utils/graphql-query.js";
3
+ import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "../../utils/graphql-query.js";
4
4
  describe("When graphqlQueryContainsWriteOperation runs", () => {
5
5
  describe("with read-only GraphQL documents", () => {
6
6
  test("should allow explicit query operations", () => {
@@ -15,6 +15,18 @@ describe("When graphqlQueryContainsWriteOperation runs", () => {
15
15
  test("should ignore mutation text inside string literals", () => {
16
16
  assert.equal(graphqlQueryContainsWriteOperation('query { search(query: "mutation") { nodes { id } } }'), false);
17
17
  });
18
+ test("should ignore escaped triple quotes inside block strings", () => {
19
+ assert.equal(graphqlQueryContainsWriteOperation('query { project(fullPath: """contains \\""" not a mutation""") { id } }'), false);
20
+ });
21
+ test("should not treat subscription field names as write operations", () => {
22
+ assert.equal(graphqlQueryContainsWriteOperation("query { project { id }, subscription { count } }"), false);
23
+ });
24
+ test("should allow query with operation name 'mutation'", () => {
25
+ assert.equal(graphqlQueryContainsWriteOperation("query mutation { project { id } }"), false);
26
+ });
27
+ test("should allow query with operation name 'subscription'", () => {
28
+ assert.equal(graphqlQueryContainsWriteOperation("query subscription { project { id } }"), false);
29
+ });
18
30
  });
19
31
  describe("with write GraphQL documents", () => {
20
32
  test("should detect mutation operations", () => {
@@ -29,5 +41,56 @@ describe("When graphqlQueryContainsWriteOperation runs", () => {
29
41
  test("should detect semicolon-separated write operations", () => {
30
42
  assert.equal(graphqlQueryContainsWriteOperation("query A { a }; mutation B { b }"), true);
31
43
  });
44
+ test("should detect comma-prefixed write operations", () => {
45
+ assert.equal(graphqlQueryContainsWriteOperation(",mutation { destroyProject(input: { projectId: 1 }) { errors } }"), true);
46
+ });
47
+ test("should detect comma-separated write operations", () => {
48
+ assert.equal(graphqlQueryContainsWriteOperation("query A { a }, mutation B { b }"), true);
49
+ });
50
+ });
51
+ });
52
+ describe("When graphqlQueryContainsDeleteOperation runs", () => {
53
+ describe("with non-delete documents", () => {
54
+ test("should allow plain queries even when they mention delete words", () => {
55
+ assert.equal(graphqlQueryContainsDeleteOperation("query { project { removeSourceBranchAfterMerge } }"), false);
56
+ });
57
+ test("should allow non-delete mutations", () => {
58
+ assert.equal(graphqlQueryContainsDeleteOperation('mutation { issueSetSeverity(input: { severity: HIGH }) { errors } }'), false);
59
+ });
60
+ test("should not count delete-like argument names inside parentheses", () => {
61
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { mergeRequestUpdate(input: { removeSourceBranch: true }) { errors } }"), false);
62
+ });
63
+ test("should not count delete-like fields nested below the mutation root", () => {
64
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { issueSetLabels(input: {}) { issue { canBeDeleted } } }"), false);
65
+ });
66
+ test("should ignore delete text inside comments", () => {
67
+ assert.equal(graphqlQueryContainsDeleteOperation("# issueDelete\nmutation { issueSetLabels { errors } }"), false);
68
+ });
69
+ test("should ignore delete text inside string literals", () => {
70
+ assert.equal(graphqlQueryContainsDeleteOperation('mutation { createNote(input: { body: "projectDelete" }) { errors } }'), false);
71
+ });
72
+ });
73
+ describe("with delete documents", () => {
74
+ test("should detect delete mutation fields", () => {
75
+ assert.equal(graphqlQueryContainsDeleteOperation('mutation { issueDelete(input: { projectPath: "g/p", iid: "1" }) { errors } }'), true);
76
+ });
77
+ test("should detect destroy mutation fields", () => {
78
+ assert.equal(graphqlQueryContainsDeleteOperation('mutation { destroyBoard(input: { id: "gid://gitlab/Board/1" }) { errors } }'), true);
79
+ });
80
+ test("should detect remove mutation fields", () => {
81
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { awardEmojiRemove(input: {}) { errors } }"), true);
82
+ });
83
+ test("should detect aliased delete mutation fields", () => {
84
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { a: issueDelete(input: {}) { errors } }"), true);
85
+ });
86
+ test("should detect delete mutations in multi-operation documents", () => {
87
+ assert.equal(graphqlQueryContainsDeleteOperation("query A { project { id } } mutation B { labelDelete(input: {}) { errors } }"), true);
88
+ });
89
+ test("should detect delete mutations with variable definitions", () => {
90
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation DeleteIssue($id: IssueID!) { issueDelete(input: { id: $id }) { errors } }"), true);
91
+ });
92
+ test("should conservatively block top-level fragment spreads in mutations", () => {
93
+ assert.equal(graphqlQueryContainsDeleteOperation("mutation { ...f } fragment f on Mutation { issueDelete(input: {}) { errors } }"), true);
94
+ });
32
95
  });
33
96
  });