@zereight/mcp-gitlab 2.1.12 → 2.1.14

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/build/schemas.js CHANGED
@@ -595,6 +595,39 @@ export const GitLabCurrentUserSchema = z.object({
595
595
  extern_uid: z.string(),
596
596
  })).optional(),
597
597
  }).passthrough();
598
+ // Group related schemas
599
+ export const CreateGroupSchema = z.object({
600
+ name: z.string().describe("The name of the group"),
601
+ path: z.string().describe("The path of the group"),
602
+ description: z.string().optional().describe("The group's description"),
603
+ visibility: z.enum(["private", "internal", "public"]).optional().describe("The group's visibility level"),
604
+ parent_id: z.coerce.number().optional().describe("The parent group ID for creating a subgroup"),
605
+ });
606
+ export const GitLabGroupSchema = z.object({
607
+ id: z.coerce.string(),
608
+ name: z.string(),
609
+ path: z.string(),
610
+ description: z.string().nullable(),
611
+ visibility: z.string().optional(),
612
+ share_with_group_lock: z.boolean().optional(),
613
+ require_two_factor_authentication: z.boolean().optional(),
614
+ two_factor_grace_period: z.number().optional(),
615
+ project_creation_level: z.string().optional(),
616
+ auto_devops_enabled: z.boolean().nullable().optional(),
617
+ subgroup_creation_level: z.string().optional(),
618
+ emails_disabled: z.boolean().nullable().optional(),
619
+ mentions_disabled: z.boolean().nullable().optional(),
620
+ lfs_enabled: z.boolean().nullable().optional(),
621
+ avatar_url: z.string().nullable().optional(),
622
+ web_url: z.string(),
623
+ request_access_enabled: z.boolean().nullable().optional(),
624
+ full_name: z.string(),
625
+ full_path: z.string(),
626
+ file_template_project_id: z.number().nullable().optional(),
627
+ parent_id: z.coerce.string().nullable().optional(),
628
+ created_at: z.string().optional(),
629
+ statistics: z.any().optional(),
630
+ });
598
631
  // Namespace related schemas
599
632
  // Base schema for project-related operations
