@zereight/mcp-gitlab 2.1.7 → 2.1.9

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ts-node
2
- import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, GitLabTreeItemSchema, GetMergeRequestSchema, GetRepositoryTreeSchema } from '../schemas.js';
2
+ import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, GetRepositoryTreeSchema } from '../schemas.js';
3
3
  function runGetFileContentsSchemaTests() {
4
4
  console.log('🧪 Testing GetFileContentsSchema...');
5
5
  const cases = [
@@ -298,6 +298,142 @@ function runCreatePipelineSchemaTests() {
298
298
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
299
299
  return { passed, failed };
300
300
  }
301
+ function runCommitStatusSchemaTests() {
302
+ console.log('\n🧪 Testing Commit Status Schemas...');
303
+ const cases = [
304
+ {
305
+ name: 'schema:create_commit_status:minimal-required-fields',
306
+ schema: CreateCommitStatusSchema,
307
+ input: { project_id: 'my/project', sha: 'abc123', state: 'success' },
308
+ expected: { project_id: 'my/project', sha: 'abc123', state: 'success' }
309
+ },
310
+ {
311
+ name: 'schema:create_commit_status:with-optional-fields',
312
+ schema: CreateCommitStatusSchema,
313
+ input: {
314
+ project_id: 'my/project',
315
+ sha: 'abc123',
316
+ state: 'failed',
317
+ name: 'external/check',
318
+ target_url: 'https://ci.example.com/build/1',
319
+ description: 'External check failed',
320
+ coverage: '87.5',
321
+ pipeline_id: '42'
322
+ },
323
+ expected: {
324
+ project_id: 'my/project',
325
+ sha: 'abc123',
326
+ state: 'failed',
327
+ name: 'external/check',
328
+ target_url: 'https://ci.example.com/build/1',
329
+ description: 'External check failed',
330
+ coverage: 87.5,
331
+ pipeline_id: 42
332
+ }
333
+ },
334
+ {
335
+ name: 'schema:create_commit_status:context-alias',
336
+ schema: CreateCommitStatusSchema,
337
+ input: { project_id: 123, sha: 'abc123', state: 'pending', context: 'external/check' },
338
+ expected: { project_id: '123', sha: 'abc123', state: 'pending', context: 'external/check' }
339
+ },
340
+ {
341
+ name: 'schema:create_commit_status:reject-invalid-state',
342
+ schema: CreateCommitStatusSchema,
343
+ input: { project_id: 'my/project', sha: 'abc123', state: 'passing' },
344
+ shouldFail: true
345
+ },
346
+ {
347
+ name: 'schema:create_commit_status:reject-missing-state',
348
+ schema: CreateCommitStatusSchema,
349
+ input: { project_id: 'my/project', sha: 'abc123' },
350
+ shouldFail: true
351
+ },
352
+ {
353
+ name: 'schema:list_commit_statuses:filters',
354
+ schema: ListCommitStatusesSchema,
355
+ input: {
356
+ project_id: 'my/project',
357
+ sha: 'abc123',
358
+ ref: 'main',
359
+ name: 'external/check',
360
+ pipeline_id: '42',
361
+ order_by: 'pipeline_id',
362
+ sort: 'desc',
363
+ all: 'true',
364
+ page: '2',
365
+ per_page: '50'
366
+ },
367
+ expected: {
368
+ project_id: 'my/project',
369
+ sha: 'abc123',
370
+ ref: 'main',
371
+ name: 'external/check',
372
+ pipeline_id: 42,
373
+ order_by: 'pipeline_id',
374
+ sort: 'desc',
375
+ all: true,
376
+ page: 2,
377
+ per_page: 50
378
+ }
379
+ },
380
+ {
381
+ name: 'schema:list_commit_statuses:all-false-string',
382
+ schema: ListCommitStatusesSchema,
383
+ input: { project_id: 'my/project', sha: 'abc123', all: 'false' },
384
+ expected: { project_id: 'my/project', sha: 'abc123', all: false }
385
+ },
386
+ {
387
+ name: 'schema:list_commit_statuses:reject-invalid-all-string',
388
+ schema: ListCommitStatusesSchema,
389
+ input: { project_id: 'my/project', sha: 'abc123', all: 'yes' },
390
+ shouldFail: true
391
+ }
392
+ ];
393
+ let passed = 0;
394
+ let failed = 0;
395
+ cases.forEach(testCase => {
396
+ const result = {
397
+ name: testCase.name,
398
+ status: 'failed'
399
+ };
400
+ const parsed = testCase.schema.safeParse(testCase.input);
401
+ if (testCase.shouldFail) {
402
+ if (parsed.success) {
403
+ result.error = 'Expected schema validation to fail';
404
+ }
405
+ else {
406
+ result.status = 'passed';
407
+ }
408
+ }
409
+ else if (parsed.success) {
410
+ const expected = testCase.expected || {};
411
+ const matches = Object.entries(expected).every(([key, value]) => {
412
+ const actual = parsed.data[key];
413
+ return JSON.stringify(actual) === JSON.stringify(value);
414
+ });
415
+ if (matches) {
416
+ result.status = 'passed';
417
+ }
418
+ else {
419
+ result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
420
+ }
421
+ }
422
+ else {
423
+ result.error = parsed.error?.message || 'Schema validation failed';
424
+ }
425
+ if (result.status === 'passed') {
426
+ passed++;
427
+ console.log(`✅ ${result.name}`);
428
+ }
429
+ else {
430
+ failed++;
431
+ console.log(`❌ ${result.name}: ${result.error}`);
432
+ }
433
+ });
434
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
435
+ return { passed, failed };
436
+ }
301
437
  function runCreateIssueNoteSchemaTests() {
302
438
  console.log('\n🧪 Testing CreateIssueNoteSchema...');
303
439
  const cases = [
@@ -448,6 +584,93 @@ function runGetMergeRequestSchemaTests() {
448
584
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
449
585
  return { passed, failed };
450
586
  }
587
+ function runGitLabMergeRequestSchemaTests() {
588
+ console.log('\n🧪 Testing GitLabMergeRequestSchema...');
589
+ const baseMergeRequest = {
590
+ id: '1001',
591
+ iid: '42',
592
+ project_id: '123',
593
+ title: 'Add milestone exposure',
594
+ description: 'Expose MR milestone',
595
+ state: 'opened',
596
+ author: {
597
+ id: '1',
598
+ username: 'octocat',
599
+ name: 'Octo Cat',
600
+ avatar_url: null,
601
+ web_url: 'https://gitlab.example.com/octocat',
602
+ },
603
+ assignees: [],
604
+ reviewers: [],
605
+ source_branch: 'feature/milestone',
606
+ target_branch: 'main',
607
+ web_url: 'https://gitlab.example.com/group/project/-/merge_requests/42',
608
+ created_at: '2026-05-07T00:00:00.000Z',
609
+ updated_at: '2026-05-07T00:00:00.000Z',
610
+ merged_at: null,
611
+ closed_at: null,
612
+ merge_commit_sha: null,
613
+ };
614
+ const cases = [
615
+ {
616
+ name: 'schema:gitlab_merge_request:preserves-milestone',
617
+ input: {
618
+ ...baseMergeRequest,
619
+ milestone: {
620
+ id: '5',
621
+ iid: '2',
622
+ title: 'v1.0',
623
+ description: 'Version 1.0',
624
+ state: 'active',
625
+ web_url: 'https://gitlab.example.com/group/project/-/milestones/2',
626
+ },
627
+ },
628
+ validate: (data) => data.milestone?.title === 'v1.0' &&
629
+ data.milestone?.id === '5' &&
630
+ data.milestone?.iid === '2',
631
+ },
632
+ {
633
+ name: 'schema:gitlab_merge_request:allows-null-milestone',
634
+ input: {
635
+ ...baseMergeRequest,
636
+ milestone: null,
637
+ },
638
+ validate: (data) => data.milestone === null,
639
+ },
640
+ {
641
+ name: 'schema:gitlab_merge_request:allows-omitted-milestone',
642
+ input: {
643
+ ...baseMergeRequest,
644
+ },
645
+ validate: (data) => data.milestone === undefined,
646
+ },
647
+ ];
648
+ let passed = 0;
649
+ let failed = 0;
650
+ cases.forEach(testCase => {
651
+ const result = { name: testCase.name, status: 'failed' };
652
+ const parsed = GitLabMergeRequestSchema.safeParse(testCase.input);
653
+ if (parsed.success && testCase.validate(parsed.data)) {
654
+ result.status = 'passed';
655
+ }
656
+ else if (parsed.success) {
657
+ result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
658
+ }
659
+ else {
660
+ result.error = parsed.error?.message || 'Schema validation failed';
661
+ }
662
+ if (result.status === 'passed') {
663
+ passed++;
664
+ console.log(`✅ ${result.name}`);
665
+ }
666
+ else {
667
+ failed++;
668
+ console.log(`❌ ${result.name}: ${result.error}`);
669
+ }
670
+ });
671
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
672
+ return { passed, failed };
673
+ }
451
674
  function runEmojiReactionSchemaTests() {
452
675
  console.log('\n🧪 Testing Emoji Reaction Schemas...');
453
676
  const cases = [
@@ -778,15 +1001,17 @@ if (import.meta.url === `file://${process.argv[1]}`) {
778
1001
  const getFileContentsResult = runGetFileContentsSchemaTests();
779
1002
  const fileContentResult = runGitLabFileContentSchemaTests();
780
1003
  const createPipelineResult = runCreatePipelineSchemaTests();
1004
+ const commitStatusResult = runCommitStatusSchemaTests();
781
1005
  const createIssueNoteResult = runCreateIssueNoteSchemaTests();
782
1006
  const getMergeRequestResult = runGetMergeRequestSchemaTests();
1007
+ const gitLabMergeRequestResult = runGitLabMergeRequestSchemaTests();
783
1008
  const emojiReactionResult = runEmojiReactionSchemaTests();
784
1009
  const repositorySchemaResult = runGitLabRepositorySchemaTests();
785
1010
  const labelsCoercionResult = runLabelsCoercionSchemaTests();
786
1011
  const treeItemResult = runGitLabTreeItemSchemaTests();
787
1012
  const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
788
- const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + treeItemResult.passed + repositoryTreeResult.passed;
789
- const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + treeItemResult.failed + repositoryTreeResult.failed;
1013
+ const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + treeItemResult.passed + repositoryTreeResult.passed;
1014
+ const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + treeItemResult.failed + repositoryTreeResult.failed;
790
1015
  console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
791
1016
  if (totalFailed > 0) {
792
1017
  process.exit(1);
@@ -0,0 +1,154 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { afterEach, describe, test } from "node:test";
5
+ import * as path from "node:path";
6
+ import { findAvailablePort } from "./utils/server-launcher.js";
7
+ const ERROR_MESSAGE = "STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token or GITLAB_JOB_TOKEN/--job-token requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true";
8
+ const HOST = process.env.HOST || "127.0.0.1";
9
+ const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
10
+ const running = new Set();
11
+ function startServer(env, port) {
12
+ const child = spawn("node", [SERVER_PATH], {
13
+ env: {
14
+ ...process.env,
15
+ GITLAB_API_URL: "https://gitlab.example.com",
16
+ HOST,
17
+ PORT: String(port),
18
+ STREAMABLE_HTTP: "true",
19
+ REMOTE_AUTHORIZATION: "false",
20
+ GITLAB_MCP_OAUTH: "false",
21
+ GITLAB_USE_OAUTH: "false",
22
+ GITLAB_PERSONAL_ACCESS_TOKEN: "",
23
+ GITLAB_JOB_TOKEN: "",
24
+ GITLAB_AUTH_COOKIE_PATH: "",
25
+ MCP_SERVER_URL: "",
26
+ GITLAB_OAUTH_APP_ID: "",
27
+ ...env,
28
+ },
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ });
31
+ running.add(child);
32
+ child.once("exit", () => running.delete(child));
33
+ return child;
34
+ }
35
+ async function waitForExit(child, timeoutMs = 5000) {
36
+ let output = "";
37
+ child.stdout?.on("data", chunk => {
38
+ output += chunk.toString();
39
+ });
40
+ child.stderr?.on("data", chunk => {
41
+ output += chunk.toString();
42
+ });
43
+ let timeoutHandle;
44
+ const timeout = new Promise((_, reject) => {
45
+ timeoutHandle = setTimeout(() => reject(new Error(`server did not exit within ${timeoutMs}ms`)), timeoutMs);
46
+ });
47
+ try {
48
+ const [code] = (await Promise.race([once(child, "exit"), timeout]));
49
+ return { code, output };
50
+ }
51
+ finally {
52
+ clearTimeout(timeoutHandle);
53
+ }
54
+ }
55
+ async function waitForHealth(port, timeoutMs = 5000) {
56
+ const deadline = Date.now() + timeoutMs;
57
+ let lastError;
58
+ while (Date.now() < deadline) {
59
+ try {
60
+ const response = await fetch(`http://${HOST}:${port}/health`);
61
+ if (response.ok)
62
+ return;
63
+ }
64
+ catch (error) {
65
+ lastError = error;
66
+ }
67
+ await new Promise(resolve => setTimeout(resolve, 100));
68
+ }
69
+ throw new Error(`server did not become healthy: ${String(lastError)}`);
70
+ }
71
+ async function postMcpWithoutAuth(port) {
72
+ return fetch(`http://${HOST}:${port}/mcp`, {
73
+ method: "POST",
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ Accept: "application/json, text/event-stream",
77
+ },
78
+ body: JSON.stringify({
79
+ jsonrpc: "2.0",
80
+ id: 1,
81
+ method: "initialize",
82
+ params: {
83
+ protocolVersion: "2025-03-26",
84
+ capabilities: {},
85
+ clientInfo: { name: "static-token-auth-test", version: "1.0.0" },
86
+ },
87
+ }),
88
+ });
89
+ }
90
+ async function postMcpWithSessionWithoutAuth(port, sessionId) {
91
+ return fetch(`http://${HOST}:${port}/mcp`, {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ Accept: "application/json, text/event-stream",
96
+ "Mcp-Session-Id": sessionId,
97
+ },
98
+ body: JSON.stringify({
99
+ jsonrpc: "2.0",
100
+ id: 2,
101
+ method: "tools/list",
102
+ params: {},
103
+ }),
104
+ });
105
+ }
106
+ afterEach(() => {
107
+ for (const child of running) {
108
+ if (!child.killed)
109
+ child.kill("SIGTERM");
110
+ }
111
+ running.clear();
112
+ });
113
+ describe("Streamable HTTP static server token auth", () => {
114
+ test("refuses startup with PAT and no MCP-layer auth", async () => {
115
+ const port = await findAvailablePort(4300);
116
+ const child = startServer({ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test" }, port);
117
+ const { code, output } = await waitForExit(child);
118
+ assert.notEqual(code, 0);
119
+ assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
120
+ });
121
+ test("refuses startup with JOB-TOKEN and no MCP-layer auth", async () => {
122
+ const port = await findAvailablePort(4310);
123
+ const child = startServer({ GITLAB_JOB_TOKEN: "job_test" }, port);
124
+ const { code, output } = await waitForExit(child);
125
+ assert.notEqual(code, 0);
126
+ assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
127
+ });
128
+ test("allows startup with PAT when REMOTE_AUTHORIZATION is enabled", async () => {
129
+ const port = await findAvailablePort(4320);
130
+ startServer({
131
+ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
132
+ REMOTE_AUTHORIZATION: "true",
133
+ }, port);
134
+ await waitForHealth(port);
135
+ const initResponse = await postMcpWithoutAuth(port);
136
+ assert.equal(initResponse.status, 200);
137
+ const sessionId = initResponse.headers.get("mcp-session-id");
138
+ assert.ok(sessionId);
139
+ const followUpResponse = await postMcpWithSessionWithoutAuth(port, sessionId);
140
+ assert.equal(followUpResponse.status, 401);
141
+ });
142
+ test("allows startup with PAT when GITLAB_MCP_OAUTH is enabled", async () => {
143
+ const port = await findAvailablePort(4330);
144
+ startServer({
145
+ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
146
+ GITLAB_MCP_OAUTH: "true",
147
+ MCP_SERVER_URL: `http://${HOST}:${port}`,
148
+ GITLAB_OAUTH_APP_ID: "oauth-app-id",
149
+ }, port);
150
+ await waitForHealth(port);
151
+ const response = await postMcpWithoutAuth(port);
152
+ assert.equal(response.status, 401);
153
+ });
154
+ });
@@ -0,0 +1,191 @@
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-ci-lint-test-token";
6
+ const TEST_PROJECT_ID = "123";
7
+ async function callTool(toolName, args, env) {
8
+ return new Promise((resolve, reject) => {
9
+ const proc = spawn("node", ["build/index.js"], {
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ env: {
12
+ ...process.env,
13
+ ...env,
14
+ USE_PIPELINE: "true",
15
+ },
16
+ });
17
+ let output = "";
18
+ let errorOutput = "";
19
+ proc.stdout?.on("data", (d) => (output += d));
20
+ proc.stderr?.on("data", (d) => (errorOutput += d));
21
+ proc.on("close", code => {
22
+ if (code !== 0) {
23
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
24
+ }
25
+ const line = output.split("\n").find(l => l.startsWith("{"));
26
+ if (!line)
27
+ return reject(new Error("No JSON output found"));
28
+ try {
29
+ const response = JSON.parse(line);
30
+ if (response.error) {
31
+ reject(response.error);
32
+ }
33
+ else {
34
+ const content = response.result?.content?.[0]?.text;
35
+ resolve(content ? JSON.parse(content) : response.result);
36
+ }
37
+ }
38
+ catch (e) {
39
+ reject(e);
40
+ }
41
+ });
42
+ proc.stdin?.end(JSON.stringify({
43
+ jsonrpc: "2.0",
44
+ id: 1,
45
+ method: "tools/call",
46
+ params: { name: toolName, arguments: args },
47
+ }) + "\n");
48
+ });
49
+ }
50
+ async function listToolNames(env) {
51
+ return new Promise((resolve, reject) => {
52
+ const proc = spawn("node", ["build/index.js"], {
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ env: {
55
+ ...process.env,
56
+ ...env,
57
+ },
58
+ });
59
+ let output = "";
60
+ let errorOutput = "";
61
+ proc.stdout?.on("data", (d) => (output += d));
62
+ proc.stderr?.on("data", (d) => (errorOutput += d));
63
+ proc.on("close", code => {
64
+ if (code !== 0) {
65
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
66
+ }
67
+ const line = output.split("\n").find(l => l.startsWith("{"));
68
+ if (!line)
69
+ return reject(new Error("No JSON output found"));
70
+ try {
71
+ const response = JSON.parse(line);
72
+ if (response.error) {
73
+ reject(response.error);
74
+ }
75
+ else {
76
+ resolve(response.result.tools.map((tool) => tool.name));
77
+ }
78
+ }
79
+ catch (e) {
80
+ reject(e);
81
+ }
82
+ });
83
+ proc.stdin?.end(JSON.stringify({
84
+ jsonrpc: "2.0",
85
+ id: 1,
86
+ method: "tools/list",
87
+ params: {},
88
+ }) + "\n");
89
+ });
90
+ }
91
+ describe("GitLab CI lint tools", () => {
92
+ let mockGitLab;
93
+ let mockGitLabUrl;
94
+ before(async () => {
95
+ const mockPort = await findMockServerPort(9260);
96
+ mockGitLab = new MockGitLabServer({
97
+ port: mockPort,
98
+ validTokens: [MOCK_TOKEN],
99
+ });
100
+ mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/ci/lint`, (req, res) => {
101
+ assert.strictEqual(req.body.content, "stages: [test]\ntest:\n script: echo ok");
102
+ assert.strictEqual(req.body.dry_run, true);
103
+ assert.strictEqual(req.body.include_jobs, true);
104
+ assert.strictEqual(req.body.ref, "main");
105
+ res.json({
106
+ valid: true,
107
+ errors: [],
108
+ warnings: [],
109
+ merged_yaml: "test:\n script: echo ok\n",
110
+ includes: [],
111
+ jobs: [{ name: "test", stage: "test" }],
112
+ });
113
+ });
114
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/ci/lint`, (req, res) => {
115
+ assert.strictEqual(req.query.content_ref, "feature/test");
116
+ assert.strictEqual(req.query.dry_run, "true");
117
+ assert.strictEqual(req.query.dry_run_ref, "main");
118
+ assert.strictEqual(req.query.include_jobs, "true");
119
+ res.json({
120
+ valid: true,
121
+ errors: [],
122
+ warnings: [],
123
+ merged_yaml: "include-job:\n script: echo included\n",
124
+ includes: [{ type: "local", location: "include.yml" }],
125
+ jobs: [{ name: "include-job", stage: "test" }],
126
+ });
127
+ });
128
+ await mockGitLab.start();
129
+ mockGitLabUrl = mockGitLab.getUrl();
130
+ });
131
+ after(async () => {
132
+ await mockGitLab.stop();
133
+ });
134
+ test("validate_ci_lint posts CI YAML content and returns lint result", async () => {
135
+ const result = await callTool("validate_ci_lint", {
136
+ project_id: TEST_PROJECT_ID,
137
+ content: "stages: [test]\ntest:\n script: echo ok",
138
+ dry_run: true,
139
+ include_jobs: true,
140
+ ref: "main",
141
+ }, {
142
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
143
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
144
+ });
145
+ assert.strictEqual(result.valid, true);
146
+ assert.deepStrictEqual(result.errors, []);
147
+ assert.strictEqual(result.jobs[0].name, "test");
148
+ });
149
+ test("validate_ci_lint surfaces invalid lint responses", async () => {
150
+ mockGitLab.addMockHandler("post", `/projects/999/ci/lint`, (req, res) => {
151
+ res.json({
152
+ valid: false,
153
+ errors: ["jobs config should contain at least one visible job"],
154
+ warnings: [],
155
+ });
156
+ });
157
+ const result = await callTool("validate_ci_lint", {
158
+ project_id: "999",
159
+ content: ".hidden:\n script: echo hidden",
160
+ }, {
161
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
162
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
163
+ });
164
+ assert.strictEqual(result.valid, false);
165
+ assert.deepStrictEqual(result.errors, ["jobs config should contain at least one visible job"]);
166
+ });
167
+ test("validate_project_ci_lint sends GET query parameters", async () => {
168
+ const result = await callTool("validate_project_ci_lint", {
169
+ project_id: TEST_PROJECT_ID,
170
+ content_ref: "feature/test",
171
+ dry_run: true,
172
+ dry_run_ref: "main",
173
+ include_jobs: true,
174
+ }, {
175
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
176
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
177
+ });
178
+ assert.strictEqual(result.valid, true);
179
+ assert.strictEqual(result.includes[0].location, "include.yml");
180
+ assert.strictEqual(result.jobs[0].name, "include-job");
181
+ });
182
+ test("CI lint tools are visible by default in read-only mode", async () => {
183
+ const tools = await listToolNames({
184
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
185
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
186
+ GITLAB_READ_ONLY_MODE: "true",
187
+ });
188
+ assert.ok(tools.includes("validate_ci_lint"));
189
+ assert.ok(tools.includes("validate_project_ci_lint"));
190
+ });
191
+ });