@zereight/mcp-gitlab 2.1.20 → 2.1.22

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
@@ -187,7 +187,7 @@ MCP 서버가 직접 로컬 브라우저 callback을 받을 때만 `GITLAB_OAUTH
187
187
  | `STREAMABLE_HTTP` | 예 | 반드시 `true` |
188
188
  | `GITLAB_OAUTH_CALLBACK_PROXY` | 선택 | MCP 서버의 고정 `/callback` URL을 사용하려면 `true` |
189
189
  | `GITLAB_OAUTH_SCOPES` | 선택 | 쉼표로 구분된 scope 목록(기본값: `api,read_api,read_user`) |
190
- | `GITLAB_ALLOWED_GROUPS` | 선택 | 쉼표로 구분된 GitLab 그룹 전체 경로 — 해당 그룹 및 하위 그룹 멤버만 토큰을 발급받을 수 있음 |
190
+ | `GITLAB_OAUTH_ALLOWED_GROUPS` | 선택 | 쉼표로 구분된 GitLab 그룹 전체 경로 — 해당 그룹 및 하위 그룹 멤버만 토큰을 발급받을 수 있음 (기존 `GITLAB_ALLOWED_GROUPS` 대체) |
191
191
 
192
192
  > **`Unregistered redirect_uri` 문제 해결**
193
193
  >
package/README.md CHANGED
@@ -208,7 +208,7 @@ exchanging credentials with GitLab on behalf of the client.
208
208
  | `STREAMABLE_HTTP` | ✅ | Must be `true` |
209
209
  | `GITLAB_OAUTH_CALLBACK_PROXY` | optional | Set to `true` to use the MCP server's fixed `/callback` URL |
210
210
  | `GITLAB_OAUTH_SCOPES` | optional | Comma-separated scopes (default: `api,read_api,read_user`) |
211
- | `GITLAB_ALLOWED_GROUPS` | optional | Comma-separated group full paths — only members (and subgroup members) may obtain a token |
211
+ | `GITLAB_OAUTH_ALLOWED_GROUPS` | optional | Comma-separated group full paths — only members (and subgroup members) may obtain a token (replaces deprecated `GITLAB_ALLOWED_GROUPS`) |
212
212
 
213
213
  When `STREAMABLE_HTTP=true`, server-side `GITLAB_PERSONAL_ACCESS_TOKEN` or `GITLAB_JOB_TOKEN` require `REMOTE_AUTHORIZATION=true` or `GITLAB_MCP_OAUTH=true`.
214
214
 
@@ -265,6 +265,13 @@ the token to GitLab on behalf of the caller.
265
265
  | `REMOTE_AUTHORIZATION` | ✅ | Set to `true` to enable |
266
266
  | `STREAMABLE_HTTP` | ✅ | Must be `true` |
267
267
  | `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
