@zereight/mcp-gitlab 2.1.67 → 2.1.69
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 +8 -4
- package/README.md +14 -5
- package/README.pt-BR.md +589 -0
- package/README.zh-CN.md +8 -4
- 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 +329 -44
- package/build/oauth.js +93 -10
- package/build/schemas.js +187 -0
- package/build/scripts/generate-tool-docs.js +7 -1
- package/build/scripts/tool-coverage/coverage.js +2 -0
- 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/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/oauth-device-flow-tests.js +10 -0
- package/build/test/oauth-startup.test.js +204 -0
- package/build/test/test-dynamic-project-scope.js +162 -0
- package/build/test/test-geteffectiveprojectid.js +4 -2
- package/build/test/test-permission-mode.js +87 -0
- package/build/test/test-snippets.js +735 -0
- package/build/test/test-toolset-filtering.js +3 -0
- package/build/tools/denied-regex.js +38 -0
- package/build/tools/registry.js +42 -1
- package/package.json +1 -1
|
@@ -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
|
+
});
|
|
@@ -5,6 +5,22 @@ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server
|
|
|
5
5
|
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
6
6
|
const MOCK_TOKEN = "glpat-dynamic-scope-token";
|
|
7
7
|
const SCOPE_HEADER = "x-gitlab-allowed-project-ids";
|
|
8
|
+
function parseToolJson(result) {
|
|
9
|
+
assert.ok(result.isError !== true, "tool should succeed");
|
|
10
|
+
const first = result.content[0];
|
|
11
|
+
assert.ok(first !== undefined && first.type === "text");
|
|
12
|
+
return JSON.parse(first.text);
|
|
13
|
+
}
|
|
14
|
+
function snippetTitleFrom(value) {
|
|
15
|
+
if (value === null || typeof value !== "object" || !("title" in value)) {
|
|
16
|
+
assert.fail("expected a snippet object with title");
|
|
17
|
+
}
|
|
18
|
+
const title = value.title;
|
|
19
|
+
if (typeof title !== "string") {
|
|
20
|
+
assert.fail("expected snippet title to be a string");
|
|
21
|
+
}
|
|
22
|
+
return title;
|
|
23
|
+
}
|
|
8
24
|
async function connectClient(mcpUrl, scope) {
|
|
9
25
|
const headers = { authorization: `Bearer ${MOCK_TOKEN}` };
|
|
10
26
|
if (scope !== undefined) {
|
|
@@ -63,6 +79,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
|
|
|
63
79
|
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
64
80
|
ENABLE_STRICT_PROJECT_SCOPE: "true",
|
|
65
81
|
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
82
|
+
GITLAB_TOOLS: "execute_graphql",
|
|
66
83
|
GITLAB_ALLOWED_PROJECT_IDS: "1,2",
|
|
67
84
|
},
|
|
68
85
|
});
|
|
@@ -270,6 +287,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
|
|
|
270
287
|
REMOTE_AUTHORIZATION: "true",
|
|
271
288
|
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
272
289
|
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
290
|
+
GITLAB_TOOLS: "execute_graphql,get_vulnerability",
|
|
273
291
|
},
|
|
274
292
|
});
|
|
275
293
|
servers.push(server);
|
|
@@ -323,6 +341,150 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
|
|
|
323
341
|
}
|
|
324
342
|
});
|
|
325
343
|
});
|
|
344
|
+
describe("with session-scoped snippet tools", () => {
|
|
345
|
+
let mockGitLab;
|
|
346
|
+
const servers = [];
|
|
347
|
+
let mcpUrl;
|
|
348
|
+
const snippetHits = [];
|
|
349
|
+
before(async () => {
|
|
350
|
+
const mockPort = await findMockServerPort();
|
|
351
|
+
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
|
|
352
|
+
mockGitLab.addMockHandler("get", "/snippets", (_req, res) => {
|
|
353
|
+
snippetHits.push("personal:list");
|
|
354
|
+
res.json([{ id: 99, title: "personal snippet of the token owner" }]);
|
|
355
|
+
});
|
|
356
|
+
mockGitLab.addMockHandler("get", "/projects/5/snippets", (_req, res) => {
|
|
357
|
+
snippetHits.push("project:list");
|
|
358
|
+
res.json([{ id: 42, title: "project snippet" }]);
|
|
359
|
+
});
|
|
360
|
+
mockGitLab.addMockHandler("get", "/projects/5/snippets/42", (_req, res) => {
|
|
361
|
+
snippetHits.push("project:get");
|
|
362
|
+
res.json({ id: 42, title: "project snippet" });
|
|
363
|
+
});
|
|
364
|
+
mockGitLab.addMockHandler("get", "/snippets/42", (_req, res) => {
|
|
365
|
+
snippetHits.push("personal:get");
|
|
366
|
+
res.json({ id: 42, title: "personal snippet of the token owner" });
|
|
367
|
+
});
|
|
368
|
+
mockGitLab.addMockHandler("delete", "/snippets/99", (_req, res) => {
|
|
369
|
+
snippetHits.push("personal:delete");
|
|
370
|
+
res.status(204).end();
|
|
371
|
+
});
|
|
372
|
+
mockGitLab.addMockHandler("delete", "/projects/5/snippets/42", (_req, res) => {
|
|
373
|
+
snippetHits.push("project:delete");
|
|
374
|
+
res.status(204).end();
|
|
375
|
+
});
|
|
376
|
+
mockGitLab.addMockHandler("post", "/snippets", (_req, res) => {
|
|
377
|
+
snippetHits.push("personal:create");
|
|
378
|
+
res.json({ id: 99, title: "personal snippet of the token owner" });
|
|
379
|
+
});
|
|
380
|
+
mockGitLab.addMockHandler("post", "/projects/5/snippets", (_req, res) => {
|
|
381
|
+
snippetHits.push("project:create");
|
|
382
|
+
res.json({ id: 42, title: "project snippet" });
|
|
383
|
+
});
|
|
384
|
+
mockGitLab.addMockHandler("put", "/snippets/42", (_req, res) => {
|
|
385
|
+
snippetHits.push("personal:update");
|
|
386
|
+
res.json({ id: 42, title: "updated personal snippet" });
|
|
387
|
+
});
|
|
388
|
+
mockGitLab.addMockHandler("put", "/projects/5/snippets/42", (_req, res) => {
|
|
389
|
+
snippetHits.push("project:update");
|
|
390
|
+
res.json({ id: 42, title: "updated project snippet" });
|
|
391
|
+
});
|
|
392
|
+
await mockGitLab.start();
|
|
393
|
+
const mcpPort = await findAvailablePort(3200);
|
|
394
|
+
const server = await launchServer({
|
|
395
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
396
|
+
port: mcpPort,
|
|
397
|
+
timeout: 5000,
|
|
398
|
+
env: {
|
|
399
|
+
STREAMABLE_HTTP: "true",
|
|
400
|
+
REMOTE_AUTHORIZATION: "true",
|
|
401
|
+
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
402
|
+
GITLAB_TOOLSETS: "snippets",
|
|
403
|
+
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
servers.push(server);
|
|
407
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
408
|
+
});
|
|
409
|
+
after(async () => {
|
|
410
|
+
cleanupServers(servers);
|
|
411
|
+
await mockGitLab?.stop();
|
|
412
|
+
});
|
|
413
|
+
test("list_snippets without project_id stays within the session scope", async () => {
|
|
414
|
+
snippetHits.length = 0;
|
|
415
|
+
const client = await connectClient(mcpUrl, "5");
|
|
416
|
+
try {
|
|
417
|
+
const result = await client.callTool("list_snippets", {});
|
|
418
|
+
assert.ok(result.content, "Should have content");
|
|
419
|
+
const content = result.content[0];
|
|
420
|
+
assert.ok("text" in content, "Content should have text");
|
|
421
|
+
const snippets = JSON.parse(content.text);
|
|
422
|
+
assert.strictEqual(snippets[0].title, "project snippet");
|
|
423
|
+
}
|
|
424
|
+
finally {
|
|
425
|
+
await client.disconnect();
|
|
426
|
+
}
|
|
427
|
+
assert.deepStrictEqual(snippetHits, ["project:list"], "must route to the scoped project, not the personal /snippets endpoint");
|
|
428
|
+
});
|
|
429
|
+
test("delete_snippet without project_id stays within the session scope", async () => {
|
|
430
|
+
snippetHits.length = 0;
|
|
431
|
+
const client = await connectClient(mcpUrl, "5");
|
|
432
|
+
try {
|
|
433
|
+
await client.callTool("delete_snippet", { snippet_id: 42 });
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await client.disconnect();
|
|
437
|
+
}
|
|
438
|
+
assert.deepStrictEqual(snippetHits, ["project:delete"], "must route to the scoped project, not the personal /snippets endpoint");
|
|
439
|
+
});
|
|
440
|
+
test("get_snippet without project_id stays within the session scope", async () => {
|
|
441
|
+
snippetHits.length = 0;
|
|
442
|
+
const client = await connectClient(mcpUrl, "5");
|
|
443
|
+
try {
|
|
444
|
+
const result = await client.callTool("get_snippet", { snippet_id: 42 });
|
|
445
|
+
assert.ok(result.content, "Should have content");
|
|
446
|
+
const content = result.content[0];
|
|
447
|
+
assert.ok("text" in content, "Content should have text");
|
|
448
|
+
const snippet = JSON.parse(content.text);
|
|
449
|
+
assert.strictEqual(snippet.title, "project snippet");
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
await client.disconnect();
|
|
453
|
+
}
|
|
454
|
+
assert.deepStrictEqual(snippetHits, ["project:get"], "must route to the scoped project, not the personal /snippets endpoint");
|
|
455
|
+
});
|
|
456
|
+
test("create_snippet without project_id stays within the session scope", async () => {
|
|
457
|
+
snippetHits.length = 0;
|
|
458
|
+
const client = await connectClient(mcpUrl, "5");
|
|
459
|
+
try {
|
|
460
|
+
const result = await client.callTool("create_snippet", {
|
|
461
|
+
title: "scoped create",
|
|
462
|
+
file_name: "note.md",
|
|
463
|
+
content: "hi",
|
|
464
|
+
});
|
|
465
|
+
assert.strictEqual(snippetTitleFrom(parseToolJson(result)), "project snippet");
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
await client.disconnect();
|
|
469
|
+
}
|
|
470
|
+
assert.deepStrictEqual(snippetHits, ["project:create"], "must route to the scoped project, not the personal /snippets endpoint");
|
|
471
|
+
});
|
|
472
|
+
test("update_snippet without project_id stays within the session scope", async () => {
|
|
473
|
+
snippetHits.length = 0;
|
|
474
|
+
const client = await connectClient(mcpUrl, "5");
|
|
475
|
+
try {
|
|
476
|
+
const result = await client.callTool("update_snippet", {
|
|
477
|
+
snippet_id: 42,
|
|
478
|
+
title: "scoped update",
|
|
479
|
+
});
|
|
480
|
+
assert.strictEqual(snippetTitleFrom(parseToolJson(result)), "updated project snippet");
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
await client.disconnect();
|
|
484
|
+
}
|
|
485
|
+
assert.deepStrictEqual(snippetHits, ["project:update"], "must route to the scoped project, not the personal /snippets endpoint");
|
|
486
|
+
});
|
|
487
|
+
});
|
|
326
488
|
describe("with ENABLE_DYNAMIC_PROJECT_SCOPE disabled", () => {
|
|
327
489
|
let mockGitLab;
|
|
328
490
|
const servers = [];
|
|
@@ -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);
|
|
@@ -318,6 +319,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
318
319
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
319
320
|
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
320
321
|
GITLAB_READ_ONLY_MODE: 'true',
|
|
322
|
+
GITLAB_TOOLSETS: 'all',
|
|
321
323
|
}
|
|
322
324
|
});
|
|
323
325
|
servers.push(server);
|
|
@@ -484,7 +486,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
484
486
|
REMOTE_AUTHORIZATION: 'true',
|
|
485
487
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
486
488
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
487
|
-
GITLAB_TOOLSETS: 'variables',
|
|
489
|
+
GITLAB_TOOLSETS: 'variables,repositories,groups,projects',
|
|
488
490
|
}
|
|
489
491
|
});
|
|
490
492
|
servers.push(server);
|
|
@@ -582,7 +584,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
582
584
|
REMOTE_AUTHORIZATION: 'true',
|
|
583
585
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
584
586
|
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
585
|
-
GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines',
|
|
587
|
+
GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines,repositories,groups,projects',
|
|
586
588
|
}
|
|
587
589
|
});
|
|
588
590
|
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
|
});
|
|
@@ -83,6 +85,7 @@ const DELETE_SAMPLE_TOOLS = [
|
|
|
83
85
|
"delete_group_milestone",
|
|
84
86
|
"delete_project_variable",
|
|
85
87
|
"delete_webhook",
|
|
88
|
+
"delete_snippet",
|
|
86
89
|
];
|
|
87
90
|
// Destructive teardown tools whose names do not start with `delete_`. All five are listed
|
|
88
91
|
// here (list-hiding) and exercised with explicit `callTool` literals below (call-rejection),
|
|
@@ -279,6 +282,90 @@ describe("Permission Mode", { concurrency: 1 }, () => {
|
|
|
279
282
|
}
|
|
280
283
|
});
|
|
281
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
|
+
});
|
|
282
369
|
describe("readonly mode via GITLAB_PERMISSION_MODE", () => {
|
|
283
370
|
let server;
|
|
284
371
|
const servers = [];
|