@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/README.md +152 -151
- package/build/index.js +443 -25
- package/build/schemas.js +91 -0
- package/build/test/test-get-file-blame.js +145 -0
- package/build/test/test-geteffectiveprojectid.js +230 -6
- package/build/test/test-remote-downloads.js +336 -0
- package/build/test/test-toolset-filtering.js +4 -1
- package/build/tools/registry.js +43 -11
- package/package.json +2 -2
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|