268
+ | `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers for public download URLs when deployed behind a trusted reverse proxy |
269
+
270
+ When `MCP_SERVER_URL` is not set, remote download URLs fall back to the local
271
+ server address. Set `MCP_TRUST_PROXY=true` only if the server is reachable through a
272
+ trusted reverse proxy and direct client access to the MCP server is blocked.
273
+ This lets the server derive public download URLs from `Forwarded` /
274
+ `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-Prefix`.
268
275
 
269
276
  **Example request headers**:
270
277
 
@@ -299,6 +306,7 @@ Commonly referenced variables:
299
306
  - `GITLAB_PERSONAL_ACCESS_TOKEN`
300
307
  - `GITLAB_USE_OAUTH`
301
308
  - `REMOTE_AUTHORIZATION`
309
+ - `MCP_TRUST_PROXY`
302
310
  - `GITLAB_MCP_OAUTH`
303
311
  - `GITLAB_OAUTH_CALLBACK_PROXY`
304
312
  - `OAUTH_STATELESS_MODE`
package/README.zh-CN.md CHANGED
@@ -187,7 +187,7 @@ OpenCode、MCPJam、Claude.ai 等远程 MCP 客户端可能会在授权时发送
187
187
  | `STREAMABLE_HTTP` | 是 | 必须为 `true` |
188
188
  | `GITLAB_OAUTH_CALLBACK_PROXY` | 可选 | 设置为 `true` 时使用 MCP 服务器固定的 `/callback` URL |
189
189
  | `GITLAB_OAUTH_SCOPES` | 可选 | 逗号分隔的 scope(默认:`api,read_api,read_user`) |
190
- | `GITLAB_ALLOWED_GROUPS` | 可选 | 逗号分隔的 GitLab 群组完整路径 — 仅该群组及其子群组的成员可获取令牌 |
190
+ | `GITLAB_OAUTH_ALLOWED_GROUPS` | 可选 | 逗号分隔的 GitLab 群组完整路径 — 仅该群组及其子群组的成员可获取令牌(替代已废弃的 `GITLAB_ALLOWED_GROUPS`)|
191
191
 
192
192
  > **排查 `Unregistered redirect_uri`**
193
193
  >
package/build/config.js CHANGED
@@ -47,6 +47,7 @@ export const SSE = getConfig("sse", "SSE") === "true";
47
47
  export const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
48
48
  export const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
49
49
  export const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
50
+ export const MCP_TRUST_PROXY = getConfig("mcp-trust-proxy", "MCP_TRUST_PROXY") === "true";
50
51
  // ---------------------------------------------------------------------------
51
52
  // OAuth / MCP OAuth
52
53
  // ---------------------------------------------------------------------------
@@ -57,8 +58,13 @@ export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
57
58
  ? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
58
59
  : undefined;
59
60
  export const GITLAB_OAUTH_CALLBACK_PROXY = getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true";
60
- export const GITLAB_ALLOWED_GROUPS = (() => {
61
- const raw = getConfig("allowed-groups", "GITLAB_ALLOWED_GROUPS");
61
+ /** @deprecated Use GITLAB_OAUTH_ALLOWED_GROUPS_RAW instead. Will be removed in the next major version. */
62
+ export const GITLAB_ALLOWED_GROUPS_RAW = getConfig("allowed-groups", "GITLAB_ALLOWED_GROUPS");
63
+ export const GITLAB_OAUTH_ALLOWED_GROUPS_RAW = getConfig("oauth-allowed-groups", "GITLAB_OAUTH_ALLOWED_GROUPS");
64
+ export const GITLAB_OAUTH_ALLOWED_GROUPS = (() => {
65
+ const newVar = GITLAB_OAUTH_ALLOWED_GROUPS_RAW;
66
+ const oldVar = GITLAB_ALLOWED_GROUPS_RAW;
67
+ const raw = newVar ?? oldVar;
62
68
  if (!raw)
63
69
  return undefined;
64
70
  const groups = raw.split(",").map((g) => g.trim()).filter(Boolean);
package/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_ALLOWED_GROUPS, } from "./config.js";
2
+ import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
3
3
  /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
4
4
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
5
5
  /**
@@ -59,17 +59,74 @@ function decryptDownloadToken(tokenStr) {
59
59
  return null;
60
60
  }
61
61
  }
62
+ function getLastHeaderValue(value) {
63
+ const raw = Array.isArray(value) ? value[value.length - 1] : value;
64
+ if (!raw)
65
+ return undefined;
66
+ const parts = raw.split(",").map(part => part.trim()).filter(Boolean);
67
+ return parts.length > 0 ? parts[parts.length - 1] : undefined;
68
+ }
69
+ function unquoteHeaderValue(value) {
70
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
71
+ return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
72
+ }
73
+ return value;
74
+ }
75
+ function getForwardedPublicBaseUrl(req) {
76
+ if (!MCP_TRUST_PROXY)
77
+ return undefined;
78
+ const forwarded = getLastHeaderValue(req.headers.forwarded);
79
+ const forwardedValues = {};
80
+ if (forwarded) {
81
+ for (const part of forwarded.split(";")) {
82
+ const separator = part.indexOf("=");
83
+ if (separator <= 0)
84
+ continue;
85
+ const key = part.slice(0, separator).trim().toLowerCase();
86
+ const value = unquoteHeaderValue(part.slice(separator + 1).trim());
87
+ if (key && value)
88
+ forwardedValues[key] = value;
89
+ }
90
+ }
91
+ const proto = (forwardedValues.proto || getLastHeaderValue(req.headers["x-forwarded-proto"]))?.toLowerCase();
92
+ const host = forwardedValues.host || getLastHeaderValue(req.headers["x-forwarded-host"]);
93
+ if (!proto || !host || !/^https?$/.test(proto))
94
+ return undefined;
95
+ if (/[\s/\\]/.test(host))
96
+ return undefined;
97
+ const prefix = getLastHeaderValue(req.headers["x-forwarded-prefix"]);
98
+ const safePrefix = prefix &&
99
+ prefix.startsWith("/") &&
100
+ !prefix.startsWith("//") &&
101
+ !prefix.includes("://") &&
102
+ !/[\s\\]/.test(prefix)
103
+ ? prefix.replace(/\/+$/, "")
104
+ : undefined;
105
+ try {
106
+ const baseUrl = new URL(`${proto}://${host}`);
107
+ if (baseUrl.username || baseUrl.password || baseUrl.pathname !== "/" || baseUrl.search || baseUrl.hash) {
108
+ return undefined;
109
+ }
110
+ if (safePrefix)
111
+ baseUrl.pathname = safePrefix;
112
+ return baseUrl.toString().replace(/\/$/, "");
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
62
118
  /**
63
119
  * Build a URL pointing to the download proxy endpoint.
64
120
  * Embeds an encrypted auth token (and API URL for dynamic routing)
65
121
  * from the current session so the URL works standalone.
66
122
  */
67
123
  function buildDownloadUrl(type, params) {
68
- const base = MCP_SERVER_URL || `http://${HOST}:${PORT}`;
124
+ const base = MCP_SERVER_URL || sessionAuthStore.getStore()?.publicBaseUrl || `http://${HOST}:${PORT}`;
69
125
  const baseUrl = new URL(base);
70
126
  // Preserve any path prefix (e.g. /gitlab-mcp) from the base URL
71
127
  const basePath = baseUrl.pathname.replace(/\/+$/, "");
72
- const url = new URL(`${basePath}/downloads/${type}`, baseUrl.origin);
128
+ const safeBasePath = basePath ? `/${basePath.replace(/^\/+/, "")}` : "";
129
+ const url = new URL(`${safeBasePath}/downloads/${type}`, baseUrl.origin);
73
130
  for (const [key, value] of Object.entries(params)) {
74
131
  url.searchParams.set(key, value);
75
132
  }
@@ -128,6 +185,8 @@ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
128
185
  import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
129
186
  import { normalizeGitLabApiUrl } from "./utils/url.js";
130
187
  import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents } from "./utils/helpers.js";
188
+ import { graphqlQueryContainsWriteOperation } from "./utils/graphql-query.js";
189
+ import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
131
190
  import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
132
191
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
133
192
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
@@ -427,6 +486,7 @@ function createServer() {
427
486
  token: authData.token,
428
487
  lastUsed: authData.lastUsed,
429
488
  apiUrl: authData.apiUrl,
489
+ publicBaseUrl: authData.publicBaseUrl,
430
490
  };
