@zereight/mcp-gitlab 2.1.69 → 2.1.70

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.
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import http from "node:http";
3
+ import { describe, test } from "node:test";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { getDefaultEnvironment, StdioClientTransport, } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ const MASKED_IP = "10.20.30.40";
7
+ async function startServer(t) {
8
+ const api = http.createServer((req, res) => {
9
+ res.setHeader("Content-Type", "application/json");
10
+ if (req.url === "/api/v4/namespaces/missing") {
11
+ res.statusCode = 404;
12
+ res.end(JSON.stringify({ message: "Namespace not found", host: MASKED_IP }));
13
+ return;
14
+ }
15
+ if (req.url === "/api/v4/groups/10/members") {
16
+ res.statusCode = 403;
17
+ res.end(JSON.stringify({ message: "Forbidden" }));
18
+ return;
19
+ }
20
+ if (req.method === "POST" && req.url === "/api/v4/projects/123/merge_requests") {
21
+ res.statusCode = 400;
22
+ res.end(JSON.stringify({ message: "Invalid merge request" }));
23
+ return;
24
+ }
25
+ if (req.url === "/api/v4/namespaces/core/exists") {
26
+ res.end(JSON.stringify({ exists: true, suggests: [] }));
27
+ return;
28
+ }
29
+ res.statusCode = 404;
30
+ res.end(JSON.stringify({ message: "Not found" }));
31
+ });
32
+ await new Promise((resolve, reject) => {
33
+ api.once("error", reject);
34
+ api.listen(0, "127.0.0.1", resolve);
35
+ });
36
+ const apiUrl = `http://127.0.0.1:${api.address().port}/api/v4`;
37
+ const client = new Client({ name: "gitlab-api-tool-error-test", version: "1.0.0" });
38
+ const transport = new StdioClientTransport({
39
+ command: process.execPath,
40
+ args: ["build/index.js"],
41
+ stderr: "pipe",
42
+ env: {
43
+ ...getDefaultEnvironment(),
44
+ GITLAB_PERSONAL_ACCESS_TOKEN: "test-token",
45
+ GITLAB_API_URL: apiUrl,
46
+ GITLAB_PERMISSION_MODE: "modify",
47
+ GITLAB_TOOLSETS: "projects,merge_requests",
48
+ GITLAB_MASKING_ENABLED: "true",
49
+ GITLAB_DISABLE_VERSION_CHECK: "true",
50
+ LOG_LEVEL: "error",
51
+ },
52
+ });
53
+ transport.stderr?.on("data", () => undefined);
54
+ t.after(async () => {
55
+ try {
56
+ await client.close();
57
+ }
58
+ finally {
59
+ api.closeAllConnections();
60
+ await new Promise(resolve => api.close(() => resolve()));
61
+ }
62
+ });
63
+ await client.connect(transport);
64
+ return client;
65
+ }
66
+ function resultText(result) {
67
+ assert.ok("content" in result && Array.isArray(result.content), "expected tool content");
68
+ const block = result.content.find((item) => typeof item === "object" &&
69
+ item !== null &&
70
+ "type" in item &&
71
+ item.type === "text" &&
72
+ "text" in item &&
73
+ typeof item.text === "string");
74
+ assert.ok(block, "expected a text tool result");
75
+ return block.text;
76
+ }
77
+ describe("GitLab API tool errors", () => {
78
+ test("returns HTTP failures as tool errors and keeps the MCP connection usable", async (t) => {
79
+ const client = await startServer(t);
80
+ const notFound = await client.callTool({
81
+ name: "get_namespace",
82
+ arguments: { namespace_id: "missing" },
83
+ });
84
+ assert.equal(notFound.isError, true);
85
+ assert.match(resultText(notFound), /GitLab API error: 404 Not Found/);
86
+ assert.doesNotMatch(resultText(notFound), new RegExp(MASKED_IP.replaceAll(".", "\\.")));
87
+ assert.match(resultText(notFound), /\[IP address\]/);
88
+ const forbidden = await client.callTool({
89
+ name: "list_group_members",
90
+ arguments: { group_id: "10" },
91
+ });
92
+ assert.equal(forbidden.isError, true);
93
+ assert.match(resultText(forbidden), /GitLab API error: 403 Forbidden/);
94
+ const invalidMergeRequest = await client.callTool({
95
+ name: "create_merge_request",
96
+ arguments: {
97
+ project_id: "123",
98
+ title: "Test MR",
99
+ source_branch: "feature",
100
+ target_branch: "main",
101
+ },
102
+ });
103
+ assert.equal(invalidMergeRequest.isError, true);
104
+ assert.match(resultText(invalidMergeRequest), /Invalid request/);
105
+ const recovery = await client.callTool({
106
+ name: "verify_namespace",
107
+ arguments: { path: "core" },
108
+ });
109
+ assert.notEqual(recovery.isError, true);
110
+ assert.deepEqual(JSON.parse(resultText(recovery)), { exists: true, suggests: [] });
111
+ });
112
+ });
@@ -0,0 +1,157 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as net from "node:net";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import * as url from "node:url";
8
+ import { describe, it } from "node:test";
9
+ // The token key is derived at module load, so the secret must be set before importing it.
10
+ const DOWNLOAD_TOKEN_SECRET = "oauth-download-test-secret";
11
+ process.env.DOWNLOAD_TOKEN_SECRET = DOWNLOAD_TOKEN_SECRET;
12
+ const { decryptDownloadToken } = await import("../utils/download-token.js");
13
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
14
+ const SERVER_ENTRY = path.resolve(__dirname, "../build/index.js");
15
+ const HOST = "127.0.0.1";
16
+ const OAUTH_TOKEN = "oauth-download-test";
17
+ const MCP_ACCESS_TOKEN = "mcp-access-test";
18
+ const STARTUP_TIMEOUT_MS = 15_000;
19
+ const MCP_HEADERS = {
20
+ "Content-Type": "application/json",
21
+ Accept: "application/json, text/event-stream",
22
+ Authorization: `Bearer ${MCP_ACCESS_TOKEN}`,
23
+ };
24
+ function getFreePortAsync() {
25
+ return new Promise((resolve, reject) => {
26
+ const probe = net.createServer();
27
+ probe.on("error", reject);
28
+ probe.listen(0, HOST, () => {
29
+ const address = probe.address();
30
+ if (address === null || typeof address === "string") {
31
+ probe.close();
32
+ reject(new Error("probe server is not listening on a TCP port"));
33
+ return;
34
+ }
35
+ probe.close(() => resolve(address.port));
36
+ });
37
+ });
38
+ }
39
+ function buildServerEnv(port, tmpDir) {
40
+ const tokenPath = path.join(tmpDir, "token.json");
41
+ fs.writeFileSync(tokenPath, JSON.stringify({
42
+ access_token: OAUTH_TOKEN,
43
+ refresh_token: "refresh",
44
+ expires_in: 7200,
45
+ created_at: Date.now(),
46
+ token_type: "Bearer",
47
+ }));
48
+ const env = {};
49
+ for (const [key, value] of Object.entries(process.env)) {
50
+ if (value !== undefined && !key.startsWith("GITLAB_")) {
51
+ env[key] = value;
52
+ }
53
+ }
54
+ return {
55
+ ...env,
56
+ STREAMABLE_HTTP: "true",
57
+ HOST,
58
+ PORT: String(port),
59
+ MCP_SERVER_URL: "",
60
+ GITLAB_API_URL: `http://${HOST}:1/api/v4`,
61
+ GITLAB_USE_OAUTH: "true",
62
+ GITLAB_OAUTH_CLIENT_ID: "client-id",
63
+ GITLAB_OAUTH_TOKEN_PATH: tokenPath,
64
+ GITLAB_IS_OLD: "true",
65
+ USE_PIPELINE: "true",
66
+ DOWNLOAD_TOKEN_SECRET,
67
+ STREAMABLE_HTTP_AUTH_TOKEN: MCP_ACCESS_TOKEN,
68
+ };
69
+ }
70
+ async function waitForServerAsync(port, child) {
71
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
72
+ while (Date.now() < deadline) {
73
+ if (child.exitCode !== null) {
74
+ throw new Error(`server exited early with code ${child.exitCode}`);
75
+ }
76
+ const reachable = await new Promise(resolve => {
77
+ const socket = net.connect(port, HOST);
78
+ socket.once("connect", () => {
79
+ socket.destroy();
80
+ resolve(true);
81
+ });
82
+ socket.once("error", () => resolve(false));
83
+ });
84
+ if (reachable) {
85
+ return;
86
+ }
87
+ await new Promise(resolve => setTimeout(resolve, 100));
88
+ }
89
+ throw new Error("server did not start listening in time");
90
+ }
91
+ async function postMcpAsync(port, body, sessionId) {
92
+ return fetch(`http://${HOST}:${port}/mcp`, {
93
+ method: "POST",
94
+ headers: sessionId ? { ...MCP_HEADERS, "mcp-session-id": sessionId } : MCP_HEADERS,
95
+ body: JSON.stringify(body),
96
+ });
97
+ }
98
+ function readJsonRpcResult(sseBody) {
99
+ const dataLine = sseBody.split("\n").find(line => line.startsWith("data: "));
100
+ if (dataLine === undefined) {
101
+ throw new Error(`no SSE data line in response: ${sseBody}`);
102
+ }
103
+ return JSON.parse(dataLine.slice("data: ".length));
104
+ }
105
+ function readDownloadUrl(rpc) {
106
+ const text = JSON.parse(JSON.stringify(rpc)).result?.content?.[0]?.text;
107
+ if (typeof text !== "string") {
108
+ throw new Error(`unexpected tool response: ${JSON.stringify(rpc)}`);
109
+ }
110
+ const downloadUrl = JSON.parse(text).download_url;
111
+ if (typeof downloadUrl !== "string") {
112
+ throw new Error(`no download_url in tool response: ${text}`);
113
+ }
114
+ return downloadUrl;
115
+ }
116
+ async function requestDownloadUrlAsync() {
117
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-oauth-download-"));
118
+ const port = await getFreePortAsync();
119
+ const env = buildServerEnv(port, tmpDir);
120
+ const child = spawn(process.execPath, [SERVER_ENTRY], { env, stdio: "ignore" });
121
+ try {
122
+ await waitForServerAsync(port, child);
123
+ const init = await postMcpAsync(port, {
124
+ jsonrpc: "2.0",
125
+ id: 1,
126
+ method: "initialize",
127
+ params: {
128
+ protocolVersion: "2025-03-26",
129
+ capabilities: {},
130
+ clientInfo: { name: "oauth-download-url-test", version: "1.0" },
131
+ },
132
+ });
133
+ const sessionId = init.headers.get("mcp-session-id") ?? undefined;
134
+ const call = await postMcpAsync(port, {
135
+ jsonrpc: "2.0",
136
+ id: 2,
137
+ method: "tools/call",
138
+ params: { name: "download_job_artifacts", arguments: { project_id: "g/p", job_id: "1" } },
139
+ }, sessionId);
140
+ return readDownloadUrl(readJsonRpcResult(await call.text()));
141
+ }
142
+ finally {
143
+ child.kill("SIGKILL");
144
+ fs.rmSync(tmpDir, { recursive: true, force: true });
145
+ }
146
+ }
147
+ describe("When a remote server builds a download URL with a stored OAuth token", () => {
148
+ describe("with GITLAB_IS_OLD enabled", () => {
149
+ it("should embed the token as a bearer credential", async () => {
150
+ const downloadUrl = await requestDownloadUrlAsync();
151
+ const embedded = new URL(downloadUrl).searchParams.get("_token");
152
+ const decrypted = embedded === null ? null : decryptDownloadToken(embedded);
153
+ assert.equal(decrypted?.header, "Authorization");
154
+ assert.equal(decrypted?.token, `Bearer ${OAUTH_TOKEN}`);
155
+ });
156
+ });
157
+ });
@@ -260,35 +260,27 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
260
260
  });
