@zereight/mcp-gitlab 2.1.46 → 2.1.47

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
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
82
82
 
83
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.45`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
83
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.46`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
84
84
 
85
85
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
86
86
 
package/README.md CHANGED
@@ -82,7 +82,7 @@ npm install -g @zereight/mcp-gitlab
82
82
 
83
83
  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`.
84
84
 
85
- No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.45`. 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`).
85
+ No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.46`. 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`).
86
86
 
87
87
  #### Using CLI Arguments (for clients with env var issues)
88
88
 
package/README.zh-CN.md CHANGED
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
80
80
 
81
81
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
82
82
 
83
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.45`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
83
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.46`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
84
84
 
85
85
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
86
86
 
package/build/config.js CHANGED
@@ -127,7 +127,7 @@ function _intEnv(name, cliKey, fallback, safeFallback = fallback) {
127
127
  }
128
128
  export const OAUTH_STATELESS_CLIENT_TTL_SECONDS = _intEnv("OAUTH_STATELESS_CLIENT_TTL_SECONDS", "oauth-stateless-client-ttl", 86_400);
129
129
  export const OAUTH_STATELESS_PENDING_TTL_SECONDS = _intEnv("OAUTH_STATELESS_PENDING_TTL_SECONDS", "oauth-stateless-pending-ttl", 600);
130
- export const OAUTH_STATELESS_STORED_TTL_SECONDS = _intEnv("OAUTH_STATELESS_STORED_TTL_SECONDS", "oauth-stateless-stored-ttl", 600);
130
+ export const OAUTH_STATELESS_STORED_TTL_SECONDS = _intEnv("OAUTH_STATELESS_STORED_TTL_SECONDS", "oauth-stateless-stored-ttl", 120);
131
131
  // ---------------------------------------------------------------------------
132
132
  // Session / server settings
133
133
  // ---------------------------------------------------------------------------
package/build/index.js CHANGED
@@ -85,8 +85,10 @@ import { createDownloadToken } from "./utils/download-token.js";
85
85
  import { determineTransportMode, TransportMode } from "./server/transport-mode.js";
86
86
  import { formatPrometheusMetrics } from "./server/metrics.js";
87
87
  import { SERVER_VERSION } from "./server/version.js";
88
+ import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
89
+ export { readMcpSessionIdHeader } from "./server/request-helpers.js";
88
90
  import { normalizeGitLabApiUrl } from "./utils/url.js";
89
- import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents, } from "./utils/helpers.js";
91
+ import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
90
92
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
91
93
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
92
94
  import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
@@ -635,71 +637,6 @@ function validateConfiguration() {
635
637
  }
636
638
  let OAUTH_ACCESS_TOKEN = null;
637
639
  let oauthClient = null;
638
- /**
639
- * Produce a safe short form of a session id for logs. Legacy UUIDs pass
640
- * through; stateless sealed sids are truncated to the "v1.sid." prefix
641
- * plus a handful of bytes of the ciphertext so operators can correlate
642
- * flows without the log line carrying the sealed bearer token.
643
- */
644
- function redactSessionIdForLog(sid) {
645
- if (!sid)
646
- return "<none>";
647
- if (sid.startsWith("v1.sid."))
648
- return "v1.sid.<redacted>";
649
- // UUIDs / other formats are low-sensitivity; show first 8 chars.
650
- return sid.length > 8 ? `${sid.slice(0, 8)}…` : sid;
651
- }
652
- /**
653
- * Detect whether an MCP JSON-RPC request body represents an "initialize"
654
- * request. Accepts both single-message and batch forms. Returns false on any
655
- * unexpected shape — callers treat an ambiguous body as non-init, which is
656
- * the safer default (it means the SDK will fail loudly rather than silently
657
- * spawning a new session).
658
- */
659
- function isInitializationRequestBody(body) {
660
- if (!body)
661
- return false;
662
- const isInitObj = (m) => typeof m === "object" && m !== null && m.method === "initialize";
663
- if (Array.isArray(body))
664
- return body.some(isInitObj);
665
- return isInitObj(body);
666
- }
667
- function isUnauthenticatedDiscoveryRequestBody(body) {
668
- if (!body)
669
- return false;
670
- const isDiscoveryMethod = (m) => {
671
- if (typeof m !== "object" || m === null)
672
- return false;
673
- const method = m.method;
674
- return (method === "initialize" || method === "notifications/initialized" || method === "tools/list");
675
- };
676
- if (Array.isArray(body))
677
- return body.every(isDiscoveryMethod);
678
- return isDiscoveryMethod(body);
679
- }
680
- /**
681
- * Normalize an `Mcp-Session-Id` header value.
682
- *
683
- * Node's HTTP types allow any request header to surface as `string[]` when
684
- * the client sends it more than once. Casting to `string` and calling
685
- * `.startsWith()` on an array throws `TypeError: startsWith is not a
686
- * function`, which Express converts to a 500 — silently turning malformed
687
- * requests into server errors and breaking the 401/404 semantics we
688
- * carefully distinguish in stateless mode. A duplicated `Mcp-Session-Id` is
689
- * also ill-formed at the protocol level: there is no well-defined way to
690
- * pick between two values, so we reject arrays rather than guess.
691
- *
692
- * Empty-string is normalized to `undefined` so call sites can use truthy
693
- * checks and `?? undefined`-style fallbacks uniformly.
694
- *
695
- * Exported for unit tests; otherwise used only by the /mcp handlers below.
696
- */
697
- export function readMcpSessionIdHeader(req) {
698
- const raw = req.headers["mcp-session-id"];
699
- if (typeof raw !== "string")
700
- return undefined;
701
- return raw.length > 0 ? raw : undefined;
702
- }
703
640
  /**
704
641
  * Loaded once at startup. Null when OAUTH_STATELESS_MODE is disabled.
705
642
  * When set, the OAuth provider and (later phases) the Mcp-Session-Id path
@@ -983,7 +920,7 @@ const BASE_HEADERS = {
983
920
  function buildAuthHeaders() {
984
921
  if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
985
922
  const ctx = sessionAuthStore.getStore();
986
- logger.debug({ context: ctx }, "buildAuthHeaders: session context");
923
+ logger.debug({ sessionId: ctx?.sessionId, header: ctx?.header, apiUrl: ctx?.apiUrl }, "buildAuthHeaders: session context");
987
924
  if (ctx?.token) {
988
925
  return {
989
926
  [ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
@@ -1028,7 +965,7 @@ function getEffectiveApiUrl() {
1028
965
  if (ctx?.apiUrl) {
1029
966
  return ctx.apiUrl;
1030
967
  }
1031
- logger.warn({ ctx }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
968
+ logger.warn({ sessionId: ctx?.sessionId, header: ctx?.header, apiUrl: ctx?.apiUrl }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
1032
969
  }
1033
970
  return GITLAB_API_URL;
1034
971
  }
@@ -1329,10 +1266,14 @@ async function getFileContents(projectId, filePath, ref) {
1329
1266
  await handleGitLabError(response);
1330
1267
  const data = await response.json();
1331
1268
  const parsedData = GitLabContentSchema.parse(data);
1332
- // Decode Base64-encoded file content to UTF-8
1333
- if (!Array.isArray(parsedData) && parsedData.content) {
1334
- parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8");
1335
- parsedData.encoding = "utf8";
1269
+ // Decode Base64-encoded text files while preserving binary content losslessly.
1270
+ if (!Array.isArray(parsedData) && typeof parsedData.content === "string") {
1271
+ const decodedContent = Buffer.from(parsedData.content, "base64");
1272
+ const utf8Content = decodedContent.toString("utf8");
1273
+ if (Buffer.from(utf8Content, "utf8").equals(decodedContent)) {
1274
+ parsedData.content = utf8Content;
1275
+ parsedData.encoding = "utf8";
1276
+ }
1336
1277
  }
1337
1278
  return parsedData;
1338
1279
  }
@@ -4108,13 +4049,33 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
4108
4049
  ...getFetchConfig(),
4109
4050
  method: "GET",
4110
4051
  });
4111
- if (approvalStateResponse.status === 404) {
4052
+ // 404 when the endpoint is unavailable; 402/403 when the instance or namespace is
4053
+ // not licensed for approval rules, which are a paid-tier feature.
4054
+ if (approvalStateResponse.status === 404 ||
4055
+ approvalStateResponse.status === 402 ||
4056
+ approvalStateResponse.status === 403) {
4112
4057
  return getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
4113
4058
  }
4114
4059
  await handleGitLabError(approvalStateResponse);
4115
4060
  const parsedApprovalState = GitLabMergeRequestApprovalStateSchema.parse(await approvalStateResponse.json());
4116
4061
  const approvedByUsers = getUniqueApprovalUsers((parsedApprovalState.rules || []).flatMap(rule => rule.approved_by || []));
4117
4062
  const approvedByUsernames = approvedByUsers.map(user => user.username);
4063
+ // approval_state only reports approvals attributed to rules, so a project with no
4064
+ // approval rules answers 200 with `rules: []` and yields no approvers even when
4065
+ // someone has approved. Read /approvals in that case. A non-empty rules array means
4066
+ // rules are in use and approval_state is authoritative, so no extra request is made
4067
+ // there - including while an MR is still waiting for its first approval.
4068
+ if ((parsedApprovalState.rules || []).length === 0) {
4069
+ try {
4070
+ const viaApprovals = await getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
4071
+ if ((viaApprovals.approved_by ?? []).length > 0) {
4072
+ return viaApprovals;
4073
+ }
4074
+ }
4075
+ catch {
4076
+ // /approvals unavailable as well - keep the approval_state answer below.
4077
+ }
4078
+ }
4118
4079
  return {
4119
4080
  ...parsedApprovalState,
4120
4081
  approved_by: approvedByUsers,
@@ -5319,12 +5280,11 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
5319
5280
  }
5320
5281
  await handleGitLabError(response);
5321
5282
  const filename = `artifacts_job_${encodeGitLabPathSegment(jobId)}.zip`;
5322
- const savePath = localPath ? path.join(localPath, filename) : filename;
5323
- fs.mkdirSync(path.dirname(savePath), { recursive: true });
5324
5283
  if (!response.body) {
5325
5284
  throw new Error("No response body from GitLab");
5326
5285
  }
5327
- await streamPipeline(response.body, fs.createWriteStream(savePath));
5286
+ const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(filename, localPath, "local_path");
5287
+ await streamPipeline(response.body, saveStream);
5328
5288
  return savePath;
5329
5289
  }
5330
5290
  /**
@@ -6722,12 +6682,10 @@ async function markdownUpload(projectId, filePath, content, filename) {
6722
6682
  fileName = filename || "upload";
6723
6683
  }
6724
6684
  else if (filePath) {
6725
- // Local file mode
6726
- if (!fs.existsSync(filePath)) {
6727
- throw new Error(`File not found: ${filePath}`);
6728
- }
6729
- fileBuffer = fs.readFileSync(filePath);
6730
- fileName = path.basename(filePath);
6685
+ // Local file mode — reject absolute/traversal/symlink escapes before reading
6686
+ const { buffer, basename: safeBasename } = readSafeExistingFile(filePath, "file_path");
6687
+ fileBuffer = buffer;
6688
+ fileName = safeBasename;
6731
6689
  }
6732
6690
  else {
6733
6691
  throw new Error("Either file_path or content must be provided");
@@ -6792,29 +6750,12 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
6792
6750
  // For non-image files, always save to disk.
6793
6751
  // For image files, only save to disk if local_path is explicitly provided.
6794
6752
  if (!mimeType || localPath) {
6795
- let savePath;
6796
- if (localPath) {
6797
- const normalizedLocalPath = path.normalize(localPath);
6798
- if (path.isAbsolute(normalizedLocalPath) ||
6799
- normalizedLocalPath === ".." ||
6800
- normalizedLocalPath.startsWith(".." + path.sep) ||
6801
- normalizedLocalPath.includes(path.sep + ".." + path.sep)) {
6802
- throw new Error("Invalid local_path: directory traversal is not allowed.");
6803
- }
6804
- savePath = path.join(normalizedLocalPath, safeFilename);
6805
- }
6806
- else {
6807
- savePath = safeFilename;
6808
- }
6809
- const dir = path.dirname(savePath);
6810
- if (!fs.existsSync(dir)) {
6811
- fs.mkdirSync(dir, { recursive: true });
6812
- }
6813
- // Stream directly to disk instead of buffering in memory
6814
6753
  if (!response.body) {
6815
6754
  throw new Error("No response body from GitLab");
6816
6755
  }
6817
- await streamPipeline(response.body, fs.createWriteStream(savePath));
6756
+ const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(safeFilename, localPath, "local_path");
6757
+ // Stream directly to disk instead of buffering in memory
6758
+ await streamPipeline(response.body, saveStream);
6818
6759
  return { buffer: Buffer.alloc(0), filename: safeFilename, mimeType, savedPath: savePath };
6819
6760
  }
6820
6761
  // Images returned inline — buffer into memory for base64 encoding
@@ -9531,7 +9472,9 @@ async function handleToolCall(params) {
9531
9472
  }
9532
9473
  }
9533
9474
  catch (error) {
9534
- logger.debug(params);
9475
+ // Log tool name only — never dump raw params (may contain approval_password).
9476
+ // Sensitive fields are also covered by REDACT_PATHS if arguments are logged elsewhere.
9477
+ logger.debug({ tool: params.name }, "Tool call failed");
9535
9478
  if (error instanceof z.ZodError) {
9536
9479
  throw new Error(`Invalid arguments: ${error.errors
9537
9480
  .map(e => `${e.path.join(".")}: ${e.message}`)
@@ -49,7 +49,7 @@ import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
49
49
  import { randomUUID, randomBytes, createHash } from "node:crypto";
50
50
  import { looksLikeStatelessClientId, mintClientId, openClientId, } from "./stateless/client-id.js";
51
51
  import { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./stateless/pending-auth.js";
52
- import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stateless/stored-tokens.js";
52
+ import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, ConsumedProxyCodeCache, PROXY_CODE_CACHE_FULL, } from "./stateless/index.js";
53
53
  import { createLogger } from "./utils/logger.js";
54
54
  const logger = createLogger("gitlab-mcp-oauth-proxy");
55
55
  // ---------------------------------------------------------------------------
@@ -129,6 +129,13 @@ class GitLabOAuthServerProvider {
129
129
  _callbackUrl;
130
130
  _pendingAuth = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
131
131
  _storedTokens = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
132
+ /**
133
+ * Per-pod replay-prevention cache for sealed (stateless) proxy authorization
134
+ * codes. Keys are SHA-256 hashes of the code; entries are TTL-bound (never
135
+ * LRU-evicted early) and hold pending→consumed state. Cross-pod replay
136
+ * remains mitigated by the short stored-code TTL + PKCE.
137
+ */
138
+ _usedProxyCodes = new ConsumedProxyCodeCache(PENDING_AUTH_MAX_SIZE);
132
139
  // Stateless mode (optional). When set, DCR and callback-proxy state are
133
140
  // serialised into opaque OAuth values and the in-memory caches above are
134
141
  // bypassed. Enabled independently of callback-proxy mode.
@@ -325,102 +332,150 @@ class GitLabOAuthServerProvider {
325
332
  // ---- Token exchange ----------------------------------------------------
326
333
  async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
327
334
  let tokens;
328
- if (this._callbackProxyEnabled) {
329
- // --- Callback proxy mode ---
330
- // The authorizationCode is a proxy code we generated in handleCallback().
331
- // It is either a sealed token (stateless mode) or a random UUID that
332
- // keys into the _storedTokens LRU (legacy mode).
333
- const stateless = this._stateless;
334
- let entry = null;
335
- if (stateless && looksLikeStatelessStoredTokensCode(authorizationCode)) {
336
- const payload = openStoredTokensCode(stateless.material, authorizationCode, stateless.storedTtlSeconds);
337
- if (!payload) {
338
- throw new ServerError("Invalid or expired authorization code");
335
+ /** Reserved sealed-code hash + id; released on failure, committed on success. */
336
+ let reservedProxyCode = null;
337
+ /**
338
+ * Legacy (non-stateless) proxy code taken from `_storedTokens` before
339
+ * binding checks. Restored on binding/PKCE failure so a wrong verifier
340
+ * cannot burn the code for the legitimate client.
341
+ */
342
+ let legacyRestorable = null;
343
+ try {
344
+ if (this._callbackProxyEnabled) {
345
+ // --- Callback proxy mode ---
346
+ // The authorizationCode is a proxy code we generated in handleCallback().
347
+ // It is either a sealed token (stateless mode) or a random UUID that
348
+ // keys into the _storedTokens LRU (legacy mode).
349
+ const stateless = this._stateless;
350
+ let entry = null;
351
+ if (stateless && looksLikeStatelessStoredTokensCode(authorizationCode)) {
352
+ const codeHash = createHash("sha256").update(authorizationCode).digest("hex");
353
+ let reserved;
354
+ try {
355
+ reserved = this._usedProxyCodes.tryReserve(codeHash, stateless.storedTtlSeconds);
356
+ if (!reserved.ok) {
357
+ if (reserved.reason === "pending") {
358
+ throw new ServerError("Authorization code exchange in progress — please retry");
359
+ }
360
+ throw new ServerError("Authorization code already used");
361
+ }
362
+ }
363
+ catch (err) {
364
+ if (err instanceof Error && err.message === PROXY_CODE_CACHE_FULL) {
365
+ logger.warn({
366
+ cacheSize: this._usedProxyCodes.size,
367
+ cacheMaxSize: this._usedProxyCodes.maxSize,
368
+ }, "Proxy code replay cache at capacity");
369
+ throw new ServerError("Authorization server busy — please retry the OAuth flow");
370
+ }
371
+ throw err;
372
+ }
373
+ reservedProxyCode = { hash: codeHash, id: reserved.reservationId };
374
+ const payload = openStoredTokensCode(stateless.material, authorizationCode, stateless.storedTtlSeconds);
375
+ if (!payload) {
376
+ throw new ServerError("Invalid or expired authorization code");
377
+ }
378
+ entry = {
379
+ tokens: payload.t,
380
+ clientId: payload.cid,
381
+ clientCodeChallenge: payload.ccc,
382
+ clientRedirectUri: payload.cru,
383
+ };
384
+ // NOTE: Cross-pod one-time use still requires a shared store. Replay
385
+ // across pods is mitigated by short TTL (default 120s) + client PKCE.
386
+ // Documented in stateless/stored-tokens.ts.
339
387
  }
340
- entry = {
341
- tokens: payload.t,
342
- clientId: payload.cid,
343
- clientCodeChallenge: payload.ccc,
344
- clientRedirectUri: payload.cru,
345
- };
346
- // NOTE: Stateless mode cannot enforce one-time use without a shared
347
- // store. Replay is mitigated by short TTL + client PKCE verification
348
- // below (attacker needs the code_verifier). Documented in
349
- // stateless/stored-tokens.ts.
350
- }
351
- else {
352
- const lru = this._storedTokens.get(authorizationCode);
353
- if (!lru) {
354
- throw new ServerError("Invalid or expired authorization code");
388
+ else {
389
+ // Atomic take: serialize concurrent exchanges. Restored below if
390
+ // client/redirect/PKCE binding checks fail (DoS prevention).
391
+ const lru = this._storedTokens.getAndDelete(authorizationCode);
392
+ if (!lru) {
393
+ throw new ServerError("Invalid or expired authorization code");
394
+ }
395
+ if (Date.now() - lru.createdAt > PENDING_AUTH_TTL_MS) {
396
+ throw new ServerError("Authorization code expired — please restart the OAuth flow");
397
+ }
398
+ legacyRestorable = { code: authorizationCode, entry: lru };
399
+ entry = {
400
+ tokens: lru.tokens,
401
+ clientId: lru.clientId,
402
+ clientCodeChallenge: lru.clientCodeChallenge,
403
+ clientRedirectUri: lru.clientRedirectUri,
404
+ };
355
405
  }
356
- if (Date.now() - lru.createdAt > PENDING_AUTH_TTL_MS) {
357
- this._storedTokens.delete(authorizationCode);
358
- throw new ServerError("Authorization code expired — please restart the OAuth flow");
406
+ // Bind the proxy code to the client and redirect_uri that initiated
407
+ // /authorize, preserving the normal OAuth authorization-code invariant.
408
+ // Binding + PKCE run while consumption is only *reserved* (pending /
409
+ // restorable); failed checks release so the legitimate client can retry.
410
+ if (client.client_id !== entry.clientId) {
411
+ throw new ServerError("Invalid client for authorization code");
359
412
  }
360
- // One-time use: delete after validation
361
- this._storedTokens.delete(authorizationCode);
362
- entry = {
363
- tokens: lru.tokens,
364
- clientId: lru.clientId,
365
- clientCodeChallenge: lru.clientCodeChallenge,
366
- clientRedirectUri: lru.clientRedirectUri,
367
- };
368
- }
369
- // Bind the proxy code to the client and redirect_uri that initiated
370
- // /authorize, preserving the normal OAuth authorization-code invariant.
371
- if (client.client_id !== entry.clientId) {
372
- throw new ServerError("Invalid client for authorization code");
373
- }
374
- if (redirectUri !== entry.clientRedirectUri) {
375
- throw new ServerError("Invalid redirect_uri for authorization code");
413
+ if (redirectUri !== entry.clientRedirectUri) {
414
+ throw new ServerError("Invalid redirect_uri for authorization code");
415
+ }
416
+ // Verify client PKCE: the client's code_verifier must match the
417
+ // code_challenge stored during /authorize.
418
+ if (entry.clientCodeChallenge) {
419
+ if (!codeVerifier) {
420
+ throw new ServerError("PKCE code_verifier is required");
421
+ }
422
+ const computed = createHash("sha256").update(codeVerifier).digest("base64url");
423
+ if (computed !== entry.clientCodeChallenge) {
424
+ throw new ServerError("PKCE verification failed");
425
+ }
426
+ }
427
+ tokens = entry.tokens;
376
428
  }
377
- // Verify client PKCE: the client's code_verifier must match the
378
- // code_challenge stored during /authorize.
379
- if (entry.clientCodeChallenge) {
380
- if (!codeVerifier) {
381
- throw new ServerError("PKCE code_verifier is required");
429
+ else {
430
+ // --- Passthrough mode (original behavior) ---
431
+ const params = new URLSearchParams({
432
+ grant_type: "authorization_code",
433
+ client_id: this._gitlabAppId,
434
+ code: authorizationCode,
435
+ });
436
+ if (codeVerifier)
437
+ params.append("code_verifier", codeVerifier);
438
+ if (redirectUri)
439
+ params.append("redirect_uri", redirectUri);
440
+ if (resource)
441
+ params.append("resource", resource.href);
442
+ const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
443
+ method: "POST",
444
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
445
+ body: params.toString(),
446
+ });
447
+ if (!response.ok) {
448
+ const body = await response.text();
449
+ logger.error(`Token exchange failed (${response.status}): ${body}`);
450
+ throw new ServerError(`Token exchange failed: ${response.status}`);
382
451
  }
383
- const computed = createHash("sha256").update(codeVerifier).digest("base64url");
384
- if (computed !== entry.clientCodeChallenge) {
385
- throw new ServerError("PKCE verification failed");
452
+ const data = await response.json();
453
+ tokens = OAuthTokensSchema.parse(data);
454
+ }
455
+ if (this._allowedGroups) {
456
+ const isMember = await this._checkGroupMembership(tokens.access_token);
457
+ if (!isMember) {
458
+ logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
459
+ throw new ServerError("Access denied: user is not a member of an allowed group");
386
460
  }
387
461
  }
388
- tokens = entry.tokens;
389
- }
390
- else {
391
- // --- Passthrough mode (original behavior) ---
392
- const params = new URLSearchParams({
393
- grant_type: "authorization_code",
394
- client_id: this._gitlabAppId,
395
- code: authorizationCode,
396
- });
397
- if (codeVerifier)
398
- params.append("code_verifier", codeVerifier);
399
- if (redirectUri)
400
- params.append("redirect_uri", redirectUri);
401
- if (resource)
402
- params.append("resource", resource.href);
403
- const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
404
- method: "POST",
405
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
406
- body: params.toString(),
407
- });
408
- if (!response.ok) {
409
- const body = await response.text();
410
- logger.error(`Token exchange failed (${response.status}): ${body}`);
411
- throw new ServerError(`Token exchange failed: ${response.status}`);
462
+ // All checks passed — permanently consume the proxy code.
463
+ legacyRestorable = null;
464
+ if (reservedProxyCode) {
465
+ this._usedProxyCodes.commit(reservedProxyCode.hash, reservedProxyCode.id);
466
+ reservedProxyCode = null;
412
467
  }
413
- const data = await response.json();
414
- tokens = OAuthTokensSchema.parse(data);
468
+ return tokens;
415
469
  }
416
- if (this._allowedGroups) {
417
- const isMember = await this._checkGroupMembership(tokens.access_token);
418
- if (!isMember) {
419
- logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
420
- throw new ServerError("Access denied: user is not a member of an allowed group");
470
+ catch (err) {
471
+ if (legacyRestorable) {
472
+ this._storedTokens.set(legacyRestorable.code, legacyRestorable.entry);
421
473
  }
474
+ if (reservedProxyCode) {
475
+ this._usedProxyCodes.release(reservedProxyCode.hash, reservedProxyCode.id);
476
+ }
477
+ throw err;
422
478
  }
423
- return tokens;
424
479
  }
425
480
  /**
426
481
  * Returns true if the token owner belongs to at least one group whose
@@ -480,9 +535,18 @@ class GitLabOAuthServerProvider {
480
535
  }
481
536
  // ---- Verify access token -----------------------------------------------
482
537
  async verifyAccessToken(token) {
483
- const res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, {
538
+ let res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, {
484
539
  headers: { Authorization: `Bearer ${token}` },
485
540
  });
541
+ if (res.status === 401) {
542
+ // Some GitLab instances sit behind an edge cache that strips the
543
+ // Authorization header on /oauth/* paths (observed on
544
+ // git.drupalcode.org, fronted by Varnish), so a valid token 401s
545
+ // here while working fine against /api/v4. Doorkeeper also accepts
546
+ // the RFC 6750 access_token query parameter — retry with that form
547
+ // before rejecting the token.
548
+ res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info?access_token=${encodeURIComponent(token)}`);
549
+ }
486
550
  if (!res.ok) {
487
551
  throw new InvalidTokenError("Invalid or expired GitLab OAuth token");
488
552
  }
package/build/schemas.js CHANGED
@@ -309,8 +309,10 @@ export const GitLabCiLintResultSchema = z.object({
309
309
  valid: z.coerce.boolean(),
310
310
  errors: z.array(z.string()),
311
311
  warnings: z.array(z.string()).optional(),
312
- merged_yaml: z.string().optional(),
313
- includes: z.array(z.unknown()).optional(),
312
+ // GitLab sends null (not an omitted field) for both when the config is
313
+ // invalid, which is exactly when this tool is called. See #638.
314
+ merged_yaml: z.string().nullish(),
315
+ includes: z.array(z.unknown()).nullish(),
314
316
  jobs: z.array(z.unknown()).optional(),
315
317
  });
316
318
  export const ValidateCiLintSchema = z.object({
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Produce a safe short form of a session id for logs. Legacy UUIDs pass
3
+ * through; stateless sealed sids are truncated to the "v1.sid." prefix
4
+ * plus a handful of bytes of the ciphertext so operators can correlate
5
+ * flows without the log line carrying the sealed bearer token.
6
+ */
7
+ export function redactSessionIdForLog(sid) {
8
+ if (!sid)
9
+ return "<none>";
10
+ if (sid.startsWith("v1.sid."))
11
+ return "v1.sid.<redacted>";
12
+ // UUIDs / other formats are low-sensitivity; show first 8 chars.
13
+ return sid.length > 8 ? `${sid.slice(0, 8)}…` : sid;
14
+ }
15
+ /**
16
+ * Detect whether an MCP JSON-RPC request body represents an "initialize"
17
+ * request. Accepts both single-message and batch forms. Returns false on any
18
+ * unexpected shape — callers treat an ambiguous body as non-init, which is
19
+ * the safer default (it means the SDK will fail loudly rather than silently
20
+ * spawning a new session).
21
+ */
22
+ export function isInitializationRequestBody(body) {
23
+ if (!body)
24
+ return false;
25
+ const isInitObj = (m) => typeof m === "object" && m !== null && m.method === "initialize";
26
+ if (Array.isArray(body))
27
+ return body.some(isInitObj);
28
+ return isInitObj(body);
29
+ }
30
+ export function isUnauthenticatedDiscoveryRequestBody(body) {
31
+ if (!body)
32
+ return false;
33
+ const isDiscoveryMethod = (m) => {
34
+ if (typeof m !== "object" || m === null)
35
+ return false;
36
+ const method = m.method;
37
+ return (method === "initialize" || method === "notifications/initialized" || method === "tools/list");
38
+ };
39
+ if (Array.isArray(body))
40
+ return body.every(isDiscoveryMethod);
41
+ return isDiscoveryMethod(body);
42
+ }
43
+ /**
44
+ * Normalize an `Mcp-Session-Id` header value.
45
+ *
46
+ * Node's HTTP types allow any request header to surface as `string[]` when
47
+ * the client sends it more than once. Casting to `string` and calling
48
+ * `.startsWith()` on an array throws `TypeError: startsWith is not a
49
+ * function`, which Express converts to a 500 — silently turning malformed
50
+ * requests into server errors and breaking the 401/404 semantics we
51
+ * carefully distinguish in stateless mode. A duplicated `Mcp-Session-Id` is
52
+ * also ill-formed at the protocol level: there is no well-defined way to
53
+ * pick between two values, so we reject arrays rather than guess.
54
+ *
55
+ * Empty-string is normalized to `undefined` so call sites can use truthy
56
+ * checks and `?? undefined`-style fallbacks uniformly.
57
+ */
58
+ export function readMcpSessionIdHeader(req) {
59
+ const raw = req.headers["mcp-session-id"];
60
+ if (typeof raw !== "string")
61
+ return undefined;
62
+ return raw.length > 0 ? raw : undefined;
63
+ }