431
491
  // Run the handler within the retrieved context
432
492
  const result = await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
@@ -854,6 +914,15 @@ const sessionAuthStore = new AsyncLocalStorage();
854
914
  // Session context map for storing auth data by session ID
855
915
  // This survives async boundaries where AsyncLocalStorage might not
856
916
  const authBySession = {};
917
+ function withPublicBaseUrl(authData, publicBaseUrl, previous) {
918
+ const effectivePublicBaseUrl = publicBaseUrl || previous?.publicBaseUrl;
919
+ return effectivePublicBaseUrl ? { ...authData, publicBaseUrl: effectivePublicBaseUrl } : authData;
920
+ }
921
+ function updateSessionPublicBaseUrl(sessionId, publicBaseUrl) {
922
+ if (sessionId && publicBaseUrl && authBySession[sessionId]) {
923
+ authBySession[sessionId].publicBaseUrl = publicBaseUrl;
924
+ }
925
+ }
857
926
  // Base headers without authentication
858
927
  const BASE_HEADERS = {
859
928
  Accept: "application/json",
@@ -4594,8 +4663,15 @@ async function createWikiPage(projectId, title, content, format) {
4594
4663
  async function updateWikiPage(projectId, slug, title, content, format) {
4595
4664
  projectId = decodeURIComponent(projectId); // Decode project ID
4596
4665
  const body = {};
4597
- if (title)
4598
- body.title = title;
4666
+ if (title) {
4667
+ if (slug.includes("/") && !title.includes("/")) {
4668
+ const existing = await getWikiPage(projectId, slug);
4669
+ body.title = resolveNestedWikiUpdateTitle(slug, title, existing.title);
4670
+ }
4671
+ else {
4672
+ body.title = title;
4673
+ }
4674
+ }
4599
4675
  if (content)
4600
4676
  body.content = content;
4601
4677
  if (format)
@@ -4672,8 +4748,15 @@ async function createGroupWikiPage(groupId, title, content, format) {
4672
4748
  async function updateGroupWikiPage(groupId, slug, title, content, format) {
4673
4749
  groupId = decodeURIComponent(groupId); // Decode group ID
4674
4750
  const body = {};
4675
- if (title)
4676
- body.title = title;
4751
+ if (title) {
4752
+ if (slug.includes("/") && !title.includes("/")) {
4753
+ const existing = await getGroupWikiPage(groupId, slug);
4754
+ body.title = resolveNestedWikiUpdateTitle(slug, title, existing.title);
4755
+ }
4756
+ else {
4757
+ body.title = title;
4758
+ }
4759
+ }
4677
4760
  if (content)
4678
4761
  body.content = content;
4679
4762
  if (format)
@@ -6357,6 +6440,9 @@ async function handleToolCall(params) {
6357
6440
  switch (params.name) {
6358
6441
  case "execute_graphql": {
6359
6442
  const args = ExecuteGraphQLSchema.parse(params.arguments);
6443
+ if (GITLAB_READ_ONLY_MODE && graphqlQueryContainsWriteOperation(args.query)) {
6444
+ throw new Error("execute_graphql does not allow mutation or subscription operations in read-only mode");
6445
+ }
6360
6446
  const apiUrl = new URL(getEffectiveApiUrl());
6361
6447
  // Build GraphQL endpoint preserving any instance subpath (e.g. /gitlab)
6362
6448
  const restPath = apiUrl.pathname || ""; // e.g. /api/v4 or /gitlab/api/v4
@@ -8976,6 +9062,7 @@ async function startStreamableHTTPServer() {
8976
9062
  token: effective.token,
8977
9063
  lastUsed: Date.now(),
8978
9064
  apiUrl: effective.apiUrl,
9065
+ publicBaseUrl: getForwardedPublicBaseUrl(req),
8979
9066
  };
8980
9067
  // Step 4: create a fresh transport per request.
8981
9068
  const transport = isInit
@@ -9025,13 +9112,13 @@ async function startStreamableHTTPServer() {
9025
9112
  });
9026
9113
  };
9027
9114
  // Configure Express middleware
9115
+ if (MCP_TRUST_PROXY) {
9116
+ app.set("trust proxy", 1);
9117
+ }
9028
9118
  app.use(express.json());
9029
9119
  registerDownloadProxy(app);
9030
9120
  // MCP OAuth — mount auth router and prepare bearer-auth middleware
9031
9121
  if (GITLAB_MCP_OAUTH) {
9032
- // Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP.
9033
- // Only enabled in OAuth mode where the server is typically behind a reverse proxy.
9034
- app.set("trust proxy", 1);
9035
9122
  const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
9036
9123
  const issuerUrl = new URL(MCP_SERVER_URL);
9037
9124
  const callbackUrl = GITLAB_OAUTH_CALLBACK_PROXY
@@ -9045,7 +9132,7 @@ async function startStreamableHTTPServer() {
9045
9132
  storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
9046
9133
  }
9047
9134
  : null;
9048
- const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_ALLOWED_GROUPS, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions);
9135
+ const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions);
9049
9136
  const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
9050
9137
  // When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
9051
9138
  // advertises root-level endpoints. Override to use path-prefixed endpoints.
@@ -9159,6 +9246,7 @@ async function startStreamableHTTPServer() {
9159
9246
  // Streamable HTTP endpoint - handles both session creation and message handling
9160
9247
  app.post("/mcp", mcpBearerAuth, async (req, res) => {
9161
9248
  const sessionId = readMcpSessionIdHeader(req);
9249
+ const publicBaseUrl = getForwardedPublicBaseUrl(req);
9162
9250
  // Track request
9163
9251
  metrics.requestsProcessed++;
9164
9252
  // Stateless-mode branch: bypass authBySession / streamableTransports
@@ -9203,19 +9291,20 @@ async function startStreamableHTTPServer() {
9203
9291
  return;
9204
9292
  }
9205
9293
  // Store auth for this session
9206
- authBySession[sessionId] = authData;
9294
+ authBySession[sessionId] = withPublicBaseUrl(authData, publicBaseUrl);
9207
9295
  logger.info(`Session ${sessionId}: stored ${authData.header} header`);
9208
9296
  setAuthTimeout(sessionId);
9209
9297
  }
9210
9298
  else if (sessionId && authData) {
9211
9299
  // Existing session: allow auth rotation/update
9212
- authBySession[sessionId] = authData;
9300
+ authBySession[sessionId] = withPublicBaseUrl(authData, publicBaseUrl, authBySession[sessionId]);
9213
9301
  logger.debug(`Session ${sessionId}: updated ${authData.header} header`);
9214
9302
  setAuthTimeout(sessionId);
9215
9303
  }
9216
9304
  else if (sessionId && authBySession[sessionId]) {
9217
9305
  // Existing session with stored auth: update last used time and reset timeout
9218
9306
  authBySession[sessionId].lastUsed = Date.now();
9307
+ updateSessionPublicBaseUrl(sessionId, publicBaseUrl);
9219
9308
  setAuthTimeout(sessionId);
9220
9309
  }
9221
9310
  else if (!sessionId && !authData) {
@@ -9231,17 +9320,12 @@ async function startStreamableHTTPServer() {
9231
9320
  if (headerAuthData) {
9232
9321
  if (headerAuthData && sessionId) {
9233
9322
  if (!authBySession[sessionId]) {
9234
- authBySession[sessionId] = headerAuthData;
9323
+ authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl);
9235
9324
  logger.info(`Session ${sessionId}: stored ${headerAuthData.header} header (header auth)`);
9236
9325
  setAuthTimeout(sessionId);
9237
9326
  }
9238
9327
  else {
9239
- authBySession[sessionId] = {
9240
- ...authBySession[sessionId],
9241
- header: headerAuthData.header,
9242
- token: headerAuthData.token,
9243
- lastUsed: Date.now(),
9244
- };
9328
+ authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl, authBySession[sessionId]);
9245
9329
  setAuthTimeout(sessionId);
9246
9330
  }
9247
9331
  }
@@ -9255,6 +9339,7 @@ async function startStreamableHTTPServer() {
9255
9339
  token: authInfo.token,
9256
9340
  lastUsed: Date.now(),
9257
9341
  apiUrl: GITLAB_API_URL,
9342
+ publicBaseUrl,
9258
9343
  };
9259
9344
  logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
9260
9345
  setAuthTimeout(sessionId);
@@ -9263,6 +9348,7 @@ async function startStreamableHTTPServer() {
9263
9348
  // Update token on every request — the client may have refreshed it
9264
9349
  authBySession[sessionId].token = authInfo.token;
9265
9350
  authBySession[sessionId].lastUsed = Date.now();
9351
+ updateSessionPublicBaseUrl(sessionId, publicBaseUrl);
9266
9352
  setAuthTimeout(sessionId);
9267
9353
  }
9268
9354
  }
@@ -9290,7 +9376,7 @@ async function startStreamableHTTPServer() {
9290
9376
  if (REMOTE_AUTHORIZATION && !authBySession[newSessionId]) {
9291
9377
  const authData = parseAuthHeaders(req);
9292
9378
  if (authData) {
9293
- authBySession[newSessionId] = authData;
9379
+ authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
9294
9380
  logger.info(`Session ${newSessionId}: stored ${authData.header} header`);
9295
9381
  setAuthTimeout(newSessionId);
9296
9382
  }
@@ -9301,7 +9387,7 @@ async function startStreamableHTTPServer() {
9301
9387
  if (hasHeaderAuth(req)) {
9302
9388
  const authData = parseAuthHeaders(req);
9303
9389
  if (authData) {
9304
- authBySession[newSessionId] = authData;
9390
+ authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
9305
9391
  logger.info(`Session ${newSessionId}: stored ${authData.header} header (header auth)`);
9306
9392
  setAuthTimeout(newSessionId);
9307
9393
  }
@@ -9314,6 +9400,7 @@ async function startStreamableHTTPServer() {
9314
9400
  token: authInfo.token,
9315
9401
  lastUsed: Date.now(),
9316
9402
  apiUrl: GITLAB_API_URL,
9403
+ publicBaseUrl,
9317
9404
  };
9318
9405
  logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
9319
9406
  setAuthTimeout(newSessionId);
@@ -9361,6 +9448,7 @@ async function startStreamableHTTPServer() {
9361
9448
  token: authData.token,
9362
9449
  lastUsed: authData.lastUsed,
9363
9450
  apiUrl: authData.apiUrl,
9451
+ publicBaseUrl: authData.publicBaseUrl,
9364
9452
  };
9365
9453
  // Run the entire request handling within AsyncLocalStorage context
9366
9454
  await sessionAuthStore.run(ctx, handleRequest);
@@ -9532,6 +9620,17 @@ async function runServer() {
9532
9620
  await initializeServerByTransportMode(transportMode);
9533
9621
  logger.info(`Configured GitLab API URLs: ${GITLAB_API_URLS.join(", ")}`);
9534
9622
  logger.info(`Default GitLab API URL: ${GITLAB_API_URL}`);
9623
+ if (GITLAB_ALLOWED_GROUPS_RAW) {
9624
+ if (GITLAB_OAUTH_ALLOWED_GROUPS_RAW) {
9625
+ logger.warn("GITLAB_ALLOWED_GROUPS is set but ignored — GITLAB_OAUTH_ALLOWED_GROUPS takes precedence.");
9626
+ }
9627
+ else {
9628
+ logger.warn("GITLAB_ALLOWED_GROUPS is deprecated. Use GITLAB_OAUTH_ALLOWED_GROUPS instead.");
9629
+ }
9630
+ }
9631
+ if (GITLAB_OAUTH_ALLOWED_GROUPS) {
9632
+ logger.info(`Group access control enabled for: ${GITLAB_OAUTH_ALLOWED_GROUPS.join(", ")}`);
9633
+ }
9535
9634
  }
9536
9635
  catch (error) {
9537
9636
  logger.error("Error initializing server:", error);
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Tests for GITLAB_OAUTH_ALLOWED_GROUPS config resolution.
3
+ *
4
+ * Verifies that:
5
+ * - The new GITLAB_OAUTH_ALLOWED_GROUPS env var is preferred when set.
6
+ * - The deprecated GITLAB_ALLOWED_GROUPS is accepted as a fallback and
7
+ * sets the deprecation flag so callers can emit a warning.
8
+ * - When both are set, the new var wins and GITLAB_ALLOWED_GROUPS_RAW remains
9
+ * set, so callers can emit a "set but ignored" warning.
10
+ *
11
+ * config.ts reads process.env at module load, so each scenario runs in a
12
+ * fresh child process — same pattern as test/stateless/config-ttl.test.ts.
13
+ */
14
+ import assert from "node:assert/strict";
15
+ import { execFileSync } from "node:child_process";
16
+ import * as path from "node:path";
17
+ import * as url from "node:url";
18
+ import { describe, test } from "node:test";
19
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
20
+ const CONFIG_PATH = path.resolve(__dirname, "../config.ts");
21
+ function loadConfig(env) {
22
+ const script = `
23
+ import(${JSON.stringify(url.pathToFileURL(CONFIG_PATH).href)}).then((m) => {
24
+ const out = {
25
+ GITLAB_OAUTH_ALLOWED_GROUPS: m.GITLAB_OAUTH_ALLOWED_GROUPS ?? null,
26
+ GITLAB_OAUTH_ALLOWED_GROUPS_RAW: m.GITLAB_OAUTH_ALLOWED_GROUPS_RAW ?? null,
27
+ GITLAB_ALLOWED_GROUPS_RAW: m.GITLAB_ALLOWED_GROUPS_RAW ?? null,
28
+ };
29
+ process.stdout.write(JSON.stringify(out));
30
+ }).catch((err) => {
31
+ process.stderr.write(String(err && err.stack || err));
32
+ process.exit(2);
33
+ });
34
+ `;
35
+ const childEnv = {
36
+ ...process.env,
37
+ };
38
+ delete childEnv.GITLAB_OAUTH_ALLOWED_GROUPS;
39
+ delete childEnv.GITLAB_ALLOWED_GROUPS;
40
+ for (const [k, v] of Object.entries(env)) {
41
+ if (v === undefined) {
42
+ delete childEnv[k];
43
+ }
44
+ else {
45
+ childEnv[k] = v;
46
+ }
47
+ }
48
+ const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", "--input-type=module", "--eval", script], { env: childEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
49
+ return JSON.parse(stdout);
50
+ }
51
+ describe("config.ts — GITLAB_OAUTH_ALLOWED_GROUPS resolution", () => {
52
+ test("neither var set — resolved value and raws are null", () => {
53
+ const cfg = loadConfig({});
54
+ assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
55
+ assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, null);
56
+ assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, null);
57
+ });
58
+ test("only new var set — resolved correctly, deprecated raw is null", () => {
59
+ const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: "my-org" });
60
+ assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["my-org"]);
61
+ assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, "my-org");
62
+ assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, null);
63
+ });
64
+ test("only deprecated var set — resolved via fallback, deprecated raw is set", () => {
65
+ const cfg = loadConfig({ GITLAB_ALLOWED_GROUPS: "my-org" });
66
+ assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["my-org"]);
67
+ assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, "my-org");
68
+ assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, null);
69
+ });
70
+ test("both vars set — new var wins, both raws are set (triggers 'set but ignored' warning)", () => {
71
+ const cfg = loadConfig({
72
+ GITLAB_OAUTH_ALLOWED_GROUPS: "new-org",
73
+ GITLAB_ALLOWED_GROUPS: "old-org",
74
+ });
75
+ assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["new-org"]);
76
+ assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, "new-org");
77
+ assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, "old-org");
78
+ });
79
+ test("comma-separated values are split and trimmed", () => {
80
+ const cfg = loadConfig({
81
+ GITLAB_OAUTH_ALLOWED_GROUPS: "my-org/team-a , my-org/team-b , my-org",
82
+ });
83
+ assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, [
84
+ "my-org/team-a",
85
+ "my-org/team-b",
86
+ "my-org",
87
+ ]);
88
+ });
89
+ test("empty string resolves to null", () => {
90
+ const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: "" });
91
+ assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
92
+ });
93
+ test("whitespace-only entries are filtered out", () => {
94
+ const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: " , , " });
95
+ assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
96
+ });
97
+ });
@@ -17,6 +17,16 @@ const MOCK_TOKEN = 'glpat-mock-token-12345';
17
17
  const TEST_PROJECT_ID = '123';