261
261
  test('should not fall back from bare numeric project IDs to groups', async () => {
262
262
  group789Requested = false;
263
- let didThrow = false;
264
- try {
265
- await client.callTool('list_work_items', {
266
- project_id: '789',
267
- });
268
- }
269
- catch (error) {
270
- didThrow = true;
271
- assert.ok(error instanceof Error);
272
- assert.ok(error.message.includes('404'), 'Should surface the project 404');
273
- }
274
- assert.ok(didThrow, 'Should have failed on the project 404');
263
+ const result = await client.callTool('list_work_items', {
264
+ project_id: '789',
265
+ });
266
+ assert.strictEqual(result.isError, true, 'Should return the project 404 as a tool error');
267
+ assert.ok(result.content, 'Should have error content');
268
+ const content = result.content[0];
269
+ assert.ok('text' in content, 'Error content should have text');
270
+ assert.ok(content.text.includes('404'), 'Should surface the project 404');
275
271
  assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
276
272
  console.log(' ✓ Bare numeric ID 789 did not fall back to /groups/789');
277
273
  });
278
274
  test('should not fall back from explicit numeric project IDs to groups', async () => {
279
275
  group789Requested = false;
280
- let didThrow = false;
281
- try {
282
- await client.callTool('list_work_items', {
283
- project_id: 'project:789',
284
- });
285
- }
286
- catch (error) {
287
- didThrow = true;
288
- assert.ok(error instanceof Error);
289
- assert.ok(error.message.includes('404'), 'Should surface the project 404');
290
- }
291
- assert.ok(didThrow, 'Should have failed on the project 404');
276
+ const result = await client.callTool('list_work_items', {
277
+ project_id: 'project:789',
278
+ });
279
+ assert.strictEqual(result.isError, true, 'Should return the project 404 as a tool error');
280
+ assert.ok(result.content, 'Should have error content');
281
+ const content = result.content[0];
282
+ assert.ok('text' in content, 'Error content should have text');
283
+ assert.ok(content.text.includes('404'), 'Should surface the project 404');
292
284
  assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
293
285
  console.log(' ✓ Explicit project:789 did not fall back to /groups/789');
294
286
  });