600
633
  const ProjectParamsSchema = z.object({
@@ -2404,6 +2437,50 @@ export const GetCommitDiffSchema = z.object({
2404
2437
  .optional()
2405
2438
  .describe("Whether to return the full diff or only first page (default: false)"),
2406
2439
  });
2440
+ export const GetFileBlameSchema = z
2441
+ .object({
2442
+ project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
2443
+ file_path: z.string().describe("The full path of the file to blame, relative to repo root"),
2444
+ ref: z
2445
+ .string()
2446
+ .describe("The name of branch, tag or commit (required by GitLab blame API)"),
2447
+ range_start: z
2448
+ .coerce.number()
2449
+ .int()
2450
+ .optional()
2451
+ .describe("First line of the blame range (inclusive, 1-based). Both range[start] and range[end] must be set together."),
2452
+ range_end: z
2453
+ .coerce.number()
2454
+ .int()
2455
+ .optional()
2456
+ .describe("Last line of the blame range (inclusive, 1-based). Both range[start] and range[end] must be set together."),
2457
+ })
2458
+ .refine((v) => (v.range_start === undefined) === (v.range_end === undefined), {
2459
+ message: "range_start and range_end must be provided together (both or neither). Passing only one silently returned full-file blame on GitLab side.",
2460
+ path: ["range_end"],
2461
+ })
2462
+ .refine((v) => v.range_start === undefined ||
2463
+ v.range_end === undefined ||
2464
+ v.range_start <= v.range_end, {
2465
+ message: "range_start must be less than or equal to range_end.",
2466
+ path: ["range_start"],
2467
+ });
2468
+ export const GitLabBlameEntrySchema = z.object({
2469
+ lines: z.array(z.string()).describe("Source lines covered by this blame range"),
2470
+ commit: z
2471
+ .object({
2472
+ id: z.string(),
2473
+ parent_ids: z.array(z.string()).optional(),
2474
+ message: z.string().optional(),
2475
+ authored_date: z.string().optional(),
2476
+ author_name: z.string().optional(),
2477
+ author_email: z.string().optional(),
2478
+ committed_date: z.string().optional(),
2479
+ committer_name: z.string().optional(),
2480
+ committer_email: z.string().optional(),
2481
+ })
2482
+ .passthrough(),
2483
+ });
2407
2484
  export const ListCommitStatusesSchema = z
2408
2485
  .object({
2409
2486
  project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
@@ -2513,6 +2590,11 @@ export const MarkdownUploadSchema = z.object({
2513
2590
  project_id: z.string().describe("Project ID or URL-encoded path of the project"),
2514
2591
  file_path: z.string().describe("Path to the file to upload"),
2515
2592
  });
2593
+ export const MarkdownUploadRemoteSchema = z.object({
2594
+ project_id: z.string().describe("Project ID or URL-encoded path of the project"),
2595
+ content: z.string().describe("File content as base64-encoded string"),
2596
+ filename: z.string().describe("Filename for the uploaded content"),
2597
+ });
2516
2598
  export const DownloadAttachmentSchema = z.object({
2517
2599
  project_id: z.string().describe("Project ID or URL-encoded path of the project"),
2518
2600
  secret: z.string().describe("The 32-character secret of the upload"),
@@ -2522,6 +2604,11 @@ export const DownloadAttachmentSchema = z.object({
2522
2604
  .optional()
2523
2605
  .describe("Local path to save the file (optional, defaults to current directory)"),
2524
2606
  });
2607
+ export const DownloadAttachmentRemoteSchema = z.object({
2608
+ project_id: z.string().describe("Project ID or URL-encoded path of the project"),
2609
+ secret: z.string().describe("The 32-character secret of the upload"),
2610
+ filename: z.string().describe("The filename of the upload"),
2611
+ });
2525
2612
  export const GroupIteration = z.object({
2526
2613
  id: z.coerce.string(),
2527
2614
  iid: z.coerce.string(),
@@ -2866,6 +2953,10 @@ export const DownloadJobArtifactsSchema = z.object({
2866
2953
  .optional()
2867
2954
  .describe("Local directory to save the artifact archive (defaults to current directory)"),
2868
2955
  });
2956
+ export const DownloadJobArtifactsRemoteSchema = z.object({
2957
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2958
+ job_id: z.coerce.string().describe("The ID of the job"),
2959
+ });
2869
2960
  export const GetJobArtifactFileSchema = z.object({
2870
2961
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2871
2962
  job_id: z.coerce.string().describe("The ID of the job"),
@@ -0,0 +1,145 @@
1
+ import { describe, test, before, after } 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
+ const MOCK_BLAME = [
8
+ {
9
+ lines: ["line one", ""],
10
+ commit: {
11
+ id: "1111111111111111111111111111111111111111",
12
+ message: "feat: initial commit",
13
+ authored_date: "2024-01-01T00:00:00.000Z",
14
+ author_name: "Alice",
15
+ author_email: "alice@example.com",
16
+ },
17
+ },
18
+ {
19
+ lines: ["line three"],
20
+ commit: {
21
+ id: "2222222222222222222222222222222222222222",
22
+ message: "feat: add second change",
23
+ authored_date: "2024-02-02T00:00:00.000Z",
24
+ author_name: "Bob",
25
+ author_email: "bob@example.com",
26
+ },
27
+ },
28
+ ];
29
+ async function callGetFileBlame(args, env) {
30
+ return new Promise((resolve, reject) => {
31
+ const proc = spawn("node", ["build/index.js"], {
32
+ stdio: ["pipe", "pipe", "pipe"],
33
+ env: {
34
+ ...process.env,
35
+ ...env,
36
+ GITLAB_READ_ONLY_MODE: "true",
37
+ },
38
+ });
39
+ let output = "";
40
+ let errorOutput = "";
41
+ proc.stdout?.on("data", (d) => (output += d));
42
+ proc.stderr?.on("data", (d) => (errorOutput += d));
43
+ proc.on("close", (code) => {
44
+ if (code !== 0)
45
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
46
+ const line = output.split("\n").find((l) => l.startsWith("{"));
47
+ if (!line)
48
+ return reject(new Error("No JSON output found"));
49
+ try {
50
+ const response = JSON.parse(line);
51
+ if (response.error)
52
+ return reject(response.error);
53
+ const content = response.result?.content?.[0]?.text;
54
+ if (content)
55
+ return resolve(JSON.parse(content));
56
+ resolve(response.result);
57
+ }
58
+ catch (e) {
59
+ reject(e);
60
+ }
61
+ });
62
+ proc.stdin?.end(JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ id: 1,
65
+ method: "tools/call",
66
+ params: { name: "get_file_blame", arguments: args },
67
+ }) + "\n");
68
+ });
69
+ }
70
+ describe("get_file_blame", () => {
71
+ let mockGitLab;
72
+ let mockGitLabUrl;
73
+ let lastQuery = {};
74
+ before(async () => {
75
+ const mockPort = await findMockServerPort(9000);
76
+ mockGitLab = new MockGitLabServer({
77
+ port: mockPort,
78
+ validTokens: [MOCK_TOKEN],
79
+ });
80
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/src%2Fexample.txt/blame`, (req, res) => {
81
+ lastQuery = req.query;
82
+ res.json(MOCK_BLAME);
83
+ });
84
+ await mockGitLab.start();
85
+ mockGitLabUrl = mockGitLab.getUrl();
86
+ });
87
+ after(async () => {
88
+ await mockGitLab.stop();
89
+ });
90
+ test("returns blame entries for a file at ref", async () => {
91
+ const blame = await callGetFileBlame({
92
+ project_id: TEST_PROJECT_ID,
93
+ file_path: "src/example.txt",
94
+ ref: "main",
95
+ }, {
96
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
97
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
98
+ });
99
+ assert.ok(Array.isArray(blame), "Response should be an array");
100
+ assert.strictEqual(blame.length, 2, "Two blame entries expected");
101
+ assert.strictEqual(blame[1].commit.id, "2222222222222222222222222222222222222222", "second entry commit id matches");
102
+ assert.deepStrictEqual(blame[1].lines, ["line three"]);
103
+ assert.strictEqual(lastQuery.ref, "main", "ref propagated to GitLab API");
104
+ assert.ok(!("range[start]" in lastQuery) && !("range[end]" in lastQuery), "no range params when omitted");
105
+ });
106
+ test("passes range[start]/range[end] when both set", async () => {
107
+ await callGetFileBlame({
108
+ project_id: TEST_PROJECT_ID,
109
+ file_path: "src/example.txt",
110
+ ref: "main",
111
+ range_start: 10,
112
+ range_end: 20,
113
+ }, {
114
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
115
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
116
+ });
117
+ assert.strictEqual(lastQuery["range[start]"], "10");
118
+ assert.strictEqual(lastQuery["range[end]"], "20");
119
+ });
120
+ test("rejects partial range (range_start only) at schema layer", async () => {
121
+ await assert.rejects(() => callGetFileBlame({
122
+ project_id: TEST_PROJECT_ID,
123
+ file_path: "src/example.txt",
124
+ ref: "main",
125
+ range_start: 10,
126
+ }, {
127
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
128
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
129
+ }), (e) => typeof e?.message === "string" &&
130
+ e.message.includes("range_start and range_end must be provided together"));
131
+ });
132
+ test("rejects inverted range (start > end) at schema layer", async () => {
133
+ await assert.rejects(() => callGetFileBlame({
134
+ project_id: TEST_PROJECT_ID,
135
+ file_path: "src/example.txt",
136
+ ref: "main",
137
+ range_start: 20,
138
+ range_end: 10,
139
+ }, {
140
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
141
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
142
+ }), (e) => typeof e?.message === "string" &&
143
+ e.message.includes("range_start must be less than or equal to range_end"));
144
+ });
145
+ });
@@ -6,7 +6,7 @@ import { describe, test, before, after } from 'node:test';
6
6
  import assert from 'node:assert';
7
7
  import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST } from './utils/server-launcher.js';
8
8
  import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
9
- import { StreamableHTTPTestClient } from './clients/streamable-http-client.js';
9
+ import { CustomHeaderClient } from './clients/custom-header-client.js';
10
10
  // Use the same token that will be passed via GITLAB_TOKEN_TEST environment variable
11
11
  const MOCK_TOKEN = process.env.GITLAB_TOKEN_TEST || 'glpat-mock-token-12345';
12
12
  const DEFAULT_PROJECT_ID = '123';
@@ -43,6 +43,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
43
43
  timeout: 5000,
44
44
  env: {
45
45
  STREAMABLE_HTTP: 'true',
46
+ REMOTE_AUTHORIZATION: 'true',
46
47
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
47
48
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
48
49
  GITLAB_READ_ONLY_MODE: 'true',
@@ -50,7 +51,9 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
50
51
  });
51
52
  servers.push(server);
52
53
  mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
53
- client = new StreamableHTTPTestClient();
54
+ client = new CustomHeaderClient({
55
+ authorization: `Bearer ${MOCK_TOKEN}`,
56
+ });
54
57
  await client.connect(mcpUrl);
55
58
  console.log(`Mock GitLab: ${mockGitLabUrl}`);
56
59
  console.log(`MCP Server: ${mcpUrl}`);
@@ -113,7 +116,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
113
116
  port: mcpPort,
114
117
  timeout: 5000,
115
118
  env: {
116
- STREAMABLE_HTTP: 'true',
119
+ REMOTE_AUTHORIZATION: 'true',
117
120
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
118
121
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
119
122
  GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
@@ -122,7 +125,9 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
122
125
  });
123
126
  servers.push(server);
124
127
  mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
125
- client = new StreamableHTTPTestClient();
128
+ client = new CustomHeaderClient({
129
+ authorization: `Bearer ${MOCK_TOKEN}`,
130
+ });
126
131
  await client.connect(mcpUrl);
127
132
  console.log(`Mock GitLab: ${mockGitLabUrl}`);
128
133
  console.log(`MCP Server: ${mcpUrl}`);
@@ -183,7 +188,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
183
188
  port: mcpPort,
184
189
  timeout: 5000,
185
190
  env: {
186
- STREAMABLE_HTTP: 'true',
191
+ REMOTE_AUTHORIZATION: 'true',
187
192
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
188
193
  GITLAB_ALLOWED_PROJECT_IDS: `${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`,
189
194
  GITLAB_READ_ONLY_MODE: 'true',
@@ -191,7 +196,9 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
191
196
  });
192
197
  servers.push(server);
193
198
  mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
194
- client = new StreamableHTTPTestClient();
199
+ client = new CustomHeaderClient({
200
+ authorization: `Bearer ${MOCK_TOKEN}`,
201
+ });
195
202
  await client.connect(mcpUrl);
196
203
  console.log(`Mock GitLab: ${mockGitLabUrl}`);
197
204
  console.log(`MCP Server: ${mcpUrl}`);
@@ -242,4 +249,221 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
242
249
  console.log(` ✓ Allowed access to second project ${OTHER_PROJECT_ID}`);
243
250
  });
244
251
  });
252
+ describe('GITLAB_PROJECT_ID guards repository and group mutators', () => {
253
+ let mcpUrl;
254
+ let mockGitLab;
255
+ let servers = [];
256
+ let client;
257
+ before(async () => {
258
+ const mockPort = await findMockServerPort(9400);
259
+ mockGitLab = new MockGitLabServer({
260
+ port: mockPort,
261
+ validTokens: [MOCK_TOKEN]
262
+ });
263
+ await mockGitLab.start();
264
+ const mockGitLabUrl = mockGitLab.getUrl();
265
+ const mcpPort = await findAvailablePort(3400);
266
+ const server = await launchServer({
267
+ mode: TransportMode.STREAMABLE_HTTP,
268
+ port: mcpPort,
269
+ timeout: 5000,
270
+ env: {
271
+ REMOTE_AUTHORIZATION: 'true',
272
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
273
+ GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
274
+ }
275
+ });
276
+ servers.push(server);
277
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
278
+ client = new CustomHeaderClient({
279
+ authorization: `Bearer ${MOCK_TOKEN}`,
280
+ });
281
+ await client.connect(mcpUrl);
282
+ });
283
+ after(async () => {
284
+ if (client)
285
+ await client.disconnect();
286
+ cleanupServers(servers);
287
+ if (mockGitLab)
288
+ await mockGitLab.stop();
289
+ });
290
+ test('should reject create_repository when GITLAB_PROJECT_ID is set', async () => {
291
+ try {
292
+ await client.callTool('create_repository', { name: 'test-repo' });
293
+ assert.fail('Should have rejected create_repository');
294
+ }
295
+ catch (error) {
296
+ assert.ok(error instanceof Error);
297
+ assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
298
+ }
299
+ });
300
+ test('should reject fork_repository when GITLAB_PROJECT_ID is set', async () => {
301
+ try {
302
+ await client.callTool('fork_repository', { project_id: '999' });
303
+ assert.fail('Should have rejected fork_repository');
304
+ }
305
+ catch (error) {
306
+ assert.ok(error instanceof Error);
307
+ assert.ok(error.message.includes('fork_repository is not allowed'), 'Should mention fork_repository');
308
+ }
309
+ });
310
+ test('should reject create_group when GITLAB_PROJECT_ID is set', async () => {
311
+ try {
312
+ await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
313
+ assert.fail('Should have rejected create_group');
314
+ }
315
+ catch (error) {
316
+ assert.ok(error instanceof Error);
317
+ assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
318
+ }
319
+ });
320
+ test('should allow get_project (non-mutator) when GITLAB_PROJECT_ID is set', async () => {
321
+ const result = await client.callTool('get_project', { project_id: '' });
322
+ assert.ok(result.content, 'Should have content');
323
+ const content = result.content[0];
324
+ assert.ok('text' in content, 'Content should have text');
325
+ const project = JSON.parse(content.text);
326
+ assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use default project');
327
+ });
328
+ });
329
+ describe('GITLAB_ALLOWED_PROJECT_IDS guards repository and group mutators (allowlist-only, no GITLAB_PROJECT_ID)', () => {
330
+ let mcpUrl;
331
+ let mockGitLab;
332
+ let servers = [];
333
+ let client;
334
+ before(async () => {
335
+ const mockPort = await findMockServerPort(9600);
336
+ mockGitLab = new MockGitLabServer({
337
+ port: mockPort,
338
+ validTokens: [MOCK_TOKEN]
339
+ });
340
+ await mockGitLab.start();
341
+ const mockGitLabUrl = mockGitLab.getUrl();
342
+ const mcpPort = await findAvailablePort(3600);
343
+ const server = await launchServer({
344
+ mode: TransportMode.STREAMABLE_HTTP,
345
+ port: mcpPort,
346
+ timeout: 5000,
347
+ env: {
348
+ REMOTE_AUTHORIZATION: 'true',
349
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
350
+ GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
351
+ }
352
+ });
353
+ servers.push(server);
354
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
355
+ client = new CustomHeaderClient({
356
+ authorization: `Bearer ${MOCK_TOKEN}`,
357
+ });
358
+ await client.connect(mcpUrl);
359
+ });
360
+ after(async () => {
361
+ if (client)
362
+ await client.disconnect();
363
+ cleanupServers(servers);
364
+ if (mockGitLab)
365
+ await mockGitLab.stop();
366
+ });
367
+ test('should reject create_repository with GITLAB_ALLOWED_PROJECT_IDS', async () => {
368
+ try {
369
+ await client.callTool('create_repository', { name: 'test-repo' });
370
+ assert.fail('Should have rejected create_repository');
371
+ }
372
+ catch (error) {
373
+ assert.ok(error instanceof Error);
374
+ assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
375
+ }
376
+ });
377
+ test('should reject fork_repository with GITLAB_ALLOWED_PROJECT_IDS', async () => {
378
+ try {
379
+ await client.callTool('fork_repository', { project_id: '999' });
380
+ assert.fail('Should have rejected fork_repository');
381
+ }
382
+ catch (error) {
383
+ assert.ok(error instanceof Error);
384
+ assert.ok(error.message.includes('fork_repository is not allowed'), 'Should mention fork_repository');
385
+ }
386
+ });
387
+ test('should reject create_group with GITLAB_ALLOWED_PROJECT_IDS', async () => {
388
+ try {
389
+ await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
390
+ assert.fail('Should have rejected create_group');
391
+ }
392
+ catch (error) {
393
+ assert.ok(error instanceof Error);
394
+ assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
395
+ }
396
+ });
397
+ test('should allow get_project (non-mutator) with GITLAB_ALLOWED_PROJECT_IDS', async () => {
398
+ const result = await client.callTool('get_project', { project_id: '' });
399
+ assert.ok(result.content, 'Should have content');
400
+ const content = result.content[0];
401
+ assert.ok('text' in content, 'Content should have text');
402
+ const project = JSON.parse(content.text);
403
+ assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use default project');
404
+ });
405
+ });
406
+ describe('GITLAB_READ_ONLY_MODE enforces read-only for all write tools', () => {
407
+ let mcpUrl;
408
+ let mockGitLab;
409
+ let servers = [];
410
+ let client;
411
+ before(async () => {
412
+ const mockPort = await findMockServerPort(9500);
413
+ mockGitLab = new MockGitLabServer({
414
+ port: mockPort,
415
+ validTokens: [MOCK_TOKEN]
416
+ });
417
+ await mockGitLab.start();
418
+ const mockGitLabUrl = mockGitLab.getUrl();
419
+ const mcpPort = await findAvailablePort(3500);
420
+ const server = await launchServer({
421
+ mode: TransportMode.STREAMABLE_HTTP,
422
+ port: mcpPort,
423
+ timeout: 5000,
424
+ env: {
425
+ REMOTE_AUTHORIZATION: 'true',
426
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
427
+ GITLAB_READ_ONLY_MODE: 'true',
428
+ }
429
+ });
430
+ servers.push(server);
431
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
432
+ client = new CustomHeaderClient({
433
+ authorization: `Bearer ${MOCK_TOKEN}`,
434
+ });
435
+ await client.connect(mcpUrl);
436
+ });
437
+ after(async () => {
438
+ if (client)
439
+ await client.disconnect();
440
+ cleanupServers(servers);
441
+ if (mockGitLab)
442
+ await mockGitLab.stop();
443
+ });
444
+ test('should reject create_group in read-only mode (no project ID)', async () => {
445
+ try {
446
+ await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
447
+ assert.fail('Should have rejected create_group in read-only mode');
448
+ }
449
+ catch (error) {
450
+ assert.ok(error instanceof Error);
451
+ assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
452
+ }
453
+ });
454
+ test('should reject create_repository in read-only mode', async () => {
455
+ try {
456
+ await client.callTool('create_repository', { name: 'test-repo' });
457
+ assert.fail('Should have rejected create_repository in read-only mode');
458
+ }
459
+ catch (error) {
460
+ assert.ok(error instanceof Error);
461
+ assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
462
+ }
463
+ });
464
+ test('should allow get_project (read-only) in read-only mode', async () => {
465
+ const result = await client.callTool('get_project', { project_id: DEFAULT_PROJECT_ID });
466
+ assert.ok(result.content, 'Should have content');
467
+ });
468
+ });
245
469
  }); // end wrapper describe