@zereight/mcp-gitlab 2.1.26 → 2.1.27

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
@@ -238,6 +238,7 @@ MCP 클라이언트 설정:
238
238
  | `REMOTE_AUTHORIZATION` | 예 | 활성화하려면 `true` |
239
239
  | `STREAMABLE_HTTP` | 예 | 반드시 `true` |
240
240
  | `ENABLE_DYNAMIC_API_URL` | 선택 | 요청별 `X-GitLab-API-URL` 헤더 허용 |
241
+ | `GITLAB_ALLOWED_HOSTS` | 선택 | 허용할 호스트의 쉼표 구분 목록; `GITLAB_API_URL` 호스트는 항상 허용 |
241
242
 
242
243
  **예시 요청 헤더:**
243
244
 
package/README.md CHANGED
@@ -111,6 +111,7 @@ docker run -i --rm \
111
111
  -e USE_MILESTONE=true \
112
112
  -e USE_PIPELINE=true \
113
113
  -e SSE=true \
114
+ -e SSE_AUTH_TOKEN=your_mcp_sse_token \
114
115
  -p 3333:3002 \
115
116
  zereight050/gitlab-mcp
116
117
  ```
@@ -120,7 +121,10 @@ docker run -i --rm \
120
121
  "mcpServers": {
121
122
  "gitlab": {
122
123
  "type": "sse",
123
- "url": "http://localhost:3333/sse"
124
+ "url": "http://localhost:3333/sse",
125
+ "headers": {
126
+ "Authorization": "Bearer your_mcp_sse_token"
127
+ }
124
128
  }
125
129
  }
126
130
  }
@@ -267,6 +271,7 @@ the token to GitLab on behalf of the caller.
267
271
  | `REMOTE_AUTHORIZATION` | ✅ | Set to `true` to enable |
268
272
  | `STREAMABLE_HTTP` | ✅ | Must be `true` |
269
273
  | `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
274
+ | `GITLAB_ALLOWED_HOSTS` | optional | Comma-separated allowed `X-GitLab-API-URL` hosts; `GITLAB_API_URL` hosts are always allowed |
270
275
  | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | optional | Allow unauthenticated `initialize`, `notifications/initialized`, and `tools/list` only (tool calls still require auth) |