@@ -255,7 +255,9 @@ describe("Pipeline schedule tools", () => {
255
255
  assert.strictEqual(result.inputs[0].name, "deploy_strategy");
256
256
  });
257
257
  test("get_pipeline_schedule surfaces a 404 as an error", async () => {
258
- await assert.rejects(() => callToolJson("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: MISSING_SCHEDULE_ID }, baseEnv), /404/);
258
+ const result = await callTool("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: MISSING_SCHEDULE_ID }, baseEnv);
259
+ assert.strictEqual(result.isError, true);
260
+ assert.match(result.content?.[0]?.text ?? "", /404/);
259
261
  });
260
262
  test("list_pipeline_schedule_pipelines returns the triggered pipelines", async () => {
261
263
  const result = await callToolJson("list_pipeline_schedule_pipelines", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
@@ -1,6 +1,19 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
- import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "../../utils/url.js";
3
+ import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "../../utils/url.js";
4
+ describe("When isCleartextRemoteUrl runs", () => {
5
+ test("should flag http to a remote host", () => {
6
+ assert.equal(isCleartextRemoteUrl("http://gitlab.example.com/api/v4"), true);
7
+ });
8
+ test("should allow https", () => {
9
+ assert.equal(isCleartextRemoteUrl("https://gitlab.example.com/api/v4"), false);
10
+ });
11
+ test("should allow http to loopback hosts", () => {
12
+ assert.equal(isCleartextRemoteUrl("http://127.0.0.1:8080/api/v4"), false);
13
+ assert.equal(isCleartextRemoteUrl("http://localhost/api/v4"), false);
14
+ assert.equal(isCleartextRemoteUrl("http://[::1]:3000/api/v4"), false);
15
+ });
16
+ });
4
17
  describe("When normalizeGitLabApiUrl runs", () => {
5
18
  test("should default to gitlab.com", () => {
6
19
  assert.equal(normalizeGitLabApiUrl(""), "https://gitlab.com/api/v4");
@@ -17,6 +17,21 @@ export function normalizeGitLabApiUrl(url) {
17
17
  }
18
18
  return normalizedUrl;
19
19
  }
20
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
21
+ /**
22
+ * True when the URL is plain `http://` to a non-loopback host, i.e. credentials sent
23
+ * to it would cross the network in cleartext. Unparseable URLs return false (they are
24
+ * rejected by the config validation instead).
25
+ */
26
+ export function isCleartextRemoteUrl(url) {
27
+ try {
28
+ const parsed = new URL(url);
29
+ return parsed.protocol === "http:" && !LOOPBACK_HOSTNAMES.has(parsed.hostname);
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
20
35
  /**
21
36
  * Dot segments anywhere in a decoded value, including after a percent-encoded
22
37
  * separator ("..%2F..%2Fuser" decodes to a path-traversal sequence).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.69",
3
+ "version": "2.1.70",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [