@zereight/mcp-gitlab 2.1.66 → 2.1.68

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.
@@ -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) {
@@ -323,6 +339,150 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
323
339
  }
324
340
  });
325
341
  });
342
+ describe("with session-scoped snippet tools", () => {
343
+ let mockGitLab;
344
+ const servers = [];
345
+ let mcpUrl;
346
+ const snippetHits = [];
347
+ before(async () => {
348
+ const mockPort = await findMockServerPort();
349
+ mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
350
+ mockGitLab.addMockHandler("get", "/snippets", (_req, res) => {
351
+ snippetHits.push("personal:list");
352
+ res.json([{ id: 99, title: "personal snippet of the token owner" }]);
353
+ });
354
+ mockGitLab.addMockHandler("get", "/projects/5/snippets", (_req, res) => {
355
+ snippetHits.push("project:list");
356
+ res.json([{ id: 42, title: "project snippet" }]);
357
+ });
358
+ mockGitLab.addMockHandler("get", "/projects/5/snippets/42", (_req, res) => {
359
+ snippetHits.push("project:get");
360
+ res.json({ id: 42, title: "project snippet" });
361
+ });
362
+ mockGitLab.addMockHandler("get", "/snippets/42", (_req, res) => {
363
+ snippetHits.push("personal:get");
364
+ res.json({ id: 42, title: "personal snippet of the token owner" });
365
+ });
366
+ mockGitLab.addMockHandler("delete", "/snippets/99", (_req, res) => {
367
+ snippetHits.push("personal:delete");
368
+ res.status(204).end();
369
+ });
370
+ mockGitLab.addMockHandler("delete", "/projects/5/snippets/42", (_req, res) => {
371
+ snippetHits.push("project:delete");
372
+ res.status(204).end();
373
+ });
374
+ mockGitLab.addMockHandler("post", "/snippets", (_req, res) => {
375
+ snippetHits.push("personal:create");
376
+ res.json({ id: 99, title: "personal snippet of the token owner" });
377
+ });
378
+ mockGitLab.addMockHandler("post", "/projects/5/snippets", (_req, res) => {
379
+ snippetHits.push("project:create");
380
+ res.json({ id: 42, title: "project snippet" });
381
+ });
382
+ mockGitLab.addMockHandler("put", "/snippets/42", (_req, res) => {
383
+ snippetHits.push("personal:update");
384
+ res.json({ id: 42, title: "updated personal snippet" });
385
+ });
386
+ mockGitLab.addMockHandler("put", "/projects/5/snippets/42", (_req, res) => {
387
+ snippetHits.push("project:update");
388
+ res.json({ id: 42, title: "updated project snippet" });
389
+ });
390
+ await mockGitLab.start();
391
+ const mcpPort = await findAvailablePort(3200);
392
+ const server = await launchServer({
393
+ mode: TransportMode.STREAMABLE_HTTP,
394
+ port: mcpPort,
395
+ timeout: 5000,
396
+ env: {
397
+ STREAMABLE_HTTP: "true",
398
+ REMOTE_AUTHORIZATION: "true",
399
+ ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
400
+ GITLAB_TOOLSETS: "snippets",
401
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
402
+ },
403
+ });
404
+ servers.push(server);
405
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
406
+ });
407
+ after(async () => {
408
+ cleanupServers(servers);
409
+ await mockGitLab?.stop();
410
+ });
411
+ test("list_snippets without project_id stays within the session scope", async () => {
412
+ snippetHits.length = 0;
413
+ const client = await connectClient(mcpUrl, "5");
414
+ try {
415
+ const result = await client.callTool("list_snippets", {});
416
+ assert.ok(result.content, "Should have content");
417
+ const content = result.content[0];
418
+ assert.ok("text" in content, "Content should have text");
419
+ const snippets = JSON.parse(content.text);
420
+ assert.strictEqual(snippets[0].title, "project snippet");
421
+ }
422
+ finally {
423
+ await client.disconnect();
424
+ }
425
+ assert.deepStrictEqual(snippetHits, ["project:list"], "must route to the scoped project, not the personal /snippets endpoint");
426
+ });
427
+ test("delete_snippet without project_id stays within the session scope", async () => {
428
+ snippetHits.length = 0;
429
+ const client = await connectClient(mcpUrl, "5");
430
+ try {
431
+ await client.callTool("delete_snippet", { snippet_id: 42 });
432
+ }
433
+ finally {
434
+ await client.disconnect();
435
+ }
436
+ assert.deepStrictEqual(snippetHits, ["project:delete"], "must route to the scoped project, not the personal /snippets endpoint");
437
+ });
438
+ test("get_snippet without project_id stays within the session scope", async () => {
439
+ snippetHits.length = 0;
440
+ const client = await connectClient(mcpUrl, "5");
441
+ try {
442
+ const result = await client.callTool("get_snippet", { snippet_id: 42 });
443
+ assert.ok(result.content, "Should have content");
444
+ const content = result.content[0];
445
+ assert.ok("text" in content, "Content should have text");
446
+ const snippet = JSON.parse(content.text);
447
+ assert.strictEqual(snippet.title, "project snippet");
448
+ }
449
+ finally {
450
+ await client.disconnect();
451
+ }
452
+ assert.deepStrictEqual(snippetHits, ["project:get"], "must route to the scoped project, not the personal /snippets endpoint");
453
+ });
454
+ test("create_snippet without project_id stays within the session scope", async () => {
455
+ snippetHits.length = 0;
456
+ const client = await connectClient(mcpUrl, "5");
457
+ try {
458
+ const result = await client.callTool("create_snippet", {
459
+ title: "scoped create",
460
+ file_name: "note.md",
461
+ content: "hi",
462
+ });
463
+ assert.strictEqual(snippetTitleFrom(parseToolJson(result)), "project snippet");
464
+ }
465
+ finally {
466
+ await client.disconnect();
467
+ }
468
+ assert.deepStrictEqual(snippetHits, ["project:create"], "must route to the scoped project, not the personal /snippets endpoint");
469
+ });
470
+ test("update_snippet without project_id stays within the session scope", async () => {
471
+ snippetHits.length = 0;
472
+ const client = await connectClient(mcpUrl, "5");
473
+ try {
474
+ const result = await client.callTool("update_snippet", {
475
+ snippet_id: 42,
476
+ title: "scoped update",
477
+ });
478
+ assert.strictEqual(snippetTitleFrom(parseToolJson(result)), "updated project snippet");
479
+ }
480
+ finally {
481
+ await client.disconnect();
482
+ }
483
+ assert.deepStrictEqual(snippetHits, ["project:update"], "must route to the scoped project, not the personal /snippets endpoint");
484
+ });
485
+ });
326
486
  describe("with ENABLE_DYNAMIC_PROJECT_SCOPE disabled", () => {
327
487
  let mockGitLab;
328
488
  const servers = [];
@@ -83,6 +83,7 @@ const DELETE_SAMPLE_TOOLS = [
83
83
  "delete_group_milestone",
84
84
  "delete_project_variable",
85
85
  "delete_webhook",
86
+ "delete_snippet",
86
87
  ];
87
88
  // Destructive teardown tools whose names do not start with `delete_`. All five are listed
88
89
  // here (list-hiding) and exercised with explicit `callTool` literals below (call-rejection),