271
276
  | `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers behind a reverse proxy (download URLs, Express `req.ip`, OAuth rate limits) |
272
277
 
package/README.zh-CN.md CHANGED
@@ -238,6 +238,7 @@ MCP 客户端配置:
238
238
  | `REMOTE_AUTHORIZATION` | 是 | 设置为 `true` 以启用 |
239
239
  | `STREAMABLE_HTTP` | 是 | 必须为 `true` |
240
240
  | `ENABLE_DYNAMIC_API_URL` | 可选 | 允许按请求通过 `X-GitLab-API-URL` 请求头指定 GitLab URL |
241
+ | `GITLAB_ALLOWED_HOSTS` | 可选 | 逗号分隔的允许主机;`GITLAB_API_URL` 中的主机始终允许 |
241
242
 
242
243
  **示例请求头:**
243
244
 
package/build/index.js CHANGED
@@ -467,6 +467,14 @@ function createServer() {
467
467
  /**
468
468
  * Validate configuration at startup
469
469
  */
470
+ function isLoopbackBindHost(host) {
471
+ const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
472
+ const isIpv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized);
473
+ return (normalized === "localhost" ||
474
+ isIpv4Loopback ||
475
+ normalized === "::1" ||
476
+ normalized === "0:0:0:0:0:0:0:1");
477
+ }
470
478
  function validateConfiguration() {
471
479
  const errors = [];
472
480
  // Validate SESSION_TIMEOUT_SECONDS
@@ -517,6 +525,12 @@ function validateConfiguration() {
517
525
  }
518
526
  }
519
527
  }
528
+ const allowedHosts = getConfig("allowed-hosts", "GITLAB_ALLOWED_HOSTS")?.split(",") || [];
529
+ for (const host of allowedHosts) {
530
+ if (host.trim() && !toAllowedGitLabApiUrl(host)) {
531
+ errors.push(`GITLAB_ALLOWED_HOSTS contains an invalid host or URL: ${host.trim()}`);
532
+ }
533
+ }
520
534
  // Validate auth configuration
521
535
  const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
522
536
  const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
@@ -526,12 +540,19 @@ function validateConfiguration() {
526
540
  const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
527
541
  const mcpServerUrl = getConfig("mcp-server-url", "MCP_SERVER_URL");
528
542
  const streamableHttp = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
543
+ const sse = getConfig("sse", "SSE") === "true";
544
+ const bindHost = getConfig("host", "HOST") || "127.0.0.1";
545
+ const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
546
+ const allowUnauthenticatedRemoteSse = getConfig("sse-dangerously-allow-unauthenticated-remote", "SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE") === "true";
529
547
  if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie && !mcpOAuth) {
530
548
  errors.push("Either --token, --job-token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables)");
531
549
  }
532
550
  if (streamableHttp && (hasToken || hasJobToken) && !remoteAuth && !mcpOAuth) {
533
551
  errors.push("STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token or GITLAB_JOB_TOKEN/--job-token requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true");
534
552
  }
553
+ if (sse && !isLoopbackBindHost(bindHost) && !sseAuthToken && !allowUnauthenticatedRemoteSse) {
554
+ errors.push("SSE=true on a non-loopback HOST requires SSE_AUTH_TOKEN (or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)");
555
+ }
535
556
  if (mcpOAuth) {
536
557
  if (!mcpServerUrl) {
537
558
  errors.push("MCP_SERVER_URL is required when GITLAB_MCP_OAUTH=true (e.g. https://mcp.example.com)");
@@ -986,11 +1007,57 @@ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
986
1007
  "Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
987
1008
  }
988
1009
  const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10;
1010
+ function toAllowedGitLabApiUrl(value) {
1011
+ const trimmed = value.trim();
1012
+ if (!trimmed)
1013
+ return null;
1014
+ try {
1015
+ const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
1016
+ if (url.protocol !== "http:" && url.protocol !== "https:")
1017
+ return null;
1018
+ return { host: url.host, apiUrl: normalizeGitLabApiUrl(url.toString()) };
1019
+ }
1020
+ catch {
1021
+ return null;
1022
+ }
1023
+ }
1024
+ function parseAllowedGitLabApiUrls(value) {
1025
+ return value
1026
+ .split(",")
1027
+ .map(toAllowedGitLabApiUrl)
1028
+ .filter((entry) => Boolean(entry));
1029
+ }
1030
+ function encodeGitLabPathSegment(value) {
1031
+ return encodeURIComponent(decodeURIComponent(value));
1032
+ }
1033
+ function encodeGitLabPath(value) {
1034
+ return value.split("/").map(encodeGitLabPathSegment).join("/");
1035
+ }
1036
+ function resolveTrustedGitLabApiUrl(value) {
1037
+ const parsed = new URL(normalizeGitLabApiUrl(value));
1038
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1039
+ throw new Error("GitLab API URL must use HTTP or HTTPS");
1040
+ }
1041
+ const allowedApiUrl = GITLAB_ALLOWED_API_URLS_BY_HOST.get(parsed.host);
1042
+ if (!allowedApiUrl) {
1043
+ throw new Error(`GitLab API URL host is not allowed: ${parsed.host}`);
1044
+ }
1045
+ return allowedApiUrl;
1046
+ }
989
1047
  // Use the normalizeGitLabApiUrl function to handle various URL formats
990
1048
  const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
991
1049
  .split(",")
992
1050
  .map(normalizeGitLabApiUrl);
993
1051
  const GITLAB_API_URL = GITLAB_API_URLS[0];
1052
+ const GITLAB_ALLOWED_API_URLS_BY_HOST = new Map();
1053
+ for (const { host, apiUrl } of [
1054
+ ...GITLAB_API_URLS.map(toAllowedGitLabApiUrl).filter((entry) => Boolean(entry)),
1055
+ ...parseAllowedGitLabApiUrls(getConfig("allowed-hosts", "GITLAB_ALLOWED_HOSTS") || ""),
1056
+ ]) {
1057
+ if (!GITLAB_ALLOWED_API_URLS_BY_HOST.has(host)) {
1058
+ GITLAB_ALLOWED_API_URLS_BY_HOST.set(host, apiUrl);
1059
+ }
1060
+ }
994
1061
  const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
995
1062
  const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(",")
996
1063
  .map(id => id.trim())
@@ -8844,18 +8911,15 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
8844
8911
  return;
8845
8912
  }
8846
8913
  // API URL: prefer token-embedded URL, then X-GitLab-API-URL header, then default
8847
- let apiUrl = tokenApiUrl || GITLAB_API_URL;
8848
- if (!tokenApiUrl) {
8849
- const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
8850
- if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
8851
- try {
8852
- new URL(dynamicApiUrl);
8853
- apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
8854
- }
8855
- catch {
8856
- res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
8857
- return;
8858
- }
8914
+ let apiUrl = GITLAB_API_URL;
8915
+ const requestedApiUrl = tokenApiUrl || req.headers["x-gitlab-api-url"]?.trim();
8916
+ if (ENABLE_DYNAMIC_API_URL && requestedApiUrl) {
8917
+ try {
8918
+ apiUrl = resolveTrustedGitLabApiUrl(requestedApiUrl);
8919
+ }
8920
+ catch {
8921
+ res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
8922
+ return;
8859
8923
  }
8860
8924
  }
8861
8925
  const { type } = req.params;
@@ -8869,7 +8933,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
8869
8933
  return;
8870
8934
  }
8871
8935
  const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
8872
- gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${job_id}/artifacts`;
8936
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(job_id)}/artifacts`;
8873
8937
  break;
8874
8938
  }
8875
8939
  case "attachment": {
@@ -8879,7 +8943,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
8879
8943
  return;
8880
8944
  }
8881
8945
  const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
8882
- gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`;
8946
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${encodeGitLabPathSegment(secret)}/${encodeGitLabPath(filename)}`;
8883
8947
  break;
8884
8948
  }
8885
8949
  case "release-asset": {
@@ -8891,7 +8955,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
8891
8955
  return;
8892
8956
  }
8893
8957
  const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
8894
- gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${direct_asset_path}`;
8958
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${encodeGitLabPath(direct_asset_path)}`;
8895
8959
  break;
8896
8960
  }
8897
8961
  default:
@@ -8943,12 +9007,21 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
8943
9007
  */
8944
9008
  async function startSSEServer() {
8945
9009
  const app = express();
9010
+ const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
8946
9011
  if (MCP_TRUST_PROXY) {
8947
9012
  app.set("trust proxy", 1);
8948
9013
  }
9014
+ const requireSseAuth = (req, res, next) => {
9015
+ if (!sseAuthToken)
9016
+ return next();
9017
+ const match = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization || "");
9018
+ if (match?.[1] === sseAuthToken)
9019
+ return next();
9020
+ res.status(401).json({ error: "SSE authentication required" });
9021
+ };
8949
9022
  const transports = {};
8950
9023
  let shuttingDown = false;
8951
- app.get("/sse", async (_, res) => {
9024
+ app.get("/sse", requireSseAuth, async (_, res) => {
8952
9025
  const serverInstance = createServer();
8953
9026
  const transport = new SSEServerTransport("/messages", res);
8954
9027
  transports[transport.sessionId] = transport;
@@ -8957,7 +9030,7 @@ async function startSSEServer() {
8957
9030
  });
8958
9031
  await serverInstance.connect(transport);
8959
9032
  });
8960
- app.post("/messages", async (req, res) => {
9033
+ app.post("/messages", requireSseAuth, async (req, res) => {
8961
9034
  const sessionId = req.query.sessionId;
8962
9035
  const transport = transports[sessionId];
8963
9036
  if (transport) {
@@ -9081,12 +9154,11 @@ async function startStreamableHTTPServer() {
9081
9154
  // Only process dynamic URL if the feature is enabled
9082
9155
  if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
9083
9156
  try {
9084
- new URL(dynamicApiUrl); // Ensure it's a valid URL format
9085
- apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
9157
+ apiUrl = resolveTrustedGitLabApiUrl(dynamicApiUrl);
9086
9158
  }
9087
9159
  catch {
9088
9160
  logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. Auth will fail.`);
