@zereight/mcp-gitlab 2.1.53 → 2.1.55

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 CHANGED
@@ -98,7 +98,7 @@ npm install -g @zereight/mcp-gitlab
98
98
 
99
99
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
100
100
 
101
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.52`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
101
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.54`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
102
102
 
103
103
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
104
104
 
package/README.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # GitLab MCP Server
2
2
 
3
+ [![GitHub stars](https://img.shields.io/github/stars/zereight/gitlab-mcp.svg?style=social&label=Star)](https://github.com/zereight/gitlab-mcp)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@zereight/mcp-gitlab.svg)](https://www.npmjs.com/package/@zereight/mcp-gitlab)
3
5
  [![npm](https://img.shields.io/npm/v/@zereight/mcp-gitlab.svg)](https://www.npmjs.com/package/@zereight/mcp-gitlab)
6
+ [![GitHub License](https://img.shields.io/github/license/zereight/gitlab-mcp.svg)](https://github.com/zereight/gitlab-mcp/blob/main/LICENSE)
4
7
  [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_GitLab_MCP-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22zereight.gitlab-mcp%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40zereight%2Fmcp-gitlab%40latest%22%5D%2C%22env%22%3A%7B%22GITLAB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agitlab-token%7D%22%2C%22GITLAB_API_URL%22%3A%22https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%22%2C%22GITLAB_PERMISSION_MODE%22%3A%22full%22%7D%7D)
8
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zereight/gitlab-mcp)
5
9
  [![MCP Toplist](https://mcptoplist.com/badge/io.github.zereight%2Fgitlab-mcp.svg)](https://mcptoplist.com/server/io.github.zereight%2Fgitlab-mcp) [![mcpindex](https://mcpindex.ai/api/v1/badge/io-github-zereight-gitlab-mcp)](https://mcpindex.ai/server/io-github-zereight-gitlab-mcp)
6
10
 
7
11
  [English](./README.md) | [한국어](./README.ko.md) | [简体中文](./README.zh-CN.md)
@@ -99,7 +103,7 @@ npm install -g @zereight/mcp-gitlab
99
103
 
100
104
  The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
101
105
 
102
- No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.52`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
106
+ No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.54`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
103
107
 
104
108
  #### Using CLI Arguments (for clients with env var issues)
105
109
 
package/README.zh-CN.md CHANGED
@@ -98,7 +98,7 @@ npm install -g @zereight/mcp-gitlab
98
98
 
99
99
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
100
100
 
101
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.52`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
101
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.54`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
102
102
 
103
103
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
104
104
 
package/build/index.js CHANGED
@@ -62,7 +62,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
62
62
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
63
63
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
64
64
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
65
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
65
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
66
66
  import { AsyncLocalStorage } from "node:async_hooks";
67
67
  import express from "express";
68
68
  import fetchCookie from "fetch-cookie";
@@ -116,6 +116,53 @@ import { randomUUID, timingSafeEqual, } from "node:crypto";
116
116
  import { createLogger } from "./utils/logger.js";
117
117
  const logger = createLogger();
118
118
  const SERVER_NAME = process.env.MCP_SERVER_NAME?.trim() || "zereight-gitlab-mcp-server";
119
+ /**
120
+ * Static MCP prompt catalog. These wrap the multi-step tool workflows documented in
121
+ * skills/gitlab-mcp/ so MCP clients that surface prompts (e.g. slash commands) can
122
+ * trigger them without the agent needing to read the Skill first. Kept inline (not
123
+ * loaded from skills/gitlab-mcp/*.md) since only "build" ships in the npm package.
124
+ */
125
+ const MCP_PROMPTS = [
126
+ {
127
+ name: "review_merge_request",
128
+ description: "Review a merge request's diff in batches and leave threaded comments.",
129
+ arguments: [
130
+ { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
131
+ { name: "merge_request_iid", description: "Merge request IID", required: true },
132
+ ],
133
+ render: (args) => `Review merge request !${args.merge_request_iid ?? "<iid>"} in project ${args.project_id ?? "<project_id>"}:\n` +
134
+ `1. Call list_merge_request_changed_files to get the changed file list (use excluded_file_patterns for lockfiles/dist/generated files).\n` +
135
+ `2. Call get_merge_request_file_diff in batches of 3-5 files, prioritizing source over config/tests.\n` +
136
+ `3. Leave findings with create_merge_request_thread (inline, position: new_path/new_line) or create_merge_request_note (general).\n` +
137
+ `4. Summarize the review verdict in a final top-level note.`,
138
+ },
139
+ {
140
+ name: "triage_pipeline_failure",
141
+ description: "Find the failing job in a pipeline and pull its log tail for diagnosis.",
142
+ arguments: [
143
+ { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
144
+ { name: "pipeline_id", description: "Pipeline ID", required: true },
145
+ ],
146
+ render: (args) => `Diagnose pipeline ${args.pipeline_id ?? "<pipeline_id>"} in project ${args.project_id ?? "<project_id>"}:\n` +
147
+ `1. Call list_pipeline_jobs to find jobs with status "failed".\n` +
148
+ `2. Call get_pipeline_job_output (or the job trace tool) on each failed job, focusing on the tail of the log.\n` +
149
+ `3. Identify the root cause (compile error, test failure, timeout, runner issue) and quote the relevant log lines.\n` +
150
+ `4. Suggest the fix or the next diagnostic step.`,
151
+ },
152
+ {
153
+ name: "triage_vulnerabilities",
154
+ description: "List a project's open vulnerabilities and propose a remediation priority order.",
155
+ arguments: [
156
+ { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
157
+ ],
158
+ render: (args) => `Triage vulnerabilities for project ${args.project_id ?? "<project_id>"}:\n` +
159
+ `1. Call list_project_vulnerabilities once with state=detected and once with state=confirmed ` +
160
+ `(the tool only accepts one state per call). Follow pageInfo.endCursor with the "after" ` +
161
+ `param while pageInfo.hasNextPage is true, and aggregate both states' results.\n` +
162
+ `2. Group the aggregated results by severity (critical > high > medium > low) and by whether a fix is already available.\n` +
163
+ `3. Propose a remediation order, calling out any critical/high items with no available fix.`,
164
+ },
165
+ ];
119
166
  /**
120
167
  * Create a new MCP Server instance with request handlers registered.
121
168
  * Each transport connection gets its own Server instance to prevent
@@ -168,8 +215,35 @@ function createServer() {
168
215
  tools: {
169
216
  listChanged: true,
170
217
  },
218
+ prompts: {},
171
219
  },
172
220
  });
221
+ mcpServer.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
222
+ prompts: MCP_PROMPTS.map(({ name, description, arguments: args }) => ({
223
+ name,
224
+ description,
225
+ arguments: args,
226
+ })),
227
+ }));
228
+ mcpServer.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
229
+ const prompt = MCP_PROMPTS.find(p => p.name === request.params.name);
230
+ if (!prompt) {
231
+ throw new Error(`Unknown prompt: ${request.params.name}`);
232
+ }
233
+ const args = (request.params.arguments ?? {});
234
+ return {
235
+ description: prompt.description,
236
+ messages: [
237
+ {
238
+ role: "user",
239
+ content: {
240
+ type: "text",
241
+ text: prompt.render(args),
242
+ },
243
+ },
244
+ ],
245
+ };
246
+ });
173
247
  mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
174
248
  // Step 6: Gemini $schema cleanup + annotations (only dynamic step per request)
175
249
  // <<< START: Remove $schema for Gemini compatibility >>>
@@ -10038,7 +10112,9 @@ async function startSSEServer() {
10038
10112
  */
10039
10113
  async function startStreamableHTTPServer() {
10040
10114
  const app = express();
10041
- const streamableTransports = {};
10115
+ // Session IDs are untrusted map keys. A null prototype prevents inherited
10116
+ // names such as "constructor" from masquerading as live transports.
10117
+ const streamableTransports = Object.create(null);
10042
10118
  const authTimeouts = {};
10043
10119
  // Configuration and limits
10044
10120
  const MAX_SESSIONS = Number.parseInt(process.env.MAX_SESSIONS || "1000", 10);
@@ -10639,8 +10715,19 @@ async function startStreamableHTTPServer() {
10639
10715
  : STREAMABLE_HTTP_AUTH_TOKEN && !REMOTE_AUTHORIZATION
10640
10716
  ? staticMcpBearerAuth
10641
10717
  : (_req, _res, next) => next();
10718
+ const rejectUnknownStatefulSession = (req, res, next) => {
10719
+ const usesStatelessSessions = OAUTH_STATELESS_MODE &&
10720
+ STATELESS_MATERIAL &&
10721
+ (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH);
10722
+ const sessionId = readMcpSessionIdHeader(req);
10723
+ if (!usesStatelessSessions && sessionId && !streamableTransports[sessionId]) {
10724
+ res.status(404).json({ error: "Session not found" });
10725
+ return;
10726
+ }
10727
+ next();
10728
+ };
10642
10729
  // Streamable HTTP endpoint - handles both session creation and message handling
10643
- app.post("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
10730
+ app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
10644
10731
  const sessionId = readMcpSessionIdHeader(req);
10645
10732
  const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
10646
10733
  // Track request
@@ -10910,7 +10997,7 @@ async function startStreamableHTTPServer() {
10910
10997
  }
10911
10998
  });
10912
10999
  // Streamable HTTP GET endpoint for listening to server-sent events (SSE)
10913
- app.get("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
11000
+ app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
10914
11001
  const sessionId = readMcpSessionIdHeader(req);
10915
11002
  const acceptHeader = readAcceptHeader(req);
10916
11003
  if (!acceptHeader.includes("text/event-stream")) {
@@ -11030,7 +11117,7 @@ async function startStreamableHTTPServer() {
11030
11117
  });
11031
11118
  });
11032
11119
  // to delete a mcp server session explicitly
11033
- app.delete("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
11120
+ app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
11034
11121
  const sessionId = readMcpSessionIdHeader(req);
11035
11122
  if (!sessionId) {
11036
11123
  res.status(400).json({ error: "mcp-session-id header is required" });
@@ -255,36 +255,22 @@ describe('Remote Authorization', { concurrency: 1 }, () => {
255
255
  await new Promise(resolve => setTimeout(resolve, TIMEOUT_TEST_WAIT_MS));
256
256
  await waitForSessionDecrease(mcpUrl, beforeTimeout);
257
257
  console.log(' ✓ Timeout closed transport and released active session slot');
258
- // Step 3: Try to make request WITHOUT auth header - should fail with 401
259
- try {
260
- const response = await fetch(mcpUrl, {
261
- method: 'POST',
262
- headers: {
263
- 'Content-Type': 'application/json',
264
- 'mcp-session-id': sessionId,
265
- },
266
- body: JSON.stringify({
267
- jsonrpc: '2.0',
268
- id: 1,
269
- method: 'tools/list'
270
- })
271
- });
272
- if (!response.ok) {
273
- assert.strictEqual(response.status, 401, 'Should return 401 Unauthorized');
274
- console.log(` ✓ Request correctly failed with status ${response.status}`);
275
- const body = await response.text();
276
- console.log(` ℹ️ Error: ${body.substring(0, 100)}...`);
277
- }
278
- else {
279
- assert.fail('Should have failed due to expired auth token');
280
- }
281
- }
282
- catch (error) {
283
- // Network errors are also acceptable
284
- assert.ok(error instanceof Error, 'Should throw error');
285
- console.log(' ✓ Request correctly failed after timeout');
286
- console.log(` ℹ️ Error: ${error.message.substring(0, 100)}...`);
287
- }
258
+ // Step 3: A stale stateful session must return the MCP re-initialization signal.
259
+ const response = await fetch(mcpUrl, {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Content-Type': 'application/json',
263
+ 'mcp-session-id': sessionId,
264
+ },
265
+ body: JSON.stringify({
266
+ jsonrpc: '2.0',
267
+ id: 1,
268
+ method: 'tools/list'
269
+ })
270
+ });
271
+ assert.strictEqual(response.status, 404, 'Should return 404 Session Not Found');
272
+ assert.deepStrictEqual(await response.json(), { error: 'Session not found' });
273
+ console.log(' ✓ Expired session returned 404 so the client can re-initialize');
288
274
  await clientWithAuth.disconnect();
289
275
  });
290
276
  });
@@ -110,17 +110,35 @@ describe("Streamable HTTP unauthenticated tool discovery", { timeout: 20_000 },
110
110
  const { server, mcpUrl } = await launchRemoteAuthServer({
111
111
  GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
112
112
  MAX_SESSIONS: "1",
113
+ MAX_REQUESTS_PER_MINUTE: "1",
114
+ MCP_TRUST_PROXY: "true",
113
115
  SESSION_TIMEOUT_SECONDS: "1",
114
116
  });
115
117
  servers.push(server);
116
- await initialize(mcpUrl);
118
+ const expiredSessionId = await initialize(mcpUrl);
117
119
  await new Promise(resolve => setTimeout(resolve, 2_000));
120
+ const expiredResponse = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": expiredSessionId });
121
+ assert.strictEqual(expiredResponse.status, 404, expiredResponse.text);
122
+ assert.deepStrictEqual(expiredResponse.data, { error: "Session not found" });
118
123
  const healthResponse = await fetch(mcpUrl.replace("/mcp", "/health"));
119
124
  assert.strictEqual(healthResponse.status, 200);
120
- const nextSession = await rawMcpRequest(mcpUrl, initializeBody);
125
+ const nextSession = await rawMcpRequest(mcpUrl, initializeBody, {
126
+ "x-forwarded-for": "192.0.2.10",
127
+ });
121
128
  assert.strictEqual(nextSession.status, 200, nextSession.text);
122
129
  assert.ok(nextSession.sessionId, "expired discovery session should free capacity");
123
130
  });
131
+ test("rejects inherited object-property names as unknown session IDs", async () => {
132
+ const { server, mcpUrl } = await launchRemoteAuthServer({
133
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
134
+ });
135
+ servers.push(server);
136
+ for (const sessionId of ["constructor", "toString", "__proto__"]) {
137
+ const response = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sessionId });
138
+ assert.strictEqual(response.status, 404, `${sessionId}: ${response.text}`);
139
+ assert.deepStrictEqual(response.data, { error: "Session not found" });
140
+ }
141
+ });
124
142
  test("still blocks unauthenticated tools/call when discovery is enabled", async () => {
125
143
  const { server, mcpUrl } = await launchRemoteAuthServer({
126
144
  GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
@@ -1,8 +1,50 @@
1
1
  import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { describe, test } from "node:test";
3
- import { checkForNewVersion, isNewerVersion } from "../../utils/version-check.js";
6
+ import { checkForNewVersion, fetchLatestVersion, isNewerVersion } from "../../utils/version-check.js";
4
7
  function fetchReturning(status, body) {
5
- return (async () => new Response(JSON.stringify(body), { status }));
8
+ return async () => new Response(JSON.stringify(body), { status });
9
+ }
10
+ function fetchCapturing(calls, status, body) {
11
+ return async (url, init) => {
12
+ const headers = new Headers(init?.headers);
13
+ calls.push({
14
+ url: url.toString(),
15
+ authorization: headers.get("authorization"),
16
+ redirect: init?.redirect,
17
+ });
18
+ return new Response(JSON.stringify(body), { status });
19
+ };
20
+ }
21
+ function throwingResolver() {
22
+ throw new Error("npm not found");
23
+ }
24
+ async function withNpmrc(contents, run, globalContents = "") {
25
+ const configDir = mkdtempSync(join(tmpdir(), "gitlab-mcp-npmrc-"));
26
+ const userconfigPath = join(configDir, ".npmrc");
27
+ const globalconfigPath = join(configDir, "global.npmrc");
28
+ writeFileSync(userconfigPath, contents);
29
+ writeFileSync(globalconfigPath, globalContents);
30
+ const previousUserconfig = process.env.NPM_CONFIG_USERCONFIG;
31
+ const previousGlobalconfig = process.env.NPM_CONFIG_GLOBALCONFIG;
32
+ process.env.NPM_CONFIG_USERCONFIG = userconfigPath;
33
+ process.env.NPM_CONFIG_GLOBALCONFIG = globalconfigPath;
34
+ try {
35
+ await run();
36
+ }
37
+ finally {
38
+ if (previousUserconfig === undefined)
39
+ delete process.env.NPM_CONFIG_USERCONFIG;
40
+ else
41
+ process.env.NPM_CONFIG_USERCONFIG = previousUserconfig;
42
+ if (previousGlobalconfig === undefined)
43
+ delete process.env.NPM_CONFIG_GLOBALCONFIG;
44
+ else
45
+ process.env.NPM_CONFIG_GLOBALCONFIG = previousGlobalconfig;
46
+ rmSync(configDir, { recursive: true, force: true });
47
+ }
6
48
  }
7
49
  describe("When isNewerVersion compares versions", () => {
8
50
  test("should detect newer patch, minor, and major versions", () => {
@@ -30,18 +72,14 @@ describe("When checkForNewVersion runs", () => {
30
72
  assert.equal(latest, null);
31
73
  });
32
74
  test("should return null without fetching when current version is unknown", async () => {
33
- let called = false;
34
- const spyFetch = (async () => {
35
- called = true;
36
- return new Response("{}", { status: 200 });
37
- });
38
- assert.equal(await checkForNewVersion("unknown", spyFetch), null);
39
- assert.equal(called, false);
75
+ const calls = [];
76
+ assert.equal(await checkForNewVersion("unknown", fetchCapturing(calls, 200, {})), null);
77
+ assert.equal(calls.length, 0);
40
78
  });
41
79
  test("should return null on registry errors instead of throwing", async () => {
42
- const failingFetch = (async () => {
80
+ const failingFetch = async () => {
43
81
  throw new Error("network down");
44
- });
82
+ };
45
83
  assert.equal(await checkForNewVersion("2.1.29", failingFetch), null);
46
84
  assert.equal(await checkForNewVersion("2.1.29", fetchReturning(500, {})), null);
47
85
  });
@@ -50,3 +88,152 @@ describe("When checkForNewVersion runs", () => {
50
88
  assert.equal(latest, null);
51
89
  });
52
90
  });
91
+ describe("When fetchLatestVersion resolves the registry", () => {
92
+ test("should fetch from the resolved registry instead of a hardcoded one", async () => {
93
+ const calls = [];
94
+ const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }), 3000, () => "https://npm.internal.example/");
95
+ assert.equal(latest, "2.1.31");
96
+ assert.equal(calls[0]?.url, "https://npm.internal.example/@zereight/mcp-gitlab/latest");
97
+ });
98
+ test("should fall back to the public registry when the resolver throws", async () => {
99
+ const calls = [];
100
+ const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }), 3000, throwingResolver);
101
+ assert.equal(latest, null);
102
+ assert.equal(calls.length, 0);
103
+ });
104
+ test("should fall back to the public registry when the resolved value has no hostname", async () => {
105
+ const calls = [];
106
+ const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }), 3000, () => "https:///");
107
+ assert.equal(latest, "2.1.31");
108
+ assert.equal(calls[0]?.url, "https://registry.npmjs.org/@zereight/mcp-gitlab/latest");
109
+ });
110
+ test("should fall back to the public registry when the resolved value has a query string", async () => {
111
+ const calls = [];
112
+ const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }), 3000, () => "https://registry.example/npm?tenant=a");
113
+ assert.equal(latest, "2.1.31");
114
+ assert.equal(calls[0]?.url, "https://registry.npmjs.org/@zereight/mcp-gitlab/latest");
115
+ });
116
+ });
117
+ describe("When resolving the registry from npm config", () => {
118
+ describe("with a scoped @zereight:registry", () => {
119
+ test("should prefer the scoped registry over the unscoped one", async () => {
120
+ await withNpmrc("registry=https://unscoped.example/\n@zereight:registry=https://scoped.example/\n", async () => {
121
+ const calls = [];
122
+ const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
123
+ assert.equal(latest, "2.1.31");
124
+ assert.equal(calls[0]?.url, "https://scoped.example/@zereight/mcp-gitlab/latest");
125
+ });
126
+ });
127
+ });
128
+ });
129
+ describe("When fetchLatestVersion authenticates against a protected registry", () => {
130
+ describe("with a scoped _authToken in .npmrc", () => {
131
+ test("should send a Bearer token only to that HTTPS registry", async () => {
132
+ await withNpmrc([
133
+ "registry=https://unscoped.example/",
134
+ "@zereight:registry=https://scoped.example/",
135
+ "//scoped.example/:_authToken=scoped-registry-token",
136
+ ].join("\n"), async () => {
137
+ const calls = [];
138
+ const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
139
+ assert.equal(latest, "2.1.31");
140
+ assert.equal(calls[0]?.url, "https://scoped.example/@zereight/mcp-gitlab/latest");
141
+ assert.equal(calls[0]?.authorization, "Bearer scoped-registry-token");
142
+ });
143
+ });
144
+ test("should refuse to follow redirects when a token is attached", async () => {
145
+ await withNpmrc([
146
+ "@zereight:registry=https://scoped.example/",
147
+ "//scoped.example/:_authToken=scoped-registry-token",
148
+ ].join("\n"), async () => {
149
+ const calls = [];
150
+ await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
151
+ assert.equal(calls[0]?.redirect, "error");
152
+ });
153
+ });
154
+ });
155
+ describe("without a matching token", () => {
156
+ test("should omit Authorization", async () => {
157
+ await withNpmrc("@zereight:registry=https://scoped.example/\n", async () => {
158
+ const calls = [];
159
+ await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
160
+ assert.equal(calls[0]?.authorization, null);
161
+ assert.equal(calls[0]?.redirect, undefined);
162
+ });
163
+ });
164
+ });
165
+ describe("when the token is on a parent registry path", () => {
166
+ test("should fall back to the host-level _authToken", async () => {
167
+ await withNpmrc([
168
+ "@zereight:registry=https://scoped.example/npm",
169
+ "//scoped.example/:_authToken=host-level-token",
170
+ ].join("\n"), async () => {
171
+ const calls = [];
172
+ const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
173
+ assert.equal(latest, "2.1.31");
174
+ assert.equal(calls[0]?.url, "https://scoped.example/npm/@zereight/mcp-gitlab/latest");
175
+ assert.equal(calls[0]?.authorization, "Bearer host-level-token");
176
+ });
177
+ });
178
+ });
179
+ describe("when both a path token and a host token exist", () => {
180
+ test("should prefer the more specific path token", async () => {
181
+ await withNpmrc([
182
+ "@zereight:registry=https://scoped.example/npm",
183
+ "//scoped.example/npm/:_authToken=path-token",
184
+ "//scoped.example/:_authToken=host-level-token",
185
+ ].join("\n"), async () => {
186
+ const calls = [];
187
+ await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
188
+ assert.equal(calls[0]?.authorization, "Bearer path-token");
189
+ });
190
+ });
191
+ });
192
+ describe("when the token is only in the global npmrc", () => {
193
+ test("should send a Bearer token from globalconfig", async () => {
194
+ await withNpmrc("@zereight:registry=https://scoped.example/\n", async () => {
195
+ const calls = [];
196
+ await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
197
+ assert.equal(calls[0]?.authorization, "Bearer global-registry-token");
198
+ }, "//scoped.example/:_authToken=global-registry-token\n");
199
+ });
200
+ });
201
+ describe("when the registry path contains underscores", () => {
202
+ test("should send a Bearer token for an Azure Artifacts-style registry path", async () => {
203
+ const registry = "https://pkgs.dev.azure.com/org/_packaging/feed/npm/registry";
204
+ const authKey = "//pkgs.dev.azure.com/org/_packaging/feed/npm/registry/:_authToken";
205
+ await withNpmrc([`@zereight:registry=${registry}`, `${authKey}=azure-feed-token`].join("\n"), async () => {
206
+ const calls = [];
207
+ const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
208
+ assert.equal(latest, "2.1.31");
209
+ assert.equal(calls[0]?.url, `${registry}/@zereight/mcp-gitlab/latest`);
210
+ assert.equal(calls[0]?.authorization, "Bearer azure-feed-token");
211
+ });
212
+ });
213
+ });
214
+ describe("with a token for a different host", () => {
215
+ test("should omit Authorization", async () => {
216
+ await withNpmrc([
217
+ "@zereight:registry=https://scoped.example/",
218
+ "//other.example/:_authToken=other-host-token",
219
+ ].join("\n"), async () => {
220
+ const calls = [];
221
+ await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
222
+ assert.equal(calls[0]?.authorization, null);
223
+ });
224
+ });
225
+ });
226
+ describe("when the registry is HTTP", () => {
227
+ test("should omit Authorization even if a token is configured", async () => {
228
+ await withNpmrc([
229
+ "@zereight:registry=http://insecure.example/",
230
+ "//insecure.example/:_authToken=http-registry-token",
231
+ ].join("\n"), async () => {
232
+ const calls = [];
233
+ await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
234
+ assert.equal(calls[0]?.url, "http://insecure.example/@zereight/mcp-gitlab/latest");
235
+ assert.equal(calls[0]?.authorization, null);
236
+ });
237
+ });
238
+ });
239
+ });
@@ -1,5 +1,176 @@
1
- const NPM_REGISTRY_LATEST_URL = "https://registry.npmjs.org/@zereight/mcp-gitlab/latest";
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
8
+ const PACKAGE_LATEST_PATH = "@zereight/mcp-gitlab/latest";
2
9
  const RELEASE_VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
