@zereight/mcp-gitlab 2.1.28 → 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.
- package/README.ko.md +82 -28
- package/README.md +56 -23
- package/README.zh-CN.md +82 -28
- package/build/config.js +13 -0
- package/build/downloads/proxy.js +199 -0
- package/build/index.js +564 -482
- package/build/oauth-proxy.js +18 -4
- package/build/schemas.js +47 -9
- package/build/scripts/check-skill-sync.js +137 -0
- package/build/scripts/generate-tool-docs.js +7 -2
- package/build/server/metrics.js +84 -0
- package/build/server/transport-mode.js +25 -0
- package/build/test/callback-proxy-tests.js +1 -1
- package/build/test/client-pool-test.js +1 -1
- package/build/test/dynamic-api-url-allowlist.test.js +2 -2
- package/build/test/dynamic-api-url-test.js +4 -4
- package/build/test/dynamic-routing-tests.js +4 -4
- package/build/test/mcp-oauth-tests.js +17 -4
- package/build/test/multi-server-test.js +3 -3
- package/build/test/no-proxy-integration-test.js +1 -1
- package/build/test/path-segment-encoding.test.js +27 -0
- package/build/test/remote-auth-simple-test.js +226 -199
- package/build/test/server/metrics.test.js +47 -0
- package/build/test/sse-auth-guard.test.js +2 -2
- package/build/test/stateless/callback-proxy.test.js +8 -0
- package/build/test/stateless/session-id-integration.test.js +3 -3
- package/build/test/streamable-http-concurrent-session.test.js +1 -1
- package/build/test/streamable-http-dns-rebinding.test.js +185 -0
- package/build/test/streamable-http-static-token-auth.test.js +25 -22
- package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
- package/build/test/test-ci-catalog.js +1 -1
- package/build/test/test-ci-lint.js +1 -1
- package/build/test/test-ci-variables.js +8 -5
- package/build/test/test-dependency-proxy.js +11 -7
- package/build/test/test-deployment-tools.js +16 -2
- package/build/test/test-download-attachment.js +1 -1
- package/build/test/test-get-file-blame.js +1 -1
- package/build/test/test-geteffectiveprojectid.js +7 -7
- package/build/test/test-issue-description-patch.js +1 -1
- package/build/test/test-job-artifacts.js +1 -1
- package/build/test/test-list-issues.js +1 -1
- package/build/test/test-list-merge-requests.js +1 -1
- package/build/test/test-list-project-members.js +1 -1
- package/build/test/test-merge-request-approval-state-tools.js +1 -1
- package/build/test/test-merge-request-pipelines.js +1 -1
- package/build/test/test-mr-diffs-filter.js +1 -1
- package/build/test/test-mr-file-diffs.js +2 -2
- package/build/test/test-oauth-proxy-rate-limit.js +1 -1
- package/build/test/test-permission-mode.js +215 -0
- package/build/test/test-protected-branches.js +1 -1
- package/build/test/test-remote-downloads.js +2 -2
- package/build/test/test-search-code.js +1 -1
- package/build/test/test-tags.js +1 -1
- package/build/test/test-todos.js +1 -1
- package/build/test/test-token-optimizations.js +3 -3
- package/build/test/test-toolset-filtering.js +1 -1
- package/build/test/test-update-issue-slim.js +141 -0
- package/build/test/test-upload-markdown.js +1 -1
- package/build/test/utils/download-token.test.js +35 -0
- package/build/test/utils/forwarded-public-base-url.test.js +9 -1
- package/build/test/utils/graphql-query.test.js +64 -1
- package/build/test/utils/mock-gitlab-server.js +24 -31
- package/build/test/utils/redact-sensitive.test.js +32 -0
- package/build/test/utils/server-launcher.js +1 -2
- package/build/test/utils/version-check.test.js +52 -0
- package/build/tools/registry.js +30 -5
- package/build/utils/download-token.js +71 -0
- package/build/utils/forwarded-public-base-url.js +22 -0
- package/build/utils/graphql-query.js +109 -12
- package/build/utils/redact-sensitive.js +21 -0
- package/build/utils/version-check.js +40 -0
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
61
|
+
const mockPort = await findMockServerPort();
|
|
62
62
|
mockGitLab = new MockGitLabServer({
|
|
63
63
|
port: mockPort,
|
|
64
64
|
validTokens: [MOCK_TOKEN],
|
package/build/test/test-tags.js
CHANGED
|
@@ -88,7 +88,7 @@ describe("tag tools", () => {
|
|
|
88
88
|
let mockGitLab;
|
|
89
89
|
let mockGitLabUrl;
|
|
90
90
|
before(async () => {
|
|
91
|
-
const mockPort = await findMockServerPort(
|
|
91
|
+
const mockPort = await findMockServerPort();
|
|
92
92
|
mockGitLab = new MockGitLabServer({
|
|
93
93
|
port: mockPort,
|
|
94
94
|
validTokens: [MOCK_TOKEN],
|
package/build/test/test-todos.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
});
|