@zereight/mcp-gitlab 2.1.68 → 2.1.70
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 +11 -3
- package/README.md +237 -232
- package/README.pt-BR.md +8 -4
- package/README.zh-CN.md +7 -3
- package/build/cli/args.js +298 -0
- package/build/cli/compact-mcp-result.js +87 -0
- package/build/cli/curated.js +243 -0
- package/build/cli/exposure.js +72 -0
- package/build/cli/help.js +108 -0
- package/build/cli/output.js +65 -0
- package/build/cli/replay-command.js +53 -0
- package/build/cli/router.js +158 -0
- package/build/cli/tables.js +117 -0
- package/build/cli-boolean-flags.js +38 -0
- package/build/cli-command.js +14 -4
- package/build/config.js +30 -7
- package/build/index.js +176 -90
- package/build/oauth.js +93 -10
- package/build/scripts/generate-tool-docs.js +2 -1
- package/build/test/cli/args.test.js +175 -0
- package/build/test/cli/boolean-flags.test.js +106 -0
- package/build/test/cli/compact-mcp-result.test.js +93 -0
- package/build/test/cli/credentials.test.js +153 -0
- package/build/test/cli/exposure.test.js +104 -0
- package/build/test/cli/load-config-snapshot.js +5 -0
- package/build/test/cli/output.test.js +74 -0
- package/build/test/cli/replay-command.test.js +37 -0
- package/build/test/cli/router.test.js +183 -0
- package/build/test/gitlab-api-tool-errors.test.js +112 -0
- package/build/test/oauth-device-flow-tests.js +10 -0
- package/build/test/oauth-download-url.test.js +157 -0
- package/build/test/oauth-startup.test.js +204 -0
- package/build/test/test-dynamic-project-scope.js +2 -0
- package/build/test/test-geteffectiveprojectid.js +20 -26
- package/build/test/test-permission-mode.js +86 -0
- package/build/test/test-pipeline-schedules.js +3 -1
- package/build/test/utils/url.test.js +14 -1
- package/build/tools/denied-regex.js +38 -0
- package/build/utils/url.js +15 -0
- package/package.json +1 -1
|
@@ -193,6 +193,16 @@ describe("When reading the CLI command", () => {
|
|
|
193
193
|
assert.equal(getPositionalCliCommand(["node", "/usr/local/bin/tsx", "index.ts", "auth", "--help"]), "auth");
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
|
+
describe("with --use-oauth before a curated command", () => {
|
|
197
|
+
it("should still return mr instead of consuming it as the flag value", () => {
|
|
198
|
+
assert.equal(getPositionalCliCommand(["node", "index.js", "--use-oauth", "mr", "list"]), "mr");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
describe("with --read-only false before a curated command", () => {
|
|
202
|
+
it("should skip the boolean literal and return mr", () => {
|
|
203
|
+
assert.equal(getPositionalCliCommand(["node", "index.js", "--read-only", "false", "mr", "list"]), "mr");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
196
206
|
describe("with space-separated --api-url before auth", () => {
|
|
197
207
|
it("should skip the URL value and return auth", () => {
|
|
198
208
|
assert.equal(getPositionalCliCommand([
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as net from "node:net";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as url from "node:url";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
// The token key is derived at module load, so the secret must be set before importing it.
|
|
10
|
+
const DOWNLOAD_TOKEN_SECRET = "oauth-download-test-secret";
|
|
11
|
+
process.env.DOWNLOAD_TOKEN_SECRET = DOWNLOAD_TOKEN_SECRET;
|
|
12
|
+
const { decryptDownloadToken } = await import("../utils/download-token.js");
|
|
13
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
14
|
+
const SERVER_ENTRY = path.resolve(__dirname, "../build/index.js");
|
|
15
|
+
const HOST = "127.0.0.1";
|
|
16
|
+
const OAUTH_TOKEN = "oauth-download-test";
|
|
17
|
+
const MCP_ACCESS_TOKEN = "mcp-access-test";
|
|
18
|
+
const STARTUP_TIMEOUT_MS = 15_000;
|
|
19
|
+
const MCP_HEADERS = {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
Accept: "application/json, text/event-stream",
|
|
22
|
+
Authorization: `Bearer ${MCP_ACCESS_TOKEN}`,
|
|
23
|
+
};
|
|
24
|
+
function getFreePortAsync() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const probe = net.createServer();
|
|
27
|
+
probe.on("error", reject);
|
|
28
|
+
probe.listen(0, HOST, () => {
|
|
29
|
+
const address = probe.address();
|
|
30
|
+
if (address === null || typeof address === "string") {
|
|
31
|
+
probe.close();
|
|
32
|
+
reject(new Error("probe server is not listening on a TCP port"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
probe.close(() => resolve(address.port));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function buildServerEnv(port, tmpDir) {
|
|
40
|
+
const tokenPath = path.join(tmpDir, "token.json");
|
|
41
|
+
fs.writeFileSync(tokenPath, JSON.stringify({
|
|
42
|
+
access_token: OAUTH_TOKEN,
|
|
43
|
+
refresh_token: "refresh",
|
|
44
|
+
expires_in: 7200,
|
|
45
|
+
created_at: Date.now(),
|
|
46
|
+
token_type: "Bearer",
|
|
47
|
+
}));
|
|
48
|
+
const env = {};
|
|
49
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
50
|
+
if (value !== undefined && !key.startsWith("GITLAB_")) {
|
|
51
|
+
env[key] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
...env,
|
|
56
|
+
STREAMABLE_HTTP: "true",
|
|
57
|
+
HOST,
|
|
58
|
+
PORT: String(port),
|
|
59
|
+
MCP_SERVER_URL: "",
|
|
60
|
+
GITLAB_API_URL: `http://${HOST}:1/api/v4`,
|
|
61
|
+
GITLAB_USE_OAUTH: "true",
|
|
62
|
+
GITLAB_OAUTH_CLIENT_ID: "client-id",
|
|
63
|
+
GITLAB_OAUTH_TOKEN_PATH: tokenPath,
|
|
64
|
+
GITLAB_IS_OLD: "true",
|
|
65
|
+
USE_PIPELINE: "true",
|
|
66
|
+
DOWNLOAD_TOKEN_SECRET,
|
|
67
|
+
STREAMABLE_HTTP_AUTH_TOKEN: MCP_ACCESS_TOKEN,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function waitForServerAsync(port, child) {
|
|
71
|
+
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
72
|
+
while (Date.now() < deadline) {
|
|
73
|
+
if (child.exitCode !== null) {
|
|
74
|
+
throw new Error(`server exited early with code ${child.exitCode}`);
|
|
75
|
+
}
|
|
76
|
+
const reachable = await new Promise(resolve => {
|
|
77
|
+
const socket = net.connect(port, HOST);
|
|
78
|
+
socket.once("connect", () => {
|
|
79
|
+
socket.destroy();
|
|
80
|
+
resolve(true);
|
|
81
|
+
});
|
|
82
|
+
socket.once("error", () => resolve(false));
|
|
83
|
+
});
|
|
84
|
+
if (reachable) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
88
|
+
}
|
|
89
|
+
throw new Error("server did not start listening in time");
|
|
90
|
+
}
|
|
91
|
+
async function postMcpAsync(port, body, sessionId) {
|
|
92
|
+
return fetch(`http://${HOST}:${port}/mcp`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: sessionId ? { ...MCP_HEADERS, "mcp-session-id": sessionId } : MCP_HEADERS,
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function readJsonRpcResult(sseBody) {
|
|
99
|
+
const dataLine = sseBody.split("\n").find(line => line.startsWith("data: "));
|
|
100
|
+
if (dataLine === undefined) {
|
|
101
|
+
throw new Error(`no SSE data line in response: ${sseBody}`);
|
|
102
|
+
}
|
|
103
|
+
return JSON.parse(dataLine.slice("data: ".length));
|
|
104
|
+
}
|
|
105
|
+
function readDownloadUrl(rpc) {
|
|
106
|
+
const text = JSON.parse(JSON.stringify(rpc)).result?.content?.[0]?.text;
|
|
107
|
+
if (typeof text !== "string") {
|
|
108
|
+
throw new Error(`unexpected tool response: ${JSON.stringify(rpc)}`);
|
|
109
|
+
}
|
|
110
|
+
const downloadUrl = JSON.parse(text).download_url;
|
|
111
|
+
if (typeof downloadUrl !== "string") {
|
|
112
|
+
throw new Error(`no download_url in tool response: ${text}`);
|
|
113
|
+
}
|
|
114
|
+
return downloadUrl;
|
|
115
|
+
}
|
|
116
|
+
async function requestDownloadUrlAsync() {
|
|
117
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-oauth-download-"));
|
|
118
|
+
const port = await getFreePortAsync();
|
|
119
|
+
const env = buildServerEnv(port, tmpDir);
|
|
120
|
+
const child = spawn(process.execPath, [SERVER_ENTRY], { env, stdio: "ignore" });
|
|
121
|
+
try {
|
|
122
|
+
await waitForServerAsync(port, child);
|
|
123
|
+
const init = await postMcpAsync(port, {
|
|
124
|
+
jsonrpc: "2.0",
|
|
125
|
+
id: 1,
|
|
126
|
+
method: "initialize",
|
|
127
|
+
params: {
|
|
128
|
+
protocolVersion: "2025-03-26",
|
|
129
|
+
capabilities: {},
|
|
130
|
+
clientInfo: { name: "oauth-download-url-test", version: "1.0" },
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const sessionId = init.headers.get("mcp-session-id") ?? undefined;
|
|
134
|
+
const call = await postMcpAsync(port, {
|
|
135
|
+
jsonrpc: "2.0",
|
|
136
|
+
id: 2,
|
|
137
|
+
method: "tools/call",
|
|
138
|
+
params: { name: "download_job_artifacts", arguments: { project_id: "g/p", job_id: "1" } },
|
|
139
|
+
}, sessionId);
|
|
140
|
+
return readDownloadUrl(readJsonRpcResult(await call.text()));
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
child.kill("SIGKILL");
|
|
144
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
describe("When a remote server builds a download URL with a stored OAuth token", () => {
|
|
148
|
+
describe("with GITLAB_IS_OLD enabled", () => {
|
|
149
|
+
it("should embed the token as a bearer credential", async () => {
|
|
150
|
+
const downloadUrl = await requestDownloadUrlAsync();
|
|
151
|
+
const embedded = new URL(downloadUrl).searchParams.get("_token");
|
|
152
|
+
const decrypted = embedded === null ? null : decryptDownloadToken(embedded);
|
|
153
|
+
assert.equal(decrypted?.header, "Authorization");
|
|
154
|
+
assert.equal(decrypted?.token, `Bearer ${OAUTH_TOKEN}`);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth startup tests.
|
|
3
|
+
*
|
|
4
|
+
* - isAuthInvalidTokenResponse: pure classifier — refresh failures that mean
|
|
5
|
+
* "re-login would fix it" vs transient/config errors that must not open a browser.
|
|
6
|
+
* - ensureOAuthToken: lazy first-use delivery — the caller must receive the
|
|
7
|
+
* token even when the on-disk token is still valid (regression: returning
|
|
8
|
+
* early on hasValidToken() alone leaves requests unauthenticated).
|
|
9
|
+
*
|
|
10
|
+
* Temp token files + a loopback mock for /oauth/token only; no MCP server.
|
|
11
|
+
* The missing-token and invalid_grant paths start the interactive browser
|
|
12
|
+
* flow and are deliberately not covered here.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, test, afterEach } from "node:test";
|
|
15
|
+
import assert from "node:assert";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as http from "node:http";
|
|
20
|
+
import { GitLabOAuth, ensureOAuthToken, isAuthInvalidTokenResponse } from "../oauth.js";
|
|
21
|
+
const validTokenFile = {
|
|
22
|
+
access_token: "cached-access-token",
|
|
23
|
+
refresh_token: "cached-refresh-token",
|
|
24
|
+
expires_in: 7200,
|
|
25
|
+
created_at: Date.now(),
|
|
26
|
+
token_type: "Bearer",
|
|
27
|
+
};
|
|
28
|
+
const expiredTokenFile = {
|
|
29
|
+
...validTokenFile,
|
|
30
|
+
created_at: Date.now() - 10 * 3600 * 1000,
|
|
31
|
+
};
|
|
32
|
+
const tmpDirs = [];
|
|
33
|
+
const servers = [];
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
for (const server of servers.splice(0)) {
|
|
36
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
37
|
+
}
|
|
38
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
function makeTokenFile(data) {
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oauth-startup-"));
|
|
44
|
+
tmpDirs.push(dir);
|
|
45
|
+
const file = path.join(dir, "token.json");
|
|
46
|
+
fs.writeFileSync(file, JSON.stringify(data));
|
|
47
|
+
return file;
|
|
48
|
+
}
|
|
49
|
+
function makeClient(tokenStoragePath, gitlabUrl, extra) {
|
|
50
|
+
return new GitLabOAuth({
|
|
51
|
+
clientId: "test-client",
|
|
52
|
+
redirectUri: "http://127.0.0.1:8888/callback",
|
|
53
|
+
gitlabUrl,
|
|
54
|
+
scopes: ["api"],
|
|
55
|
+
tokenStoragePath,
|
|
56
|
+
...extra,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function startTokenServer(handler) {
|
|
60
|
+
const bodies = [];
|
|
61
|
+
const server = http.createServer((req, res) => {
|
|
62
|
+
let body = "";
|
|
63
|
+
req.on("data", (chunk) => {
|
|
64
|
+
body += chunk.toString();
|
|
65
|
+
});
|
|
66
|
+
req.on("end", () => {
|
|
67
|
+
bodies.push(body);
|
|
68
|
+
const { status, json } = handler();
|
|
69
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
70
|
+
res.end(JSON.stringify(json));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
servers.push(server);
|
|
74
|
+
await new Promise(resolve => server.listen(0, "127.0.0.1", () => resolve()));
|
|
75
|
+
const addr = server.address();
|
|
76
|
+
assert(addr && typeof addr !== "string");
|
|
77
|
+
return { url: `http://127.0.0.1:${addr.port}`, bodies };
|
|
78
|
+
}
|
|
79
|
+
describe("isAuthInvalidTokenResponse", () => {
|
|
80
|
+
test("400 invalid_grant is auth-invalid", () => {
|
|
81
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_grant"}'), true);
|
|
82
|
+
});
|
|
83
|
+
test("400 invalid_token is auth-invalid", () => {
|
|
84
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_token"}'), true);
|
|
85
|
+
});
|
|
86
|
+
test("401 invalid_grant is auth-invalid", () => {
|
|
87
|
+
assert.strictEqual(isAuthInvalidTokenResponse(401, '{"error":"invalid_grant"}'), true);
|
|
88
|
+
});
|
|
89
|
+
test("400 invalid_client is not auth-invalid (config error)", () => {
|
|
90
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_client"}'), false);
|
|
91
|
+
});
|
|
92
|
+
test("400 invalid_request is not auth-invalid (config error)", () => {
|
|
93
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_request"}'), false);
|
|
94
|
+
});
|
|
95
|
+
test("500 server_error is not auth-invalid (transient)", () => {
|
|
96
|
+
assert.strictEqual(isAuthInvalidTokenResponse(500, '{"error":"server_error"}'), false);
|
|
97
|
+
});
|
|
98
|
+
test("429 is not auth-invalid (rate limited)", () => {
|
|
99
|
+
assert.strictEqual(isAuthInvalidTokenResponse(429, '{"error":"invalid_grant"}'), false);
|
|
100
|
+
});
|
|
101
|
+
test("non-JSON body is not auth-invalid", () => {
|
|
102
|
+
assert.strictEqual(isAuthInvalidTokenResponse(400, "gateway timeout"), false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe("ensureOAuthToken", () => {
|
|
106
|
+
test("null client is a no-op", async () => {
|
|
107
|
+
const delivered = [];
|
|
108
|
+
await ensureOAuthToken(null, null, t => {
|
|
109
|
+
delivered.push(t);
|
|
110
|
+
});
|
|
111
|
+
await ensureOAuthToken(undefined, undefined, t => {
|
|
112
|
+
delivered.push(t);
|
|
113
|
+
});
|
|
114
|
+
assert.deepStrictEqual(delivered, []);
|
|
115
|
+
});
|
|
116
|
+
test("first call with valid cached token delivers it without network", async () => {
|
|
117
|
+
// Closed port: any fetch attempt fails, proving the cached path is network-free.
|
|
118
|
+
const client = makeClient(makeTokenFile(validTokenFile), "http://127.0.0.1:9");
|
|
119
|
+
const delivered = [];
|
|
120
|
+
await ensureOAuthToken(client, null, t => {
|
|
121
|
+
delivered.push(t);
|
|
122
|
+
});
|
|
123
|
+
assert.deepStrictEqual(delivered, ["cached-access-token"]);
|
|
124
|
+
});
|
|
125
|
+
test("already-held valid token is not re-delivered", async () => {
|
|
126
|
+
const client = makeClient(makeTokenFile(validTokenFile), "http://127.0.0.1:9");
|
|
127
|
+
const delivered = [];
|
|
128
|
+
await ensureOAuthToken(client, "cached-access-token", t => {
|
|
129
|
+
delivered.push(t);
|
|
130
|
+
});
|
|
131
|
+
assert.deepStrictEqual(delivered, []);
|
|
132
|
+
});
|
|
133
|
+
test("expired token refreshes via /oauth/token and persists", async () => {
|
|
134
|
+
const { url, bodies } = await startTokenServer(() => ({
|
|
135
|
+
status: 200,
|
|
136
|
+
json: {
|
|
137
|
+
access_token: "fresh-token",
|
|
138
|
+
refresh_token: "fresh-refresh",
|
|
139
|
+
expires_in: 7200,
|
|
140
|
+
token_type: "Bearer",
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
const file = makeTokenFile(expiredTokenFile);
|
|
144
|
+
const client = makeClient(file, url);
|
|
145
|
+
const delivered = [];
|
|
146
|
+
await ensureOAuthToken(client, null, t => {
|
|
147
|
+
delivered.push(t);
|
|
148
|
+
});
|
|
149
|
+
assert.deepStrictEqual(delivered, ["fresh-token"]);
|
|
150
|
+
assert.strictEqual(bodies.length, 1);
|
|
151
|
+
assert.match(bodies[0], /grant_type=refresh_token/);
|
|
152
|
+
assert.match(bodies[0], /refresh_token=cached-refresh-token/);
|
|
153
|
+
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
154
|
+
assert.strictEqual(saved.access_token, "fresh-token");
|
|
155
|
+
});
|
|
156
|
+
test("transient refresh failure propagates without opening a browser", async () => {
|
|
157
|
+
const { url } = await startTokenServer(() => ({ status: 500, json: { error: "server_error" } }));
|
|
158
|
+
const client = makeClient(makeTokenFile(expiredTokenFile), url);
|
|
159
|
+
const delivered = [];
|
|
160
|
+
// Rejection (not a browser popup) proves the no-browser path.
|
|
161
|
+
await assert.rejects(() => ensureOAuthToken(client, null, t => {
|
|
162
|
+
delivered.push(t);
|
|
163
|
+
}), /Token refresh failed: 500/);
|
|
164
|
+
assert.deepStrictEqual(delivered, []);
|
|
165
|
+
});
|
|
166
|
+
test("token script resolves once, then fast path holds", async () => {
|
|
167
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oauth-startup-"));
|
|
168
|
+
tmpDirs.push(dir);
|
|
169
|
+
const client = makeClient(path.join(dir, "token.json"), "http://127.0.0.1:9", {
|
|
170
|
+
tokenScript: "echo script-token-1",
|
|
171
|
+
});
|
|
172
|
+
const delivered = [];
|
|
173
|
+
await ensureOAuthToken(client, null, t => {
|
|
174
|
+
delivered.push(t);
|
|
175
|
+
});
|
|
176
|
+
assert.deepStrictEqual(delivered, ["script-token-1"]);
|
|
177
|
+
await ensureOAuthToken(client, delivered[0], t => {
|
|
178
|
+
delivered.push(t);
|
|
179
|
+
});
|
|
180
|
+
assert.deepStrictEqual(delivered, ["script-token-1"]);
|
|
181
|
+
});
|
|
182
|
+
test("concurrent calls coalesce into a single refresh request", async () => {
|
|
183
|
+
const { url, bodies } = await startTokenServer(() => ({
|
|
184
|
+
status: 200,
|
|
185
|
+
json: {
|
|
186
|
+
access_token: "coalesced-token",
|
|
187
|
+
refresh_token: "new-refresh",
|
|
188
|
+
expires_in: 7200,
|
|
189
|
+
token_type: "Bearer",
|
|
190
|
+
},
|
|
191
|
+
}));
|
|
192
|
+
const file = makeTokenFile(expiredTokenFile);
|
|
193
|
+
const client = makeClient(file, url);
|
|
194
|
+
const deliveredA = [];
|
|
195
|
+
const deliveredB = [];
|
|
196
|
+
await Promise.all([
|
|
197
|
+
ensureOAuthToken(client, null, t => deliveredA.push(t)),
|
|
198
|
+
ensureOAuthToken(client, null, t => deliveredB.push(t)),
|
|
199
|
+
]);
|
|
200
|
+
assert.deepStrictEqual(deliveredA, ["coalesced-token"]);
|
|
201
|
+
assert.deepStrictEqual(deliveredB, ["coalesced-token"]);
|
|
202
|
+
assert.strictEqual(bodies.length, 1);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -79,6 +79,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
|
|
|
79
79
|
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
80
80
|
ENABLE_STRICT_PROJECT_SCOPE: "true",
|
|
81
81
|
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
82
|
+
GITLAB_TOOLS: "execute_graphql",
|
|
82
83
|
GITLAB_ALLOWED_PROJECT_IDS: "1,2",
|
|
83
84
|
},
|
|
84
85
|
});
|
|
@@ -286,6 +287,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
|
|
|
286
287
|
REMOTE_AUTHORIZATION: "true",
|
|
287
288
|
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
288
289
|
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
290
|
+
GITLAB_TOOLS: "execute_graphql,get_vulnerability",
|
|
289
291
|
},
|
|
290
292
|
});
|
|
291
293
|
servers.push(server);
|
|
@@ -168,6 +168,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
168
168
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
169
169
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
170
170
|
GITLAB_READ_ONLY_MODE: 'false',
|
|
171
|
+
GITLAB_TOOLSETS: 'all',
|
|
171
172
|
}
|
|
172
173
|
});
|
|
173
174
|
servers.push(server);
|
|
@@ -259,35 +260,27 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
259
260
|
});
|
|
260
261
|
test('should not fall back from bare numeric project IDs to groups', async () => {
|
|
261
262
|
group789Requested = false;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
assert.ok(error instanceof Error);
|
|
271
|
-
assert.ok(error.message.includes('404'), 'Should surface the project 404');
|
|
272
|
-
}
|
|
273
|
-
assert.ok(didThrow, 'Should have failed on the project 404');
|
|
263
|
+
const result = await client.callTool('list_work_items', {
|
|
264
|
+
project_id: '789',
|
|
265
|
+
});
|
|
266
|
+
assert.strictEqual(result.isError, true, 'Should return the project 404 as a tool error');
|
|
267
|
+
assert.ok(result.content, 'Should have error content');
|
|
268
|
+
const content = result.content[0];
|
|
269
|
+
assert.ok('text' in content, 'Error content should have text');
|
|
270
|
+
assert.ok(content.text.includes('404'), 'Should surface the project 404');
|
|
274
271
|
assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
|
|
275
272
|
console.log(' ✓ Bare numeric ID 789 did not fall back to /groups/789');
|
|
276
273
|
});
|
|
277
274
|
test('should not fall back from explicit numeric project IDs to groups', async () => {
|
|
278
275
|
group789Requested = false;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
assert.ok(error instanceof Error);
|
|
288
|
-
assert.ok(error.message.includes('404'), 'Should surface the project 404');
|
|
289
|
-
}
|
|
290
|
-
assert.ok(didThrow, 'Should have failed on the project 404');
|
|
276
|
+
const result = await client.callTool('list_work_items', {
|
|
277
|
+
project_id: 'project:789',
|
|
278
|
+
});
|
|
279
|
+
assert.strictEqual(result.isError, true, 'Should return the project 404 as a tool error');
|
|
280
|
+
assert.ok(result.content, 'Should have error content');
|
|
281
|
+
const content = result.content[0];
|
|
282
|
+
assert.ok('text' in content, 'Error content should have text');
|
|
283
|
+
assert.ok(content.text.includes('404'), 'Should surface the project 404');
|
|
291
284
|
assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
|
|
292
285
|
console.log(' ✓ Explicit project:789 did not fall back to /groups/789');
|
|
293
286
|
});
|
|
@@ -318,6 +311,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
318
311
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
319
312
|
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
320
313
|
GITLAB_READ_ONLY_MODE: 'true',
|
|
314
|
+
GITLAB_TOOLSETS: 'all',
|
|
321
315
|
}
|
|
322
316
|
});
|
|
323
317
|
servers.push(server);
|
|
@@ -484,7 +478,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
484
478
|
REMOTE_AUTHORIZATION: 'true',
|
|
485
479
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
486
480
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
487
|
-
GITLAB_TOOLSETS: 'variables',
|
|
481
|
+
GITLAB_TOOLSETS: 'variables,repositories,groups,projects',
|
|
488
482
|
}
|
|
489
483
|
});
|
|
490
484
|
servers.push(server);
|
|
@@ -582,7 +576,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
582
576
|
REMOTE_AUTHORIZATION: 'true',
|
|
583
577
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
584
578
|
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
585
|
-
GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines',
|
|
579
|
+
GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines,repositories,groups,projects',
|
|
586
580
|
}
|
|
587
581
|
});
|
|
588
582
|
servers.push(server);
|
|
@@ -31,6 +31,8 @@ async function launchMcpServer(extraEnv = {}) {
|
|
|
31
31
|
REMOTE_AUTHORIZATION: "true",
|
|
32
32
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
33
33
|
GITLAB_TOOLSETS: "all",
|
|
34
|
+
// execute_graphql is in no toolset; opt in so the guards under test are reachable.
|
|
35
|
+
GITLAB_TOOLS: "execute_graphql",
|
|
34
36
|
...extraEnv,
|
|
35
37
|
},
|
|
36
38
|
});
|
|
@@ -280,6 +282,90 @@ describe("Permission Mode", { concurrency: 1 }, () => {
|
|
|
280
282
|
}
|
|
281
283
|
});
|
|
282
284
|
});
|
|
285
|
+
describe("call-time enforcement of list filters (modify mode)", () => {
|
|
286
|
+
const servers = [];
|
|
287
|
+
after(() => cleanupServers(servers));
|
|
288
|
+
/** Calls a tool on a fresh client and returns the serialized result, or the error message. */
|
|
289
|
+
async function callOutcome(server, name, args) {
|
|
290
|
+
const client = await connectClient(server);
|
|
291
|
+
try {
|
|
292
|
+
return JSON.stringify(await client.callTool(name, args));
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
return error instanceof Error ? error.message : String(error);
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
await client.disconnect();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
test("rejects a regex-denied tool and an execute_graphql with no GITLAB_TOOLS entry", async () => {
|
|
302
|
+
const server = await launchMcpServer({
|
|
303
|
+
GITLAB_PERMISSION_MODE: "modify",
|
|
304
|
+
GITLAB_TOOLS: "",
|
|
305
|
+
GITLAB_DENIED_TOOLS_REGEX: "^merge_merge_request$",
|
|
306
|
+
});
|
|
307
|
+
servers.push(server);
|
|
308
|
+
const names = await getToolNames(server);
|
|
309
|
+
assert.ok(!names.includes("merge_merge_request"), "regex-denied tool must be unlisted");
|
|
310
|
+
assert.ok(!names.includes("execute_graphql"), "execute_graphql must be unlisted");
|
|
311
|
+
const merge = await callOutcome(server, "merge_merge_request", {
|
|
312
|
+
project_id: "1",
|
|
313
|
+
merge_request_iid: 1,
|
|
314
|
+
});
|
|
315
|
+
assert.ok(merge.includes("not available"), `merge_merge_request: ${merge}`);
|
|
316
|
+
const graphql = await callOutcome(server, "execute_graphql", {
|
|
317
|
+
query: "query { currentUser { id } }",
|
|
318
|
+
});
|
|
319
|
+
assert.ok(graphql.includes("not available"), `execute_graphql: ${graphql}`);
|
|
320
|
+
});
|
|
321
|
+
test("policy-hidden tool is unlisted but still callable", async () => {
|
|
322
|
+
const server = await launchMcpServer({
|
|
323
|
+
GITLAB_PERMISSION_MODE: "modify",
|
|
324
|
+
GITLAB_TOOL_POLICY_HIDDEN: "list_issues",
|
|
325
|
+
});
|
|
326
|
+
servers.push(server);
|
|
327
|
+
const names = await getToolNames(server);
|
|
328
|
+
assert.ok(!names.includes("list_issues"), "hidden tool must be unlisted");
|
|
329
|
+
const outcome = await callOutcome(server, "list_issues", { project_id: "1" });
|
|
330
|
+
assert.ok(!outcome.includes("not available"), `hidden tool must stay callable: ${outcome}`);
|
|
331
|
+
});
|
|
332
|
+
test("policy-hidden tool that is also regex-denied is rejected", async () => {
|
|
333
|
+
const server = await launchMcpServer({
|
|
334
|
+
GITLAB_PERMISSION_MODE: "modify",
|
|
335
|
+
GITLAB_TOOL_POLICY_HIDDEN: "list_issues",
|
|
336
|
+
GITLAB_DENIED_TOOLS_REGEX: "^list_issues$",
|
|
337
|
+
});
|
|
338
|
+
servers.push(server);
|
|
339
|
+
const names = await getToolNames(server);
|
|
340
|
+
assert.ok(!names.includes("list_issues"), "hidden tool must be unlisted");
|
|
341
|
+
const outcome = await callOutcome(server, "list_issues", { project_id: "1" });
|
|
342
|
+
assert.ok(outcome.includes("not available"), `regex denial must win over hidden exemption: ${outcome}`);
|
|
343
|
+
});
|
|
344
|
+
test("tool outside the enabled toolsets is rejected until discover_tools activates it", async () => {
|
|
345
|
+
const server = await launchMcpServer({
|
|
346
|
+
GITLAB_PERMISSION_MODE: "modify",
|
|
347
|
+
GITLAB_TOOLSETS: "issues",
|
|
348
|
+
});
|
|
349
|
+
servers.push(server);
|
|
350
|
+
const client = await connectClient(server);
|
|
351
|
+
try {
|
|
352
|
+
const before = await client
|
|
353
|
+
.callTool("list_pipelines", { project_id: "1" })
|
|
354
|
+
.then(r => JSON.stringify(r))
|
|
355
|
+
.catch((e) => e.message);
|
|
356
|
+
assert.ok(before.includes("not available"), `before activation: ${before}`);
|
|
357
|
+
await client.callTool("discover_tools", { category: "pipelines" });
|
|
358
|
+
const after = await client
|
|
359
|
+
.callTool("list_pipelines", { project_id: "1" })
|
|
360
|
+
.then(r => JSON.stringify(r))
|
|
361
|
+
.catch((e) => e.message);
|
|
362
|
+
assert.ok(!after.includes("not available"), `after activation: ${after}`);
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
await client.disconnect();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
});
|
|
283
369
|
describe("readonly mode via GITLAB_PERMISSION_MODE", () => {
|
|
284
370
|
let server;
|
|
285
371
|
const servers = [];
|
|
@@ -255,7 +255,9 @@ describe("Pipeline schedule tools", () => {
|
|
|
255
255
|
assert.strictEqual(result.inputs[0].name, "deploy_strategy");
|
|
256
256
|
});
|
|
257
257
|
test("get_pipeline_schedule surfaces a 404 as an error", async () => {
|
|
258
|
-
|
|
258
|
+
const result = await callTool("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: MISSING_SCHEDULE_ID }, baseEnv);
|
|
259
|
+
assert.strictEqual(result.isError, true);
|
|
260
|
+
assert.match(result.content?.[0]?.text ?? "", /404/);
|
|
259
261
|
});
|
|
260
262
|
test("list_pipeline_schedule_pipelines returns the triggered pipelines", async () => {
|
|
261
263
|
const result = await callToolJson("list_pipeline_schedule_pipelines", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "../../utils/url.js";
|
|
3
|
+
import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "../../utils/url.js";
|
|
4
|
+
describe("When isCleartextRemoteUrl runs", () => {
|
|
5
|
+
test("should flag http to a remote host", () => {
|
|
6
|
+
assert.equal(isCleartextRemoteUrl("http://gitlab.example.com/api/v4"), true);
|
|
7
|
+
});
|
|
8
|
+
test("should allow https", () => {
|
|
9
|
+
assert.equal(isCleartextRemoteUrl("https://gitlab.example.com/api/v4"), false);
|
|
10
|
+
});
|
|
11
|
+
test("should allow http to loopback hosts", () => {
|
|
12
|
+
assert.equal(isCleartextRemoteUrl("http://127.0.0.1:8080/api/v4"), false);
|
|
13
|
+
assert.equal(isCleartextRemoteUrl("http://localhost/api/v4"), false);
|
|
14
|
+
assert.equal(isCleartextRemoteUrl("http://[::1]:3000/api/v4"), false);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
4
17
|
describe("When normalizeGitLabApiUrl runs", () => {
|
|
5
18
|
test("should default to gitlab.com", () => {
|
|
6
19
|
assert.equal(normalizeGitLabApiUrl(""), "https://gitlab.com/api/v4");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const MAX_PATTERN_LENGTH = 200;
|
|
2
|
+
const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]/;
|
|
3
|
+
// Only allow safe characters in tool regex patterns to prevent regex injection (CodeQL)
|
|
4
|
+
const SAFE_PATTERN_CHARS = /^[a-zA-Z0-9_|^$().*+?{}[\]\\:,\s-]+$/;
|
|
5
|
+
export function compileDeniedToolsRegex(pattern) {
|
|
6
|
+
if (!pattern) {
|
|
7
|
+
return { regex: undefined, error: undefined };
|
|
8
|
+
}
|
|
9
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
10
|
+
return {
|
|
11
|
+
regex: undefined,
|
|
12
|
+
error: `GITLAB_DENIED_TOOLS_REGEX pattern exceeds ${MAX_PATTERN_LENGTH} chars. Ignoring.`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (!SAFE_PATTERN_CHARS.test(pattern)) {
|
|
16
|
+
return {
|
|
17
|
+
regex: undefined,
|
|
18
|
+
error: "GITLAB_DENIED_TOOLS_REGEX contains invalid characters. Ignoring.",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
|
|
22
|
+
return {
|
|
23
|
+
regex: undefined,
|
|
24
|
+
error: "GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const regex = new RegExp(pattern);
|
|
29
|
+
regex.test("sample_tool_name");
|
|
30
|
+
return { regex, error: undefined };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {
|
|
34
|
+
regex: undefined,
|
|
35
|
+
error: `Invalid GITLAB_DENIED_TOOLS_REGEX pattern: "${pattern}". Ignoring.`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|