@zereight/mcp-gitlab 2.1.54 → 2.1.56

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.53`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
101
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.55`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
102
102
 
103
103
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
104
104
 
@@ -288,7 +288,7 @@ MCP 클라이언트 설정:
288
288
  | `STREAMABLE_HTTP` | 예 | 반드시 `true` |
289
289
  | `ENABLE_DYNAMIC_API_URL` | 선택 | 요청별 `X-GitLab-API-URL` 헤더 허용 |
290
290
  | `GITLAB_ALLOWED_HOSTS` | 선택 | 허용할 `X-GitLab-API-URL` 호스트의 쉼표 구분 목록; `GITLAB_API_URL` 호스트는 항상 허용 |
291
- | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | 선택 | 인증 없이 `initialize`, `notifications/initialized`, `tools/list`만 허용(도구 호출은 여전히 인증 필요) |
291
+ | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | 선택 | 인증 없이 `initialize`, `notifications/initialized`, `tools/list`, `server/discover`만 허용(도구 호출은 여전히 인증 필요) |
292
292
  | `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | 선택 | DNS rebinding 방지를 위한 허용 `/mcp` 호스트/오리진 값 |
293
293
  | `MCP_TRUST_PROXY` | 선택 | 리버스 프록시 뒤에서 `Forwarded` / `X-Forwarded-*` 헤더 신뢰(다운로드 URL, Express `req.ip`, `/mcp` IP rate limit, OAuth rate limit) |
294
294
 
package/README.md CHANGED
@@ -103,7 +103,7 @@ npm install -g @zereight/mcp-gitlab
103
103
 
104
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`.
105
105
 
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.53`. 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.55`. 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`).
107
107
 
108
108
  #### Using CLI Arguments (for clients with env var issues)
109
109
 
@@ -316,7 +316,7 @@ the token to GitLab on behalf of the caller.
316
316
  | `STREAMABLE_HTTP` | ✅ | Must be `true` |
317
317
  | `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
318
318
  | `GITLAB_ALLOWED_HOSTS` | optional | Comma-separated allowed `X-GitLab-API-URL` hosts; `GITLAB_API_URL` hosts are always allowed |
319
- | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | optional | Allow unauthenticated `initialize`, `notifications/initialized`, and `tools/list` only (tool calls still require auth) |
319
+ | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | optional | Allow unauthenticated `initialize`, `notifications/initialized`, `tools/list`, and `server/discover` only (tool calls still require auth) |
320
320
  | `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | optional | Allowed public `/mcp` host/origin values for DNS rebinding protection |
321
321
  | `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers behind a reverse proxy (download URLs, Express `req.ip`, `/mcp` IP rate limits, OAuth rate limits) |
322
322
 
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.53`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
101
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.55`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
102
102
 
103
103
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
104
104
 
@@ -289,7 +289,7 @@ MCP 客户端配置:
289
289
  | `STREAMABLE_HTTP` | 是 | 必须为 `true` |
290
290
  | `ENABLE_DYNAMIC_API_URL` | 可选 | 允许按请求通过 `X-GitLab-API-URL` 请求头指定 GitLab URL |
291
291
  | `GITLAB_ALLOWED_HOSTS` | 可选 | 允许的 `X-GitLab-API-URL` 主机逗号分隔列表;`GITLAB_API_URL` 中的主机始终允许 |