9089
- return null; // Reject if URL is malformed
9161
+ return null; // Reject if URL is malformed or not allowed
9090
9162
  }
9091
9163
  }
9092
9164
  // Extract token — priority: Private-Token > JOB-TOKEN > Authorization Bearer
@@ -0,0 +1,104 @@
1
+ import { after, before, describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { createServer } from "node:http";
4
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
5
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
6
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
7
+ const MOCK_TOKEN = "glpat-dynamic-url-token";
8
+ async function startAttackerServer(port) {
9
+ let hits = 0;
10
+ const server = createServer((_req, res) => {
11
+ hits++;
12
+ res.writeHead(200, { "content-type": "application/json" });
13
+ res.end("[]");
14
+ });
15
+ await new Promise((resolve, reject) => {
16
+ server.once("error", reject);
17
+ server.listen(port, HOST, () => {
18
+ server.off("error", reject);
19
+ resolve();
20
+ });
21
+ });
22
+ return { server, getHits: () => hits };
23
+ }
24
+ describe("Dynamic API URL allowlist", () => {
25
+ let primaryGitLab;
26
+ let secondaryGitLab;
27
+ let attackerServer;
28
+ let mcpServer;
29
+ let mcpUrl;
30
+ let secondaryHit = false;
31
+ let getAttackerHits = () => 0;
32
+ before(async () => {
33
+ const primaryPort = await findMockServerPort(9100);
34
+ primaryGitLab = new MockGitLabServer({ port: primaryPort, validTokens: [MOCK_TOKEN] });
35
+ await primaryGitLab.start();
36
+ const secondaryPort = await findMockServerPort(9200);
37
+ secondaryGitLab = new MockGitLabServer({ port: secondaryPort, validTokens: [MOCK_TOKEN] });
38
+ secondaryGitLab.addMockHandler("get", "/projects/1/issues", (_req, res) => {
39
+ secondaryHit = true;
40
+ res.json([]);
41
+ });
42
+ await secondaryGitLab.start();
43
+ const attackerPort = await findAvailablePort(9300);
44
+ const attacker = await startAttackerServer(attackerPort);
45
+ attackerServer = attacker.server;
46
+ getAttackerHits = attacker.getHits;
47
+ const mcpPort = await findAvailablePort(3100);
48
+ mcpServer = await launchServer({
49
+ mode: TransportMode.STREAMABLE_HTTP,
50
+ port: mcpPort,
51
+ timeout: 5000,
52
+ env: {
53
+ STREAMABLE_HTTP: "true",
54
+ REMOTE_AUTHORIZATION: "true",
55
+ ENABLE_DYNAMIC_API_URL: "true",
56
+ GITLAB_API_URL: `${primaryGitLab.getUrl()}/api/v4`,
57
+ GITLAB_ALLOWED_HOSTS: `${secondaryGitLab.getUrl()}/api/v4`,
58
+ },
59
+ });
60
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
61
+ });
62
+ after(async () => {
63
+ if (mcpServer)
64
+ cleanupServers([mcpServer]);
65
+ await primaryGitLab?.stop();
66
+ await secondaryGitLab?.stop();
67
+ const server = attackerServer;
68
+ if (server)
69
+ await new Promise(resolve => server.close(() => resolve()));
70
+ });
71
+ test("allows dynamic API URLs on configured hosts", async () => {
72
+ const client = new CustomHeaderClient({
73
+ authorization: `Bearer ${MOCK_TOKEN}`,
74
+ "x-gitlab-api-url": `${secondaryGitLab.getUrl()}/api/v4`,
75
+ });
76
+ await client.connect(mcpUrl);
77
+ await client.callTool("list_issues", { project_id: "1" });
78
+ await client.disconnect();
79
+ assert.strictEqual(secondaryHit, true, "allowed dynamic host should receive GitLab calls");
80
+ });
81
+ test("rejects dynamic API URLs on unconfigured hosts before forwarding tokens", async () => {
82
+ const server = attackerServer;
83
+ assert.ok(server, "attacker server should be running");
84
+ const attackerUrl = `http://${HOST}:${server.address().port}/api/v4`;
85
+ const client = new CustomHeaderClient({
86
+ authorization: `Bearer ${MOCK_TOKEN}`,
87
+ "x-gitlab-api-url": attackerUrl,
88
+ });
89
+ let connected = false;
90
+ try {
91
+ await client.connect(mcpUrl);
92
+ connected = true;
93
+ await client.callTool("list_issues", { project_id: "1" });
94
+ }
95
+ catch {
96
+ // Expected: the session is rejected before any GitLab API request is made.
97
+ }
98
+ finally {
99
+ await client.disconnect();
100
+ }
101
+ assert.strictEqual(connected, false, "untrusted dynamic host should not initialize a session");
102
+ assert.strictEqual(getAttackerHits(), 0, "token-bearing requests must not reach untrusted hosts");
103
+ });
104
+ });
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { afterEach, describe, test } from "node:test";
5
+ import * as path from "node:path";
6
+ import { findAvailablePort } from "./utils/server-launcher.js";
7
+ const ERROR_MESSAGE = "SSE=true on a non-loopback HOST requires SSE_AUTH_TOKEN (or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)";
8
+ const LOOPBACK = "127.0.0.1";
9
+ const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
10
+ const running = new Set();
11
+ function startSseServer(env, port) {
12
+ const child = spawn("node", [SERVER_PATH], {
13
+ env: {
14
+ ...process.env,
15
+ GITLAB_API_URL: "https://gitlab.example.com/api/v4",
16
+ HOST: "0.0.0.0",
17
+ PORT: String(port),
18
+ SSE: "true",
19
+ STREAMABLE_HTTP: "false",
20
+ REMOTE_AUTHORIZATION: "false",
21
+ GITLAB_MCP_OAUTH: "false",
22
+ GITLAB_USE_OAUTH: "false",
23
+ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
24
+ GITLAB_JOB_TOKEN: "",
25
+ GITLAB_AUTH_COOKIE_PATH: "",
26
+ ...env,
27
+ },
28
+ stdio: ["ignore", "pipe", "pipe"],
29
+ });
30
+ running.add(child);
31
+ child.once("exit", () => running.delete(child));
32
+ return child;
33
+ }
34
+ async function waitForExit(child, timeoutMs = 5000) {
35
+ let output = "";
36
+ child.stdout?.on("data", chunk => {
37
+ output += chunk.toString();
38
+ });
39
+ child.stderr?.on("data", chunk => {
40
+ output += chunk.toString();
41
+ });
42
+ let timeoutHandle;
43
+ const timeout = new Promise((_, reject) => {
44
+ timeoutHandle = setTimeout(() => reject(new Error(`server did not exit within ${timeoutMs}ms`)), timeoutMs);
45
+ });
46
+ try {
47
+ const [code] = (await Promise.race([once(child, "exit"), timeout]));
48
+ return { code, output };
49
+ }
50
+ finally {
51
+ clearTimeout(timeoutHandle);
52
+ }
53
+ }
54
+ async function waitForHealth(port, timeoutMs = 5000) {
55
+ const deadline = Date.now() + timeoutMs;
56
+ let lastError;
57
+ while (Date.now() < deadline) {
58
+ try {
59
+ const response = await fetch(`http://${LOOPBACK}:${port}/health`);
60
+ if (response.ok)
61
+ return;
62
+ }
63
+ catch (error) {
64
+ lastError = error;
65
+ }
66
+ await new Promise(resolve => setTimeout(resolve, 100));
67
+ }
68
+ throw new Error(`server did not become healthy: ${String(lastError)}`);
69
+ }
70
+ afterEach(() => {
71
+ for (const child of running) {
72
+ if (!child.killed)
73
+ child.kill("SIGTERM");
74
+ }
75
+ running.clear();
76
+ });
77
+ describe("SSE remote auth guard", () => {
78
+ test("refuses unauthenticated SSE on non-loopback hosts", async () => {
79
+ const port = await findAvailablePort(4400);
80
+ const child = startSseServer({}, port);
81
+ const { code, output } = await waitForExit(child);
82
+ assert.notEqual(code, 0);
83
+ assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
84
+ });
85
+ test("requires bearer auth on SSE endpoints when SSE_AUTH_TOKEN is configured", async () => {
86
+ const port = await findAvailablePort(4410);
87
+ startSseServer({ SSE_AUTH_TOKEN: "mcp_sse_secret" }, port);
88
+ await waitForHealth(port);
89
+ const sseResponse = await fetch(`http://${LOOPBACK}:${port}/sse`);
90
+ assert.equal(sseResponse.status, 401);
91
+ const unauthenticatedMessage = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=missing`, { method: "POST" });
92
+ assert.equal(unauthenticatedMessage.status, 401);
93
+ const authenticatedMessage = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=missing`, { method: "POST", headers: { Authorization: "Bearer mcp_sse_secret" } });
94
+ assert.equal(authenticatedMessage.status, 400);
95
+ });
96
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.26",
3
+ "version": "2.1.27",
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": [
@@ -51,7 +51,7 @@
51
51
  "changelog": "auto-changelog -p",
52
52
  "test": "npm run test:all",
53
53
  "test:all": "npm run build && npm run test:mock && npm run test:live",
54
- "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
54
+ "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/dynamic-api-url-allowlist.test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/sse-auth-guard.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
55
55
  "test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
56
56
  "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
57
57
  "test:live": "node test/validate-api.js",