@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.
- package/README.ko.md +25 -0
- package/README.md +150 -98
- package/README.zh-CN.md +25 -0
- package/build/index.js +183 -9
- package/build/schemas.js +160 -0
- package/build/test/schema-tests.js +228 -3
- package/build/test/streamable-http-static-token-auth.test.js +154 -0
- package/build/test/test-ci-lint.js +191 -0
- package/build/test/test-todos.js +195 -0
- package/build/test/test-toolset-filtering.js +13 -6
- package/build/tools/registry.js +52 -1
- package/package.json +2 -2
|
@@ -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
|
+
});
|