292
- | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | 可选 | 仅允许未认证的 `initialize`、`notifications/initialized`、`tools/list`(工具调用仍需认证) |
292
+ | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | 可选 | 仅允许未认证的 `initialize`、`notifications/initialized`、`tools/list`、`server/discover`(工具调用仍需认证) |
293
293
  | `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | 可选 | 用于 DNS rebinding 防护的允许 `/mcp` 主机/来源值 |
294
294
  | `MCP_TRUST_PROXY` | 可选 | 在反向代理后信任 `Forwarded` / `X-Forwarded-*` 请求头(下载 URL、Express `req.ip`、`/mcp` IP 速率限制、OAuth 速率限制) |
295
295
 
package/build/index.js CHANGED
@@ -10112,7 +10112,9 @@ async function startSSEServer() {
10112
10112
  */
10113
10113
  async function startStreamableHTTPServer() {
10114
10114
  const app = express();
10115
- 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);
10116
10118
  const authTimeouts = {};
10117
10119
  // Configuration and limits
10118
10120
  const MAX_SESSIONS = Number.parseInt(process.env.MAX_SESSIONS || "1000", 10);
@@ -10713,8 +10715,19 @@ async function startStreamableHTTPServer() {
10713
10715
  : STREAMABLE_HTTP_AUTH_TOKEN && !REMOTE_AUTHORIZATION
10714
10716
  ? staticMcpBearerAuth
10715
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
+ };
10716
10729
  // Streamable HTTP endpoint - handles both session creation and message handling
10717
- app.post("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
10730
+ app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
10718
10731
  const sessionId = readMcpSessionIdHeader(req);
10719
10732
  const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
10720
10733
  // Track request
@@ -10984,7 +10997,7 @@ async function startStreamableHTTPServer() {
10984
10997
  }
10985
10998
  });
10986
10999
  // Streamable HTTP GET endpoint for listening to server-sent events (SSE)
10987
- app.get("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
11000
+ app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
10988
11001
  const sessionId = readMcpSessionIdHeader(req);
10989
11002
  const acceptHeader = readAcceptHeader(req);
10990
11003
  if (!acceptHeader.includes("text/event-stream")) {
@@ -11104,7 +11117,7 @@ async function startStreamableHTTPServer() {
11104
11117
  });
11105
11118
  });
11106
11119
  // to delete a mcp server session explicitly
11107
- app.delete("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
11120
+ app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
11108
11121
  const sessionId = readMcpSessionIdHeader(req);
11109
11122
  if (!sessionId) {
11110
11123
  res.status(400).json({ error: "mcp-session-id header is required" });
@@ -34,7 +34,10 @@ export function isUnauthenticatedDiscoveryRequestBody(body) {
34
34
  if (typeof m !== "object" || m === null)
35
35
  return false;
36
36
  const method = m.method;
37
- return (method === "initialize" || method === "notifications/initialized" || method === "tools/list");
37
+ return (method === "initialize" ||
38
+ method === "notifications/initialized" ||
39
+ method === "tools/list" ||
40
+ method === "server/discover");
38
41
  };
39
42
  if (Array.isArray(body))
40
43
  return body.every(isDiscoveryMethod);
@@ -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
  });
@@ -16,12 +16,22 @@ describe("streamable request helpers", () => {
16
16
  });
17
17
  test("allows only discovery methods in unauthenticated discovery bodies", () => {
18
18
  assert.strictEqual(isUnauthenticatedDiscoveryRequestBody({ method: "tools/list" }), true);
19
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody({ method: "server/discover" }), true);
19
20
  assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
20
21
  { method: "initialize" },
21
22
  { method: "notifications/initialized" },
22
23
  { method: "tools/list" },
24
+ { method: "server/discover" },
23
25
  ]), true);
24
- assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([{ method: "tools/list" }, { method: "call_tool" }]), false);
26
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
27
+ { method: "tools/list" },
28
+ { method: "call_tool" },
29
+ { method: "tools/call" },
30
+ ]), false);
31
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
32
+ { method: "server/discover" },
33
+ { method: "tools/call" },
34
+ ]), false);
25
35
  assert.strictEqual(isUnauthenticatedDiscoveryRequestBody(undefined), false);
26
36
  });
27
37
  test("normalizes Mcp-Session-Id headers", () => {
@@ -106,21 +106,87 @@ describe("Streamable HTTP unauthenticated tool discovery", { timeout: 20_000 },
106
106
  assert.ok(Array.isArray(listResponse.data.result?.tools), "tools/list should return tools");
107
107
  assert.ok(listResponse.data.result.tools.length > 0, "tools/list should not be empty");
108
108
  });
109
+ test("allows unauthenticated server/discover in individual and batch requests", async () => {
110
+ const { server, mcpUrl } = await launchRemoteAuthServer({
111
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
112
+ });
113
+ servers.push(server);
114
+ const preInitDiscover = await rawMcpRequest(mcpUrl, {
115
+ jsonrpc: "2.0",
116
+ id: 10,
117
+ method: "server/discover",
118
+ params: {},
119
+ });
120
+ assert.strictEqual(preInitDiscover.status, 400, preInitDiscover.text);
121
+ assert.match(preInitDiscover.data?.error?.message ?? "", /Server not initialized/i, "server/discover should pass the auth gate before MCP initialization errors");
122
+ const sessionId = await initialize(mcpUrl);
123
+ const initialized = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", method: "notifications/initialized" }, { "mcp-session-id": sessionId });
124
+ assert.ok([200, 202, 204].includes(initialized.status), initialized.text);
125
+ const discoverResponse = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 11, method: "server/discover", params: {} }, { "mcp-session-id": sessionId });
126
+ assert.strictEqual(discoverResponse.status, 200, discoverResponse.text);
127
+ assert.strictEqual(discoverResponse.data?.id, 11);
128
+ assert.strictEqual(discoverResponse.data?.error?.code, -32601);
129
+ assert.match(discoverResponse.data?.error?.message ?? "", /Method not found/i);
130
+ const batchResponse = await rawMcpRequest(mcpUrl, [
131
+ { jsonrpc: "2.0", id: 12, method: "server/discover", params: {} },
132
+ { jsonrpc: "2.0", id: 13, method: "tools/list", params: {} },
133
+ ], { "mcp-session-id": sessionId });
134
+ assert.strictEqual(batchResponse.status, 200, batchResponse.text);
135
+ assert.ok(Array.isArray(batchResponse.data.result?.tools), "batch tools/list should return tools");
136
+ assert.ok(batchResponse.data.result.tools.length > 0, "batch tools/list should not be empty");
137
+ });
138
+ test("rejects unauthenticated discovery batch when tools/call is included", async () => {
139
+ const { server, mcpUrl } = await launchRemoteAuthServer({
140
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
141
+ });
142
+ servers.push(server);
143
+ const response = await rawMcpRequest(mcpUrl, [
144
+ { jsonrpc: "2.0", id: 20, method: "server/discover", params: {} },
145
+ {
146
+ jsonrpc: "2.0",
147
+ id: 21,
148
+ method: "tools/call",
149
+ params: {
150
+ name: "get_project",
151
+ arguments: { project_id: "123" },
152
+ },
153
+ },
154
+ ]);
155
+ assert.strictEqual(response.status, 401, response.text);
156
+ });
109
157
  test("expires unauthenticated discovery sessions and frees capacity", async () => {
110
158
  const { server, mcpUrl } = await launchRemoteAuthServer({
111
159
  GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
112
160
  MAX_SESSIONS: "1",
161
+ MAX_REQUESTS_PER_MINUTE: "1",
162
+ MCP_TRUST_PROXY: "true",
113
163
  SESSION_TIMEOUT_SECONDS: "1",
114
164
  });
115
165
  servers.push(server);
116
- await initialize(mcpUrl);
166
+ const expiredSessionId = await initialize(mcpUrl);
117
167
  await new Promise(resolve => setTimeout(resolve, 2_000));
168
+ const expiredResponse = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": expiredSessionId });
169
+ assert.strictEqual(expiredResponse.status, 404, expiredResponse.text);
170
+ assert.deepStrictEqual(expiredResponse.data, { error: "Session not found" });
118
171
  const healthResponse = await fetch(mcpUrl.replace("/mcp", "/health"));
119
172
  assert.strictEqual(healthResponse.status, 200);
120
- const nextSession = await rawMcpRequest(mcpUrl, initializeBody);
173
+ const nextSession = await rawMcpRequest(mcpUrl, initializeBody, {
174
+ "x-forwarded-for": "192.0.2.10",
175
+ });
121
176
  assert.strictEqual(nextSession.status, 200, nextSession.text);
122
177
  assert.ok(nextSession.sessionId, "expired discovery session should free capacity");
123
178
  });
179
+ test("rejects inherited object-property names as unknown session IDs", async () => {
180
+ const { server, mcpUrl } = await launchRemoteAuthServer({
181
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
182
+ });
183
+ servers.push(server);
184
+ for (const sessionId of ["constructor", "toString", "__proto__"]) {
185
+ const response = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sessionId });
186
+ assert.strictEqual(response.status, 404, `${sessionId}: ${response.text}`);
187
+ assert.deepStrictEqual(response.data, { error: "Session not found" });
188
+ }
189
+ });
124
190
  test("still blocks unauthenticated tools/call when discovery is enabled", async () => {
125
191
  const { server, mcpUrl } = await launchRemoteAuthServer({
126
192
  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.54",
3
+ "version": "2.1.56",
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": [