18
18
  const TEST_JOB_ID = '456';
19
19
  const TEST_SECRET = 'testsecret';
20
+ const FORWARDED_BASE_URL = 'https://gitlab.mcp.example.test/gitlab-mcp';
21
+ const FORWARDED_HEADERS = {
22
+ 'X-Forwarded-Proto': 'https',
23
+ 'X-Forwarded-Host': 'gitlab.mcp.example.test',
24
+ 'X-Forwarded-Prefix': '/gitlab-mcp',
25
+ };
26
+ const RFC_FORWARDED_HEADERS = {
27
+ 'Forwarded': 'for=192.0.2.43;proto=http;host=attacker.example.test, for="[2001:db8:cafe::17]";proto="https";host="gitlab.mcp.example.test"',
28
+ 'X-Forwarded-Prefix': '/gitlab-mcp',
29
+ };
20
30
  // Minimal 1x1 transparent PNG
21
31
  const MINIMAL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
22
32
  const LARGE_FILE_TOKEN = 'glpat-largefile-test-token';
@@ -92,6 +102,8 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
92
102
  env: {
93
103
  STREAMABLE_HTTP: 'true',
94
104
  REMOTE_AUTHORIZATION: 'true',
105
+ MCP_TRUST_PROXY: 'false',
106
+ MCP_SERVER_URL: '',
95
107
  GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
96
108
  USE_PIPELINE: 'true',
97
109
  MAX_REQUESTS_PER_MINUTE: '2',
@@ -148,6 +160,61 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
148
160
  }