10
+ const AUTH_TOKEN_KEY_PATTERN = /^\/\/[-A-Za-z0-9.]+(?::\d+)?(?:\/[-A-Za-z0-9._]+)*\/:_authToken$/;
11
+ const NPMRC_ENV_PATTERN = /\$\{([A-Za-z_]\w*)\}/g;
12
+ /**
13
+ * A configured registry is only used when it parses as a plain HTTP(S) URL
14
+ * with a hostname. A query or fragment is rejected too: the package path is
15
+ * appended to this value with plain string concatenation below, so a query
16
+ * string like "?tenant=a" would swallow the appended path instead of the
17
+ * request reaching the intended endpoint.
18
+ */
19
+ function isUsableRegistryUrl(registry) {
20
+ try {
21
+ const parsed = new URL(registry);
22
+ return ((parsed.protocol === "http:" || parsed.protocol === "https:") &&
23
+ parsed.hostname !== "" &&
24
+ parsed.search === "" &&
25
+ parsed.hash === "");
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /** Runs `npm config get <key>` and returns the trimmed value, or null if unavailable/unset. */
32
+ async function npmConfigGet(key) {
33
+ try {
34
+ // On Windows "npm" resolves to npm.cmd, which execFile can't launch
35
+ // directly — route through cmd.exe instead. Command and args are fixed
36
+ // literals (only `key` varies, and only with our own constant inputs), so
37
+ // this doesn't open a shell-injection surface.
38
+ const isWindows = process.platform === "win32";
39
+ const { stdout } = await execFileAsync(isWindows ? "cmd.exe" : "npm", isWindows ? ["/d", "/s", "/c", "npm", "config", "get", key] : ["config", "get", key], { timeout: 5000, windowsHide: true });
40
+ const value = stdout.trim().replace(/\/$/, "");
41
+ return value && value !== "undefined" ? value : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /**
48
+ * Resolves the npm registry to use, honoring `.npmrc` / `npm_config_registry`.
49
+ * Package-scoped config (`@zereight:registry`) takes precedence, matching how
50
+ * npm itself resolves the registry for a scoped package like this one.
51
+ */
52
+ async function resolveConfiguredRegistry() {
53
+ const scoped = await npmConfigGet("@zereight:registry");
54
+ if (scoped)
55
+ return scoped;
56
+ const unscoped = await npmConfigGet("registry");
57
+ if (unscoped)
58
+ return unscoped;
59
+ return DEFAULT_NPM_REGISTRY;
60
+ }
61
+ function authTokenConfigKeys(registry) {
62
+ if (!isUsableRegistryUrl(registry))
63
+ return [];
64
+ const parsed = new URL(registry);
65
+ if (parsed.protocol !== "https:")
66
+ return [];
67
+ const keys = [];
68
+ let path = parsed.pathname.replace(/\/$/, "");
69
+ while (true) {
70
+ const key = `//${parsed.host}${path}/:_authToken`;
71
+ if (AUTH_TOKEN_KEY_PATTERN.test(key))
72
+ keys.push(key);
73
+ if (path === "")
74
+ break;
75
+ const slash = path.lastIndexOf("/");
76
+ path = slash <= 0 ? "" : path.slice(0, slash);
77
+ }
78
+ return keys;
79
+ }
80
+ async function npmrcCandidatePaths() {
81
+ const paths = [];
82
+ const configuredGlobal = process.env.NPM_CONFIG_GLOBALCONFIG;
83
+ const globalconfig = configuredGlobal || (await npmConfigGet("globalconfig"));
84
+ if (globalconfig)
85
+ paths.push(globalconfig);
86
+ paths.push(process.env.NPM_CONFIG_USERCONFIG ?? join(homedir(), ".npmrc"));
87
+ paths.push(join(process.cwd(), ".npmrc"));
88
+ return paths;
89
+ }
90
+ function unquoteNpmrcValue(value) {
91
+ if (value.length >= 2) {
92
+ const start = value[0];
93
+ const end = value[value.length - 1];
94
+ if ((start === '"' && end === '"') || (start === "'" && end === "'")) {
95
+ return value.slice(1, -1);
96
+ }
97
+ }
98
+ return value;
99
+ }
100
+ function expandNpmrcEnv(value) {
101
+ return value.replace(NPMRC_ENV_PATTERN, (_match, name) => process.env[name] ?? "");
102
+ }
103
+ function parseNpmrc(content) {
104
+ const entries = new Map();
105
+ for (const rawLine of content.split(/\r?\n/)) {
106
+ const line = rawLine.trim();
107
+ if (!line || line.startsWith("#") || line.startsWith(";"))
108
+ continue;
109
+ const separator = line.indexOf("=");
110
+ if (separator <= 0)
111
+ continue;
112
+ const key = line.slice(0, separator).trim();
113
+ const value = expandNpmrcEnv(unquoteNpmrcValue(line.slice(separator + 1).trim()));
114
+ entries.set(key, value);
115
+ }
116
+ return entries;
117
+ }
118
+ async function readNpmrcFile(path) {
119
+ try {
120
+ return parseNpmrc(await readFile(path, "utf8"));
121
+ }
122
+ catch {
123
+ return new Map();
124
+ }
125
+ }
126
+ /**
127
+ * Reads `//host[:port][/path]/:_authToken` from npmrc files, then walks up
128
+ * parent paths to `//host/:_authToken`, matching npm-registry-fetch.
129
+ * `npm config get` refuses to print protected auth keys, so this parses the
130
+ * files npm itself would consult instead of asking the CLI.
131
+ */
132
+ async function resolveRegistryAuthToken(registry) {
133
+ const keys = authTokenConfigKeys(registry);
134
+ if (keys.length === 0)
135
+ return null;
136
+ const merged = new Map();
137
+ for (const path of await npmrcCandidatePaths()) {
138
+ const entries = await readNpmrcFile(path);
139
+ for (const [entryKey, value] of entries)
140
+ merged.set(entryKey, value);
141
+ }
142
+ for (const key of keys) {
143
+ const token = merged.get(key);
144
+ if (token)
145
+ return token;
146
+ }
147
+ return null;
148
+ }
149
+ function requestIsUnderRegistry(registry, requestUrl) {
150
+ try {
151
+ const parsedRegistry = new URL(registry);
152
+ const parsedRequest = new URL(requestUrl);
153
+ if (parsedRegistry.protocol !== "https:" || parsedRequest.protocol !== "https:")
154
+ return false;
155
+ if (parsedRegistry.origin !== parsedRequest.origin)
156
+ return false;
157
+ const basePath = parsedRegistry.pathname.replace(/\/$/, "");
158
+ if (basePath === "")
159
+ return true;
160
+ return parsedRequest.pathname === basePath || parsedRequest.pathname.startsWith(`${basePath}/`);
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
166
+ function readVersionField(data) {
167
+ if (typeof data !== "object" || data === null)
168
+ return null;
169
+ if (!("version" in data))
170
+ return null;
171
+ const version = data.version;
172
+ return typeof version === "string" ? version : null;
173
+ }
3
174
  export function isNewerVersion(candidate, current) {
4
175
  const parse = (version) => version.split(".").map(part => Number.parseInt(part, 10));
5
176
  const a = parse(candidate);
@@ -12,16 +183,27 @@ export function isNewerVersion(candidate, current) {
12
183
  }
13
184
  return false;
14
185
  }
15
- export async function fetchLatestVersion(fetchFn = fetch, timeoutMs = 3000) {
186
+ export async function fetchLatestVersion(fetchFn = fetch, timeoutMs = 3000, resolveRegistry = resolveConfiguredRegistry) {
16
187
  try {
17
- const response = await fetchFn(NPM_REGISTRY_LATEST_URL, {
188
+ const resolved = (await resolveRegistry()).replace(/\/$/, "");
189
+ const registry = isUsableRegistryUrl(resolved) ? resolved : DEFAULT_NPM_REGISTRY;
190
+ const registryLatestUrl = `${registry}/${PACKAGE_LATEST_PATH}`;
191
+ const headers = { accept: "application/json" };
192
+ const init = {
18
193
  signal: AbortSignal.timeout(timeoutMs),
19
- headers: { accept: "application/json" },
20
- });
194
+ headers,
195
+ };
196
+ const token = await resolveRegistryAuthToken(registry).catch(() => null);
197
+ if (token && requestIsUnderRegistry(registry, registryLatestUrl)) {
198
+ headers.authorization = `Bearer ${token}`;
199
+ // Never follow redirects with a token attached — fetch would otherwise
200
+ // forward Authorization to a different origin.
201
+ init.redirect = "error";
202
+ }
203
+ const response = await fetchFn(registryLatestUrl, init);
21
204
  if (!response.ok)
22
205
  return null;
23
- const data = (await response.json());
24
- return typeof data.version === "string" ? data.version : null;
206
+ return readVersionField(await response.json());
25
207
  }
26
208
  catch {
27
209
  // Fail silent: the update check must never break or delay server startup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.53",
3
+ "version": "2.1.55",
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": [