@zereight/mcp-gitlab 2.1.63 → 2.1.65
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 +20 -5
- package/README.md +19 -6
- package/README.zh-CN.md +19 -6
- package/build/downloads/proxy.js +14 -7
- package/build/index.js +413 -455
- package/build/schemas.js +2 -2
- package/build/scripts/generate-tool-coverage.js +58 -0
- package/build/scripts/generate-tool-docs.js +6 -1
- package/build/scripts/tool-coverage/coverage.js +339 -0
- package/build/scripts/tool-coverage/coverage.test.js +142 -0
- package/build/test/path-segment-encoding.test.js +73 -17
- package/build/test/sse-session-limits.test.js +273 -0
- package/build/test/test-blank-filters.js +156 -0
- package/build/test/test-download-attachment.js +11 -0
- package/build/test/test-empty-toolsets.js +535 -0
- package/build/test/test-geteffectiveprojectid.js +1 -1
- package/build/test/test-job-artifacts.js +22 -0
- package/build/test/test-list-issues.js +47 -0
- package/build/test/test-list-merge-requests.js +76 -0
- package/build/test/test-list-milestones.js +145 -0
- package/build/test/test-permission-mode.js +103 -12
- package/build/test/test-remote-downloads.js +14 -1
- package/build/test/utils/graphql-query.test.js +72 -0
- package/build/test/utils/safe-redirect-fetch.test.js +463 -0
- package/build/test/utils/tool-args.test.js +89 -1
- package/build/test/utils/url.test.js +128 -0
- package/build/test-note.js +2 -1
- package/build/test-resolve-issue-note.js +4 -3
- package/build/tools/registry.js +16 -2
- package/build/utils/graphql-query.js +31 -9
- package/build/utils/safe-redirect-fetch.js +321 -0
- package/build/utils/tool-args.js +47 -3
- package/build/utils/url.js +102 -0
- package/package.json +2 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { test } from "node:test";
|
|
4
|
-
|
|
4
|
+
import { encodeGitLabPath, encodeGitLabPathSegment } from "../utils/url.js";
|
|
5
|
+
const readSource = (relativePath) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
|
6
|
+
const indexSource = readSource("../index.ts");
|
|
5
7
|
const rawPathSegmentPatterns = [
|
|
6
8
|
"/issues/${issueIid}",
|
|
7
9
|
"/merge_requests/${mergeRequestIid}",
|
|
@@ -21,22 +23,76 @@ const rawPathSegmentPatterns = [
|
|
|
21
23
|
];
|
|
22
24
|
test("GitLab URL path IDs are encoded before interpolation", () => {
|
|
23
25
|
for (const pattern of rawPathSegmentPatterns) {
|
|
24
|
-
assert.equal(
|
|
26
|
+
assert.equal(indexSource.includes(pattern), false, `${pattern} must use encodeGitLabPathSegment`);
|
|
25
27
|
}
|
|
26
28
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
29
|
+
/**
|
|
30
|
+
* encodeURIComponent() does not validate what it encodes: "../../user" becomes
|
|
31
|
+
* "..%2F..%2Fuser", which a server that decodes the separator turns back into a
|
|
32
|
+
* traversal. Every value that lands in a URL path goes through
|
|
33
|
+
* encodeGitLabPathSegment()/encodeGitLabPath() instead; plain encoding stays only
|
|
34
|
+
* for query-string values, where a "/" is data and not a path separator.
|
|
35
|
+
*/
|
|
36
|
+
const queryStringEncoders = [
|
|
37
|
+
"?source_branch=${encodeURIComponent(branchName)}",
|
|
38
|
+
"?access_token=${encodeURIComponent(token)}",
|
|
39
|
+
];
|
|
40
|
+
const serverSources = [
|
|
41
|
+
["index.ts", "../index.ts"],
|
|
42
|
+
["downloads/proxy.ts", "../downloads/proxy.ts"],
|
|
43
|
+
["oauth-proxy.ts", "../oauth-proxy.ts"],
|
|
44
|
+
];
|
|
45
|
+
for (const [name, relativePath] of serverSources) {
|
|
46
|
+
test(`${name} validates values before they reach a URL path`, () => {
|
|
47
|
+
for (const line of readSource(relativePath).split("\n")) {
|
|
48
|
+
if (!line.includes("encodeURIComponent("))
|
|
49
|
+
continue;
|
|
50
|
+
assert.ok(queryStringEncoders.some(allowed => line.includes(allowed)), `${name} encodes a URL value without validating it: ${line.trim()}`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
test("plain encoding of a project path would leave the traversal visible", () => {
|
|
55
|
+
// The payload this guard exists for: the old encoder emits the separators, so a
|
|
56
|
+
// server that decodes them routes the request to /api/v4/user.
|
|
57
|
+
const payload = "../../user";
|
|
58
|
+
const legacy = `https://gitlab.com/api/v4/projects/${encodeURIComponent(payload)}`;
|
|
59
|
+
assert.equal(new URL(legacy).pathname, "/api/v4/projects/..%2F..%2Fuser");
|
|
60
|
+
});
|
|
61
|
+
test("the validated encoder keeps an ordinary project path inside one segment", () => {
|
|
62
|
+
const url = new URL(`https://gitlab.com/api/v4/projects/${encodeGitLabPathSegment("group/project")}/issues/${encodeGitLabPathSegment("1")}/notes`);
|
|
63
|
+
assert.equal(url.pathname, "/api/v4/projects/group%2Fproject/issues/1/notes");
|
|
64
|
+
});
|
|
65
|
+
test("path traversal payloads are rejected instead of encoded into one segment", () => {
|
|
66
|
+
// GHSA-7c3w-fxgh-frc7 / #587: an unencoded job_id with ../ escaped to /api/v4/user
|
|
67
|
+
for (const payload of [
|
|
68
|
+
"../../../user",
|
|
69
|
+
"1/../../../../admin/ci/variables",
|
|
70
|
+
"..%2F..%2Fuser",
|
|
71
|
+
"..%252F..%252Fuser",
|
|
72
|
+
"/etc/passwd",
|
|
73
|
+
]) {
|
|
74
|
+
assert.throws(() => encodeGitLabPathSegment(payload), /Cannot use value as a GitLab URL path segment/, payload);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
test("a slash-separated path cannot start outside its route prefix", () => {
|
|
78
|
+
assert.throws(() => encodeGitLabPath("/etc/passwd"), /Cannot use value as a GitLab URL path/);
|
|
79
|
+
assert.throws(() => encodeGitLabPath("1/../../../../admin/ci/variables"), /Cannot use value as a GitLab URL path segment/);
|
|
80
|
+
});
|
|
81
|
+
test("a broken escape next to a valid one is rejected, not passed through", () => {
|
|
82
|
+
// A guard cannot inspect a value it fails to decode: "%2E%2E%2F%ZZ" still holds a
|
|
83
|
+
// real escape that may hide a separator, so it must not be forwarded unvalidated.
|
|
84
|
+
assert.throws(() => encodeGitLabPathSegment("%2E%2E%2F%ZZ"), /Cannot use value as a GitLab URL path segment/);
|
|
85
|
+
});
|
|
86
|
+
test("the artifact path goes through the shared multi-segment validator", () => {
|
|
87
|
+
// getJobArtifactFile() was the last call site that split the path itself. The
|
|
88
|
+
// hand-rolled version kept empty segments, so "a//b" reached the URL as an extra
|
|
89
|
+
// separator instead of being rejected like every other call site.
|
|
90
|
+
assert.ok(indexSource.includes("const encodedArtifactPath = encodeGitLabPath(artifactPath);"), "artifact paths must go through encodeGitLabPath");
|
|
91
|
+
assert.equal(indexSource.includes(".map(segment => encodeGitLabPathSegment(segment))"), false, "artifact paths must not be split into segments by hand");
|
|
92
|
+
});
|
|
93
|
+
test("a percent sign that starts no escape stays a literal character", () => {
|
|
94
|
+
// "bad%zz/path" cannot be decoded either, but it holds no escape at all, so the
|
|
95
|
+
// guard sees the entire value and the percent sign is encoded as data. Rejecting
|
|
96
|
+
// it would break every file or branch name containing "%".
|
|
97
|
+
assert.equal(encodeGitLabPathSegment("bad%zz/path"), "bad%25zz%2Fpath");
|
|
42
98
|
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { afterEach, describe, test } from "node:test";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { findAvailablePort } from "./utils/server-launcher.js";
|
|
7
|
+
const LOOPBACK = "127.0.0.1";
|
|
8
|
+
const SSE_TOKEN = "mcp_sse_secret";
|
|
9
|
+
const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
|
|
10
|
+
// Keeps connection churn from tripping the per-IP creation limit in tests that
|
|
11
|
+
// are not about rate limiting.
|
|
12
|
+
const UNTHROTTLED = "1000";
|
|
13
|
+
const running = new Set();
|
|
14
|
+
function startSseServer(env, port) {
|
|
15
|
+
const child = spawn("node", [SERVER_PATH], {
|
|
16
|
+
env: {
|
|
17
|
+
...process.env,
|
|
18
|
+
GITLAB_API_URL: "https://gitlab.example.com/api/v4",
|
|
19
|
+
HOST: "0.0.0.0",
|
|
20
|
+
PORT: String(port),
|
|
21
|
+
SSE: "true",
|
|
22
|
+
STREAMABLE_HTTP: "false",
|
|
23
|
+
REMOTE_AUTHORIZATION: "false",
|
|
24
|
+
GITLAB_MCP_OAUTH: "false",
|
|
25
|
+
GITLAB_USE_OAUTH: "false",
|
|
26
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
|
|
27
|
+
GITLAB_JOB_TOKEN: "",
|
|
28
|
+
GITLAB_AUTH_COOKIE_PATH: "",
|
|
29
|
+
SSE_AUTH_TOKEN: SSE_TOKEN,
|
|
30
|
+
...env,
|
|
31
|
+
},
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
33
|
+
});
|
|
34
|
+
running.add(child);
|
|
35
|
+
child.once("exit", () => running.delete(child));
|
|
36
|
+
return child;
|
|
37
|
+
}
|
|
38
|
+
async function waitForHealth(port, timeoutMs = 10000) {
|
|
39
|
+
const deadline = Date.now() + timeoutMs;
|
|
40
|
+
let lastError;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(`http://${LOOPBACK}:${port}/health`);
|
|
44
|
+
if (response.ok)
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
lastError = error;
|
|
49
|
+
}
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`server did not become healthy: ${String(lastError)}`);
|
|
53
|
+
}
|
|
54
|
+
function openSseConnection(port) {
|
|
55
|
+
let statusCode;
|
|
56
|
+
let text = "";
|
|
57
|
+
let resolveEnded = () => { };
|
|
58
|
+
const ended = new Promise(resolve => {
|
|
59
|
+
resolveEnded = resolve;
|
|
60
|
+
});
|
|
61
|
+
const request = http.get({
|
|
62
|
+
host: LOOPBACK,
|
|
63
|
+
port,
|
|
64
|
+
path: "/sse",
|
|
65
|
+
headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
|
|
66
|
+
}, response => {
|
|
67
|
+
statusCode = response.statusCode;
|
|
68
|
+
response.on("data", chunk => {
|
|
69
|
+
text += chunk.toString();
|
|
70
|
+
});
|
|
71
|
+
response.on("end", () => resolveEnded());
|
|
72
|
+
response.on("close", () => resolveEnded());
|
|
73
|
+
});
|
|
74
|
+
request.on("error", () => resolveEnded());
|
|
75
|
+
return {
|
|
76
|
+
get statusCode() {
|
|
77
|
+
return statusCode;
|
|
78
|
+
},
|
|
79
|
+
streamText: () => text,
|
|
80
|
+
close: () => {
|
|
81
|
+
request.destroy();
|
|
82
|
+
resolveEnded();
|
|
83
|
+
},
|
|
84
|
+
ended,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function waitFor(predicate, timeoutMs = 5000, stepMs = 50) {
|
|
88
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
if (predicate())
|
|
91
|
+
return true;
|
|
92
|
+
await new Promise(resolve => setTimeout(resolve, stepMs));
|
|
93
|
+
}
|
|
94
|
+
return predicate();
|
|
95
|
+
}
|
|
96
|
+
/** Resolves true when the server ends the stream within the window. */
|
|
97
|
+
async function endedWithin(connection, ms) {
|
|
98
|
+
return Promise.race([
|
|
99
|
+
connection.ended.then(() => true),
|
|
100
|
+
new Promise(resolve => setTimeout(() => resolve(false), ms)),
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
/** Reads the session id the transport advertises in its opening `endpoint` event. */
|
|
104
|
+
async function readSessionId(connection, timeoutMs = 5000) {
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
while (Date.now() < deadline) {
|
|
107
|
+
const match = /data: \/messages\?sessionId=([^\s]+)/.exec(connection.streamText());
|
|
108
|
+
if (match)
|
|
109
|
+
return match[1];
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`SSE stream did not advertise a session id: ${connection.streamText()}`);
|
|
113
|
+
}
|
|
114
|
+
async function postSseMessage(port, sessionId, body) {
|
|
115
|
+
const response = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=${sessionId}`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { Authorization: `Bearer ${SSE_TOKEN}`, "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify(body),
|
|
119
|
+
});
|
|
120
|
+
await response.arrayBuffer();
|
|
121
|
+
return response.status;
|
|
122
|
+
}
|
|
123
|
+
async function getSseStatus(port) {
|
|
124
|
+
const response = await fetch(`http://${LOOPBACK}:${port}/sse`, {
|
|
125
|
+
headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
|
|
126
|
+
});
|
|
127
|
+
await response.arrayBuffer();
|
|
128
|
+
return response.status;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Connection attempts are rate-limited per IP, so a capacity slot released
|
|
132
|
+
* asynchronously has to be polled for instead of raced with a single request.
|
|
133
|
+
*/
|
|
134
|
+
async function waitForAcceptedConnection(port, timeoutMs = 5000) {
|
|
135
|
+
const deadline = Date.now() + timeoutMs;
|
|
136
|
+
let connection = openSseConnection(port);
|
|
137
|
+
for (;;) {
|
|
138
|
+
await waitFor(() => connection.statusCode !== undefined, 2000, 25);
|
|
139
|
+
if (connection.statusCode === 200)
|
|
140
|
+
return connection;
|
|
141
|
+
connection.close();
|
|
142
|
+
if (Date.now() >= deadline)
|
|
143
|
+
return connection;
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
145
|
+
connection = openSseConnection(port);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
for (const child of running) {
|
|
150
|
+
if (!child.killed)
|
|
151
|
+
child.kill("SIGTERM");
|
|
152
|
+
}
|
|
153
|
+
running.clear();
|
|
154
|
+
});
|
|
155
|
+
describe("SSE session limits", { concurrency: 1 }, () => {
|
|
156
|
+
test("rejects new SSE connections once MAX_SESSIONS is reached", async () => {
|
|
157
|
+
const port = await findAvailablePort(4500);
|
|
158
|
+
startSseServer({ MAX_SESSIONS: "2", MAX_REQUESTS_PER_MINUTE: UNTHROTTLED }, port);
|
|
159
|
+
await waitForHealth(port);
|
|
160
|
+
const first = openSseConnection(port);
|
|
161
|
+
const second = openSseConnection(port);
|
|
162
|
+
const bothConnected = await waitFor(() => first.statusCode === 200 && second.statusCode === 200, 5000);
|
|
163
|
+
assert.ok(bothConnected, "expected the first two SSE connections to be accepted");
|
|
164
|
+
const rejected = await fetch(`http://${LOOPBACK}:${port}/sse`, {
|
|
165
|
+
headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
|
|
166
|
+
});
|
|
167
|
+
assert.equal(rejected.status, 503);
|
|
168
|
+
const body = (await rejected.json());
|
|
169
|
+
assert.match(body.error ?? "", /capacity/i);
|
|
170
|
+
first.close();
|
|
171
|
+
second.close();
|
|
172
|
+
});
|
|
173
|
+
test("frees the capacity slot when a client closes the stream", async () => {
|
|
174
|
+
const port = await findAvailablePort(4510);
|
|
175
|
+
startSseServer({ MAX_SESSIONS: "2", MAX_REQUESTS_PER_MINUTE: UNTHROTTLED }, port);
|
|
176
|
+
await waitForHealth(port);
|
|
177
|
+
const first = openSseConnection(port);
|
|
178
|
+
const second = openSseConnection(port);
|
|
179
|
+
assert.ok(await waitFor(() => first.statusCode === 200 && second.statusCode === 200, 5000), "expected the first two SSE connections to be accepted");
|
|
180
|
+
assert.equal(await getSseStatus(port), 503, "expected the server to be at capacity");
|
|
181
|
+
first.close();
|
|
182
|
+
// SESSION_TIMEOUT_SECONDS is left at its 3600s default, so the slot can
|
|
183
|
+
// only come back through the response "close" handler - not the idle sweep.
|
|
184
|
+
const reclaimed = await waitForAcceptedConnection(port);
|
|
185
|
+
assert.equal(reclaimed.statusCode, 200, "closing a client connection should free its capacity slot");
|
|
186
|
+
reclaimed.close();
|
|
187
|
+
second.close();
|
|
188
|
+
});
|
|
189
|
+
test("rate-limits SSE connection creation per client IP", async () => {
|
|
190
|
+
const port = await findAvailablePort(4520);
|
|
191
|
+
startSseServer({ MAX_REQUESTS_PER_MINUTE: "2" }, port);
|
|
192
|
+
await waitForHealth(port);
|
|
193
|
+
const first = openSseConnection(port);
|
|
194
|
+
const second = openSseConnection(port);
|
|
195
|
+
assert.ok(await waitFor(() => first.statusCode === 200 && second.statusCode === 200, 5000), "expected the first two SSE connections to be accepted");
|
|
196
|
+
const rejected = await fetch(`http://${LOOPBACK}:${port}/sse`, {
|
|
197
|
+
headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
|
|
198
|
+
});
|
|
199
|
+
assert.equal(rejected.status, 429);
|
|
200
|
+
const body = (await rejected.json());
|
|
201
|
+
assert.equal(body.error, "Rate limit exceeded");
|
|
202
|
+
assert.match(body.message ?? "", /per minute/i);
|
|
203
|
+
first.close();
|
|
204
|
+
second.close();
|
|
205
|
+
});
|
|
206
|
+
test("closes SSE sessions that stay idle", async () => {
|
|
207
|
+
const port = await findAvailablePort(4530);
|
|
208
|
+
startSseServer({ SESSION_TIMEOUT_SECONDS: "2" }, port);
|
|
209
|
+
await waitForHealth(port);
|
|
210
|
+
const connection = openSseConnection(port);
|
|
211
|
+
assert.ok(await waitFor(() => connection.statusCode === 200, 5000), "expected the SSE connection to be accepted");
|
|
212
|
+
const closedByIdleTimeout = await Promise.race([
|
|
213
|
+
connection.ended.then(() => true),
|
|
214
|
+
new Promise(resolve => setTimeout(() => resolve(false), 12000)),
|
|
215
|
+
]);
|
|
216
|
+
assert.equal(closedByIdleTimeout, true, "idle SSE session should be closed by the server");
|
|
217
|
+
connection.close();
|
|
218
|
+
});
|
|
219
|
+
test("POST /messages keeps an SSE session alive past the idle timeout", async () => {
|
|
220
|
+
const port = await findAvailablePort(4540);
|
|
221
|
+
startSseServer({ SESSION_TIMEOUT_SECONDS: "2" }, port);
|
|
222
|
+
await waitForHealth(port);
|
|
223
|
+
const connection = openSseConnection(port);
|
|
224
|
+
assert.ok(await waitFor(() => connection.statusCode === 200, 5000), "expected the SSE connection to be accepted");
|
|
225
|
+
const sessionId = await readSessionId(connection);
|
|
226
|
+
// Ping across several idle windows; every POST /messages must reset activity.
|
|
227
|
+
const pingStatuses = [];
|
|
228
|
+
const keepAlive = setInterval(() => {
|
|
229
|
+
void postSseMessage(port, sessionId, { jsonrpc: "2.0", id: 1, method: "ping" })
|
|
230
|
+
.then(status => pingStatuses.push(status))
|
|
231
|
+
.catch(() => undefined);
|
|
232
|
+
}, 400);
|
|
233
|
+
try {
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, 6000));
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
clearInterval(keepAlive);
|
|
238
|
+
}
|
|
239
|
+
assert.ok(pingStatuses.length >= 8, `expected keep-alive pings to be sent, saw ${pingStatuses.length}`);
|
|
240
|
+
assert.ok(pingStatuses.every(status => status === 202), `expected every ping to be accepted, saw ${JSON.stringify(pingStatuses)}`);
|
|
241
|
+
assert.match(connection.streamText(), /"result"/, "expected a ping response on the stream");
|
|
242
|
+
assert.equal(await endedWithin(connection, 300), false, "POST /messages activity should keep the SSE session open");
|
|
243
|
+
assert.equal(await endedWithin(connection, 8000), true, "the session should close once POST /messages activity stops");
|
|
244
|
+
connection.close();
|
|
245
|
+
});
|
|
246
|
+
test("treats inherited object keys as unknown SSE sessions", async () => {
|
|
247
|
+
const port = await findAvailablePort(4560);
|
|
248
|
+
startSseServer({}, port);
|
|
249
|
+
await waitForHealth(port);
|
|
250
|
+
const response = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=constructor`, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: { Authorization: `Bearer ${SSE_TOKEN}`, "Content-Type": "application/json" },
|
|
253
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
|
|
254
|
+
signal: AbortSignal.timeout(3000),
|
|
255
|
+
});
|
|
256
|
+
assert.equal(response.status, 400);
|
|
257
|
+
assert.match(await response.text(), /No transport found/);
|
|
258
|
+
});
|
|
259
|
+
test("reports degraded health while SSE capacity is exhausted", async () => {
|
|
260
|
+
const port = await findAvailablePort(4550);
|
|
261
|
+
startSseServer({ MAX_SESSIONS: "1" }, port);
|
|
262
|
+
await waitForHealth(port);
|
|
263
|
+
const connection = openSseConnection(port);
|
|
264
|
+
assert.ok(await waitFor(() => connection.statusCode === 200, 5000), "expected the SSE connection to be accepted");
|
|
265
|
+
const degraded = await fetch(`http://${LOOPBACK}:${port}/health`);
|
|
266
|
+
const body = (await degraded.json());
|
|
267
|
+
assert.equal(degraded.status, 503);
|
|
268
|
+
assert.equal(body.status, "degraded");
|
|
269
|
+
assert.equal(body.activeSessions, 1);
|
|
270
|
+
assert.equal(body.maxSessions, 1);
|
|
271
|
+
connection.close();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, test, before, after, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
+
const MOCK_TOKEN = "glpat-mock-token-12345";
|
|
6
|
+
const TEST_PROJECT_ID = "123";
|
|
7
|
+
// Cases below cover tools outside the default toolsets, so every toolset is enabled.
|
|
8
|
+
const TOOLSET_ENV = { GITLAB_TOOLSETS: "all" };
|
|
9
|
+
// Helper to run an MCP tool through the stdio server
|
|
10
|
+
async function callTool(toolName, args, env) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
13
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
14
|
+
env: {
|
|
15
|
+
...process.env,
|
|
16
|
+
...TOOLSET_ENV,
|
|
17
|
+
...env,
|
|
18
|
+
GITLAB_READ_ONLY_MODE: "true",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
let output = "";
|
|
22
|
+
let errorOutput = "";
|
|
23
|
+
proc.stdout?.on("data", d => (output += d));
|
|
24
|
+
proc.stderr?.on("data", d => (errorOutput += d));
|
|
25
|
+
proc.on("close", code => {
|
|
26
|
+
if (code !== 0)
|
|
27
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
28
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
29
|
+
if (!line)
|
|
30
|
+
return reject(new Error("No JSON output found"));
|
|
31
|
+
try {
|
|
32
|
+
const response = JSON.parse(line);
|
|
33
|
+
if (response.error) {
|
|
34
|
+
reject(new Error(JSON.stringify(response.error)));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const content = response.result?.content?.[0]?.text;
|
|
38
|
+
if (content) {
|
|
39
|
+
try {
|
|
40
|
+
resolve(JSON.parse(content));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
resolve(response.result);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
reject(e);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
proc.stdin?.end(JSON.stringify({
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
id: 1,
|
|
57
|
+
method: "tools/call",
|
|
58
|
+
params: { name: toolName, arguments: args },
|
|
59
|
+
}) + "\n");
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
describe("blank query filters", () => {
|
|
63
|
+
let mockGitLab;
|
|
64
|
+
let mockGitLabUrl;
|
|
65
|
+
before(async () => {
|
|
66
|
+
const mockPort = await findMockServerPort();
|
|
67
|
+
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
|
|
68
|
+
await mockGitLab.start();
|
|
69
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
70
|
+
});
|
|
71
|
+
after(async () => {
|
|
72
|
+
await mockGitLab.stop();
|
|
73
|
+
});
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
mockGitLab.clearCustomHandlers();
|
|
76
|
+
});
|
|
77
|
+
const env = () => ({
|
|
78
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
79
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
80
|
+
});
|
|
81
|
+
// Registers a handler that captures the request URL and answers with an empty list
|
|
82
|
+
function captureRequest(path) {
|
|
83
|
+
let capturedUrl;
|
|
84
|
+
mockGitLab.addMockHandler("get", path, (req, res) => {
|
|
85
|
+
capturedUrl = req.originalUrl;
|
|
86
|
+
res.json([]);
|
|
87
|
+
});
|
|
88
|
+
return () => {
|
|
89
|
+
assert.ok(capturedUrl, "Mock handler should have received a request");
|
|
90
|
+
return capturedUrl;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function searchOf(capturedUrl) {
|
|
94
|
+
return new URL(capturedUrl, "http://localhost").search;
|
|
95
|
+
}
|
|
96
|
+
test("list_labels omits blank filters", async () => {
|
|
97
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/labels`);
|
|
98
|
+
const labels = await callTool("list_labels", { project_id: TEST_PROJECT_ID, search: "" }, env());
|
|
99
|
+
assert.deepStrictEqual(labels, []);
|
|
100
|
+
assert.strictEqual(searchOf(capturedUrl()), "");
|
|
101
|
+
});
|
|
102
|
+
test("list_tags omits blank filters", async () => {
|
|
103
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/repository/tags`);
|
|
104
|
+
const tags = await callTool("list_tags", { project_id: TEST_PROJECT_ID, search: " " }, env());
|
|
105
|
+
assert.deepStrictEqual(tags, []);
|
|
106
|
+
assert.strictEqual(searchOf(capturedUrl()), "");
|
|
107
|
+
});
|
|
108
|
+
test("list_pipelines omits blank filters", async () => {
|
|
109
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/pipelines`);
|
|
110
|
+
const pipelines = await callTool("list_pipelines", { project_id: TEST_PROJECT_ID, ref: "" }, env());
|
|
111
|
+
assert.deepStrictEqual(pipelines, []);
|
|
112
|
+
assert.strictEqual(searchOf(capturedUrl()), "");
|
|
113
|
+
});
|
|
114
|
+
test("list_deployments omits blank filters", async () => {
|
|
115
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/deployments`);
|
|
116
|
+
const deployments = await callTool("list_deployments", { project_id: TEST_PROJECT_ID, environment: "", ref: "\t" }, env());
|
|
117
|
+
assert.deepStrictEqual(deployments, []);
|
|
118
|
+
assert.strictEqual(searchOf(capturedUrl()), "");
|
|
119
|
+
});
|
|
120
|
+
test("list_environments omits blank filters", async () => {
|
|
121
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/environments`);
|
|
122
|
+
const environments = await callTool("list_environments", { project_id: TEST_PROJECT_ID, name: " ", search: "" }, env());
|
|
123
|
+
assert.deepStrictEqual(environments, []);
|
|
124
|
+
assert.strictEqual(searchOf(capturedUrl()), "");
|
|
125
|
+
});
|
|
126
|
+
test("list_job_artifacts omits blank filters", async () => {
|
|
127
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/jobs/7/artifacts/tree`);
|
|
128
|
+
const artifacts = await callTool("list_job_artifacts", { project_id: TEST_PROJECT_ID, job_id: "7", path: "" }, env());
|
|
129
|
+
assert.deepStrictEqual(artifacts, []);
|
|
130
|
+
assert.strictEqual(searchOf(capturedUrl()), "");
|
|
131
|
+
});
|
|
132
|
+
test("list_events omits blank filters", async () => {
|
|
133
|
+
const capturedUrl = captureRequest("/events");
|
|
134
|
+
const events = await callTool("list_events", { action: "", scope: " \t" }, env());
|
|
135
|
+
assert.deepStrictEqual(events, []);
|
|
136
|
+
assert.strictEqual(searchOf(capturedUrl()), "");
|
|
137
|
+
});
|
|
138
|
+
test("list_project_variables omits blank nested filters", async () => {
|
|
139
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/variables`);
|
|
140
|
+
const variables = await callTool("list_project_variables", { project_id: TEST_PROJECT_ID, filter: { environment_scope: " " } }, env());
|
|
141
|
+
assert.deepStrictEqual(variables, []);
|
|
142
|
+
assert.strictEqual(searchOf(capturedUrl()), "");
|
|
143
|
+
});
|
|
144
|
+
test("list_labels keeps a non-blank filter", async () => {
|
|
145
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/labels`);
|
|
146
|
+
await callTool("list_labels", { project_id: TEST_PROJECT_ID, search: "bug" }, env());
|
|
147
|
+
const params = new URL(capturedUrl(), "http://localhost").searchParams;
|
|
148
|
+
assert.deepStrictEqual(params.getAll("search"), ["bug"]);
|
|
149
|
+
});
|
|
150
|
+
test("list_project_variables keeps a non-blank nested filter", async () => {
|
|
151
|
+
const capturedUrl = captureRequest(`/projects/${TEST_PROJECT_ID}/variables`);
|
|
152
|
+
await callTool("list_project_variables", { project_id: TEST_PROJECT_ID, filter: { environment_scope: "production" } }, env());
|
|
153
|
+
const params = new URL(capturedUrl(), "http://localhost").searchParams;
|
|
154
|
+
assert.deepStrictEqual(params.getAll("filter[environment_scope]"), ["production"]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -72,6 +72,9 @@ describe('download_attachment', () => {
|
|
|
72
72
|
mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => { res.set('Content-Type', 'image/png').send(MINIMAL_PNG_BUF); });
|
|
73
73
|
// Plain-text upload endpoint
|
|
74
74
|
mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => { res.set('Content-Type', 'text/plain').send('hello world'); });
|
|
75
|
+
// GitLab redirects uploads to its object storage, so the hop after this response
|
|
76
|
+
// leaves the origin. The destination is a non-public address here.
|
|
77
|
+
mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/redirected.txt`, (_req, res) => { res.redirect('http://169.254.169.254/latest/meta-data/'); });
|
|
75
78
|
});
|
|
76
79
|
after(async () => {
|
|
77
80
|
await mockGitLab.stop();
|
|
@@ -126,6 +129,14 @@ describe('download_attachment', () => {
|
|
|
126
129
|
raw.result.content.some(c => typeof c.text === 'string' && c.text.toLowerCase().includes('traversal'));
|
|
127
130
|
assert.ok(isRpcError || isContentError, 'Should return an error mentioning directory traversal');
|
|
128
131
|
});
|
|
132
|
+
test('a redirect to a non-public address is refused', async () => {
|
|
133
|
+
const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'redirected.txt' }, env);
|
|
134
|
+
const mentionsRefusal = (text) => text.toLowerCase().includes('non-public address');
|
|
135
|
+
const isRpcError = typeof raw.error?.message === 'string' && mentionsRefusal(raw.error.message);
|
|
136
|
+
const isContentError = Array.isArray(raw.result?.content) &&
|
|
137
|
+
raw.result.content.some(c => typeof c.text === 'string' && mentionsRefusal(c.text));
|
|
138
|
+
assert.ok(isRpcError || isContentError, `Should refuse the redirect instead of following it, got ${JSON.stringify(raw)}`);
|
|
139
|
+
});
|
|
129
140
|
test('local_path through a symlink directory is rejected', async () => {
|
|
130
141
|
const linkDir = `omc-test-symlink-${RUN_ID}`;
|
|
131
142
|
fs.rmSync(linkDir, { recursive: true, force: true });
|