149
161
  assert.ok(got429, 'Should have received 429 within 10 requests (rate limit is 2/min)');
150
162
  });
163
+ test('ignores forwarded headers unless MCP_TRUST_PROXY is enabled', async () => {
164
+ const initRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
165
+ method: 'POST',
166
+ headers: {
167
+ 'Content-Type': 'application/json',
168
+ 'Accept': 'application/json, text/event-stream',
169
+ 'Private-Token': MOCK_TOKEN,
170
+ },
171
+ body: JSON.stringify({
172
+ jsonrpc: '2.0',
173
+ id: 20,
174
+ method: 'initialize',
175
+ params: {
176
+ protocolVersion: '2025-03-26',
177
+ capabilities: {},
178
+ clientInfo: { name: 'test-remote-downloads-no-trust-proxy', version: '1.0' },
179
+ },
180
+ }),
181
+ });
182
+ assert.strictEqual(initRes.status, 200, 'Initialize should succeed');
183
+ const sessionId = initRes.headers.get('mcp-session-id');
184
+ assert.ok(sessionId, 'Should receive a session ID');
185
+ const toolRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
186
+ method: 'POST',
187
+ headers: {
188
+ 'Content-Type': 'application/json',
189
+ 'Accept': 'application/json, text/event-stream',
190
+ ...FORWARDED_HEADERS,
191
+ 'Private-Token': MOCK_TOKEN,
192
+ 'mcp-session-id': sessionId,
193
+ },
194
+ body: JSON.stringify({
195
+ jsonrpc: '2.0',
196
+ id: 21,
197
+ method: 'tools/call',
198
+ params: {
199
+ name: 'download_job_artifacts',
200
+ arguments: {
201
+ project_id: TEST_PROJECT_ID,
202
+ job_id: TEST_JOB_ID,
203
+ },
204
+ },
205
+ }),
206
+ });
207
+ assert.strictEqual(toolRes.status, 200, 'Tool call should return 200');
208
+ const responses = parseSSE(await toolRes.text());
209
+ const result = responses.find(r => r.id === 21);
210
+ assert.ok(result?.result, 'Should have a result');
211
+ const textBlock = result.result.content.find(c => c.type === 'text');
212
+ assert.ok(textBlock?.text, 'Should have text content');
213
+ const parsed = JSON.parse(textBlock.text);
214
+ assert.ok(parsed.download_url, 'Should contain download_url');
215
+ assert.ok(parsed.download_url.startsWith(`http://${HOST}:${serverPort}/downloads/job-artifacts?`), `URL should fall back to local server address when MCP_TRUST_PROXY is disabled, got ${parsed.download_url}`);
216
+ assert.ok(!parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), 'URL should not use forwarded public base URL when MCP_TRUST_PROXY is disabled');
217
+ });
151
218
  });
152
219
  describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000 }, () => {
153
220
  let mockGitLab;
@@ -189,6 +256,8 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000
189
256
  env: {
190
257
  STREAMABLE_HTTP: 'true',
191
258
  REMOTE_AUTHORIZATION: 'true',
259
+ MCP_TRUST_PROXY: 'true',
260
+ MCP_SERVER_URL: '',
192
261
  GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
193
262
  USE_PIPELINE: 'true',
194
263
  },
@@ -222,12 +291,13 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000
222
291
  if (mockGitLab)
223
292
  await mockGitLab.stop();
224
293
  });
225
- async function callTool(id, name, args) {
294
+ async function callTool(id, name, args, extraHeaders = {}) {
226
295
  const res = await fetch(`http://${HOST}:${serverPort}/mcp`, {
227
296
  method: 'POST',
228
297
  headers: {
229
298
  'Content-Type': 'application/json',
230
299
  'Accept': 'application/json, text/event-stream',
300
+ ...extraHeaders,
231
301
  'Private-Token': MOCK_TOKEN,
232
302
  'mcp-session-id': sessionId,
233
303
  },
@@ -269,6 +339,97 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000
269
339
  assert.ok(buf.length > 0, 'Downloaded content should not be empty');
270
340
  assert.ok(buf.includes(Buffer.from('PK')), 'Should contain zip magic bytes');
271
341
  });
342
+ test('download_job_artifacts ignores forwarded hosts containing URL components', async () => {
343
+ const result = await callTool(14, 'download_job_artifacts', {
344
+ project_id: TEST_PROJECT_ID,
345
+ job_id: TEST_JOB_ID,
346
+ }, {
347
+ ...FORWARDED_HEADERS,
348
+ 'X-Forwarded-Host': 'gitlab.mcp.example.test@attacker.example.test',
349
+ });
350
+ assert.ok(result.result, 'Should have a result');
351
+ const content = result.result.content;
352
+ assert.ok(content && content.length > 0, 'Should have content');
353
+ const textBlock = content.find(c => c.type === 'text');
354
+ assert.ok(textBlock?.text, 'Should have text content');
355
+ const parsed = JSON.parse(textBlock.text);
356
+ assert.ok(parsed.download_url, 'Should contain download_url');
357
+ assert.ok(parsed.download_url.startsWith(`http://${HOST}:${serverPort}/downloads/job-artifacts?`), `URL should fall back to local server address for unsafe forwarded host, got ${parsed.download_url}`);
358
+ assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not contain unsafe forwarded host, got ${parsed.download_url}`);
359
+ assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
360
+ });
361
+ test('download_job_artifacts ignores authority-style forwarded prefixes', async () => {
362
+ const result = await callTool(15, 'download_job_artifacts', {
363
+ project_id: TEST_PROJECT_ID,
364
+ job_id: TEST_JOB_ID,
365
+ }, {
366
+ ...FORWARDED_HEADERS,
367
+ 'X-Forwarded-Prefix': '//attacker.example.test',
368
+ });
369
+ assert.ok(result.result, 'Should have a result');
370
+ const content = result.result.content;
371
+ assert.ok(content && content.length > 0, 'Should have content');
372
+ const textBlock = content.find(c => c.type === 'text');
373
+ assert.ok(textBlock?.text, 'Should have text content');
374
+ const parsed = JSON.parse(textBlock.text);
375
+ assert.ok(parsed.download_url, 'Should contain download_url');
376
+ assert.ok(parsed.download_url.startsWith('https://gitlab.mcp.example.test/downloads/job-artifacts?'), `URL should keep forwarded proto/host while ignoring unsafe prefix, got ${parsed.download_url}`);
377
+ assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not contain authority-style prefix, got ${parsed.download_url}`);
378
+ assert.ok(!parsed.download_url.startsWith(`http://${HOST}:${serverPort}`), 'URL should not fall back to local server address');
379
+ assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
380
+ });
381
+ test('download_job_artifacts uses rightmost forwarded header values', async () => {
382
+ const result = await callTool(16, 'download_job_artifacts', {
383
+ project_id: TEST_PROJECT_ID,
384
+ job_id: TEST_JOB_ID,
385
+ }, {
386
+ 'X-Forwarded-Proto': 'http, https',
387
+ 'X-Forwarded-Host': 'attacker.example.test, gitlab.mcp.example.test',
388
+ 'X-Forwarded-Prefix': '/attacker, /gitlab-mcp',
389
+ });
390
+ assert.ok(result.result, 'Should have a result');
391
+ const content = result.result.content;
392
+ assert.ok(content && content.length > 0, 'Should have content');
393
+ const textBlock = content.find(c => c.type === 'text');
394
+ assert.ok(textBlock?.text, 'Should have text content');
395
+ const parsed = JSON.parse(textBlock.text);
396
+ assert.ok(parsed.download_url, 'Should contain download_url');
397
+ assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use the rightmost forwarded header values, got ${parsed.download_url}`);
398
+ assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not use the leftmost untrusted forwarded header value, got ${parsed.download_url}`);
399
+ assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
400
+ });
401
+ test('download_job_artifacts derives download_url from RFC 7239 Forwarded header', async () => {
402
+ const result = await callTool(18, 'download_job_artifacts', {
403
+ project_id: TEST_PROJECT_ID,
404
+ job_id: TEST_JOB_ID,
405
+ }, RFC_FORWARDED_HEADERS);
406
+ assert.ok(result.result, 'Should have a result');
407
+ const content = result.result.content;
408
+ assert.ok(content && content.length > 0, 'Should have content');
409
+ const textBlock = content.find(c => c.type === 'text');
410
+ assert.ok(textBlock?.text, 'Should have text content');
411
+ const parsed = JSON.parse(textBlock.text);
412
+ assert.ok(parsed.download_url, 'Should contain download_url');
413
+ assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use RFC 7239 Forwarded header values, got ${parsed.download_url}`);
414
+ assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not use the leftmost untrusted Forwarded value, got ${parsed.download_url}`);
415
+ assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
416
+ });
417
+ test('download_job_artifacts derives download_url from forwarded headers when MCP_SERVER_URL is unset', async () => {
418
+ const result = await callTool(17, 'download_job_artifacts', {
419
+ project_id: TEST_PROJECT_ID,
420
+ job_id: TEST_JOB_ID,
421
+ }, FORWARDED_HEADERS);
422
+ assert.ok(result.result, 'Should have a result');
423
+ const content = result.result.content;
424
+ assert.ok(content && content.length > 0, 'Should have content');
425
+ const textBlock = content.find(c => c.type === 'text');
426
+ assert.ok(textBlock?.text, 'Should have text content');
427
+ const parsed = JSON.parse(textBlock.text);
428
+ assert.ok(parsed.download_url, 'Should contain download_url');
429
+ assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use forwarded public base URL, got ${parsed.download_url}`);
430
+ assert.ok(!parsed.download_url.startsWith(`http://${HOST}:${serverPort}`), 'URL should not fall back to local server address');
431
+ assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
432
+ });
272
433
  test('download_attachment for non-image returns download_url', async () => {
273
434
  const result = await callTool(11, 'download_attachment', {
274
435
  project_id: TEST_PROJECT_ID,
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { graphqlQueryContainsWriteOperation } from "../../utils/graphql-query.js";
4
+ describe("When graphqlQueryContainsWriteOperation runs", () => {
5
+ describe("with read-only GraphQL documents", () => {
6
+ test("should allow explicit query operations", () => {
7
+ assert.equal(graphqlQueryContainsWriteOperation("query { project(fullPath: \"g/p\") { id } }"), false);
8
+ });
9
+ test("should allow shorthand query operations", () => {
10
+ assert.equal(graphqlQueryContainsWriteOperation("{ project { id } }"), false);
11
+ });
12
+ test("should ignore mutation text inside comments", () => {
13
+ assert.equal(graphqlQueryContainsWriteOperation("# mutation destroy\nquery { project { id } }"), false);
14
+ });
15
+ test("should ignore mutation text inside string literals", () => {
16
+ assert.equal(graphqlQueryContainsWriteOperation('query { search(query: "mutation") { nodes { id } } }'), false);
17
+ });
18
+ });
19
+ describe("with write GraphQL documents", () => {
20
+ test("should detect mutation operations", () => {
21
+ assert.equal(graphqlQueryContainsWriteOperation('mutation { destroyProject(input: { projectId: "gid://gitlab/Project/1" }) { errors } }'), true);
22
+ });
23
+ test("should detect subscription operations", () => {
24
+ assert.equal(graphqlQueryContainsWriteOperation("subscription { mergeRequestCreated { id } }"), true);
25
+ });
26
+ test("should detect write operations in multi-operation documents", () => {
27
+ assert.equal(graphqlQueryContainsWriteOperation("query A { a } mutation B { b }"), true);
28
+ });
29
+ test("should detect semicolon-separated write operations", () => {
30
+ assert.equal(graphqlQueryContainsWriteOperation("query A { a }; mutation B { b }"), true);
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { resolveNestedWikiUpdateTitle } from "../../utils/wiki-title.js";
4
+ describe("When resolveNestedWikiUpdateTitle runs", () => {
5
+ describe("with nested wiki slugs", () => {
6
+ test("should prefix leaf titles using the existing hierarchical title", () => {
7
+ assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "infra servers v2", "00-map/infra servers"), "00-map/infra servers v2");
8
+ });
9
+ test("should prefix leaf titles using the slug parent when the existing title is flat", () => {
10
+ assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "infra servers v2", "infra servers"), "00-map/infra servers v2");
11
+ });
12
+ test("should keep full hierarchical titles unchanged", () => {
13
+ assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "00-map/infra servers v2", "00-map/infra servers"), "00-map/infra servers v2");
14
+ });
15
+ });
16
+ describe("with flat wiki slugs", () => {
17
+ test("should keep leaf titles unchanged", () => {
18
+ assert.equal(resolveNestedWikiUpdateTitle("infra-servers", "infra servers v2", "infra servers"), "infra servers v2");
19
+ });
20
+ });
21
+ });
@@ -0,0 +1,52 @@
1
+ function stripGraphQLCommentsAndStrings(source) {
2
+ let result = "";
3
+ let i = 0;
4
+ while (i < source.length) {
5
+ const ch = source[i];
6
+ if (ch === "#") {
7
+ while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
8
+ i++;
9
+ }
10
+ result += " ";
11
+ continue;
12
+ }
13
+ if (ch === '"' || ch === "'") {
14
+ const quote = ch;
15
+ i++;
16
+ while (i < source.length) {
17
+ if (source[i] === "\\") {
18
+ i = Math.min(i + 2, source.length);
19
+ continue;
20
+ }
21
+ if (source[i] === quote) {
22
+ i++;
23
+ break;
24
+ }
25
+ i++;
26
+ }
27
+ result += " ";
28
+ continue;
29
+ }
30
+ if (source.slice(i, i + 3) === '"""') {
31
+ i += 3;
32
+ while (i < source.length && source.slice(i, i + 3) !== '"""') {
33
+ i++;
34
+ }
35
+ if (i < source.length) {
36
+ i += 3;
37
+ }
38
+ result += " ";
39
+ continue;
40
+ }
41
+ result += ch;
42
+ i++;
43
+ }
44
+ return result;
45
+ }
46
+ export function graphqlQueryContainsWriteOperation(query) {
47
+ const normalized = stripGraphQLCommentsAndStrings(query).trim();
48
+ if (!normalized) {
49
+ return false;
50
+ }
51
+ return /(?:^|[};]\s*)(mutation|subscription)\b/.test(normalized);
52
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Preserve nested wiki hierarchy when callers pass a leaf-only title on update.
3
+ */
4
+ export function resolveNestedWikiUpdateTitle(slug, providedTitle, existingTitle) {
5
+ if (providedTitle.includes("/") || !slug.includes("/")) {
6
+ return providedTitle;
7
+ }
8
+ const titleParentIndex = existingTitle.lastIndexOf("/");
9
+ if (titleParentIndex >= 0) {
10
+ return `${existingTitle.slice(0, titleParentIndex)}/${providedTitle}`;
11
+ }
12
+ const slugParentIndex = slug.lastIndexOf("/");
13
+ if (slugParentIndex >= 0) {
14
+ return `${slug.slice(0, slugParentIndex)}/${providedTitle}`;
15
+ }
16
+ return providedTitle;
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.20",
3
+ "version": "2.1.22",
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/streamable-http-static-token-auth.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-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-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-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/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/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.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-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-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/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",