@zereight/mcp-gitlab 2.1.46 → 2.1.48

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.47`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `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.47`. 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.47`。如果始终想使用最新版本,请改用 `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, readAcceptHeader, 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";
@@ -160,7 +162,9 @@ function createServer() {
160
162
  version: SERVER_VERSION,
161
163
  }, {
162
164
  capabilities: {
163
- tools: {},
165
+ tools: {
166
+ listChanged: true,
167
+ },
164
168
  },
165
169
  });
166
170
  mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -635,71 +639,6 @@ function validateConfiguration() {
635
639
  }
636
640
  let OAUTH_ACCESS_TOKEN = null;
637
641
  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
642
  /**
704
643
  * Loaded once at startup. Null when OAUTH_STATELESS_MODE is disabled.
705
644
  * When set, the OAuth provider and (later phases) the Mcp-Session-Id path
@@ -983,7 +922,7 @@ const BASE_HEADERS = {
983
922
  function buildAuthHeaders() {
984
923
  if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
985
924
  const ctx = sessionAuthStore.getStore();
986
- logger.debug({ context: ctx }, "buildAuthHeaders: session context");
925
+ logger.debug({ sessionId: ctx?.sessionId, header: ctx?.header, apiUrl: ctx?.apiUrl }, "buildAuthHeaders: session context");
987
926
  if (ctx?.token) {
988
927
  return {
989
928
  [ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
@@ -1028,7 +967,7 @@ function getEffectiveApiUrl() {
1028
967
  if (ctx?.apiUrl) {
1029
968
  return ctx.apiUrl;
1030
969
  }
1031
- logger.warn({ ctx }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
970
+ logger.warn({ sessionId: ctx?.sessionId, header: ctx?.header, apiUrl: ctx?.apiUrl }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
1032
971
  }
1033
972
  return GITLAB_API_URL;
1034
973
  }
@@ -1329,10 +1268,14 @@ async function getFileContents(projectId, filePath, ref) {
1329
1268
  await handleGitLabError(response);
1330
1269
  const data = await response.json();
1331
1270
  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";
1271
+ // Decode Base64-encoded text files while preserving binary content losslessly.
1272
+ if (!Array.isArray(parsedData) && typeof parsedData.content === "string") {
1273
+ const decodedContent = Buffer.from(parsedData.content, "base64");
1274
+ const utf8Content = decodedContent.toString("utf8");
1275
+ if (Buffer.from(utf8Content, "utf8").equals(decodedContent)) {
1276
+ parsedData.content = utf8Content;
1277
+ parsedData.encoding = "utf8";
1278
+ }
1336
1279
  }
1337
1280
  return parsedData;
1338
1281
  }
@@ -4108,13 +4051,33 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
4108
4051
  ...getFetchConfig(),
4109
4052
  method: "GET",
4110
4053
  });
4111
- if (approvalStateResponse.status === 404) {
4054
+ // 404 when the endpoint is unavailable; 402/403 when the instance or namespace is
4055
+ // not licensed for approval rules, which are a paid-tier feature.
4056
+ if (approvalStateResponse.status === 404 ||
4057
+ approvalStateResponse.status === 402 ||
4058
+ approvalStateResponse.status === 403) {
4112
4059
  return getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
4113
4060
  }
4114
4061
  await handleGitLabError(approvalStateResponse);
4115
4062
  const parsedApprovalState = GitLabMergeRequestApprovalStateSchema.parse(await approvalStateResponse.json());
4116
4063
  const approvedByUsers = getUniqueApprovalUsers((parsedApprovalState.rules || []).flatMap(rule => rule.approved_by || []));
4117
4064
  const approvedByUsernames = approvedByUsers.map(user => user.username);
4065
+ // approval_state only reports approvals attributed to rules, so a project with no
4066
+ // approval rules answers 200 with `rules: []` and yields no approvers even when
4067
+ // someone has approved. Read /approvals in that case. A non-empty rules array means
4068
+ // rules are in use and approval_state is authoritative, so no extra request is made
4069
+ // there - including while an MR is still waiting for its first approval.
4070
+ if ((parsedApprovalState.rules || []).length === 0) {
4071
+ try {
4072
+ const viaApprovals = await getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
4073
+ if ((viaApprovals.approved_by ?? []).length > 0) {
4074
+ return viaApprovals;
4075
+ }
4076
+ }
4077
+ catch {
4078
+ // /approvals unavailable as well - keep the approval_state answer below.
4079
+ }
4080
+ }
4118
4081
  return {
4119
4082
  ...parsedApprovalState,
4120
4083
  approved_by: approvedByUsers,
@@ -5319,12 +5282,11 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
5319
5282
  }
5320
5283
  await handleGitLabError(response);
5321
5284
  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
5285
  if (!response.body) {
5325
5286
  throw new Error("No response body from GitLab");
5326
5287
  }
5327
- await streamPipeline(response.body, fs.createWriteStream(savePath));
5288
+ const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(filename, localPath, "local_path");
5289
+ await streamPipeline(response.body, saveStream);
5328
5290
  return savePath;
5329
5291
  }
5330
5292
  /**
@@ -6722,12 +6684,10 @@ async function markdownUpload(projectId, filePath, content, filename) {
6722
6684
  fileName = filename || "upload";
6723
6685
  }
6724
6686
  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);
6687
+ // Local file mode — reject absolute/traversal/symlink escapes before reading
6688
+ const { buffer, basename: safeBasename } = readSafeExistingFile(filePath, "file_path");
6689
+ fileBuffer = buffer;
6690
+ fileName = safeBasename;
6731
6691
  }
6732
6692
  else {
6733
6693
  throw new Error("Either file_path or content must be provided");
@@ -6792,29 +6752,12 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
6792
6752
  // For non-image files, always save to disk.
6793
6753
  // For image files, only save to disk if local_path is explicitly provided.
6794
6754
  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
6755
  if (!response.body) {
6815
6756
  throw new Error("No response body from GitLab");
6816
6757
  }
6817
- await streamPipeline(response.body, fs.createWriteStream(savePath));
6758
+ const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(safeFilename, localPath, "local_path");
6759
+ // Stream directly to disk instead of buffering in memory
6760
+ await streamPipeline(response.body, saveStream);
6818
6761
  return { buffer: Buffer.alloc(0), filename: safeFilename, mimeType, savedPath: savePath };
6819
6762
  }
6820
6763
  // Images returned inline — buffer into memory for base64 encoding
@@ -9531,7 +9474,9 @@ async function handleToolCall(params) {
9531
9474
  }
9532
9475
  }
9533
9476
  catch (error) {
9534
- logger.debug(params);
9477
+ // Log tool name only — never dump raw params (may contain approval_password).
9478
+ // Sensitive fields are also covered by REDACT_PATHS if arguments are logged elsewhere.
9479
+ logger.debug({ tool: params.name }, "Tool call failed");
9535
9480
  if (error instanceof z.ZodError) {
9536
9481
  throw new Error(`Invalid arguments: ${error.errors
9537
9482
  .map(e => `${e.path.join(".")}: ${e.message}`)
@@ -10568,13 +10513,87 @@ async function startStreamableHTTPServer() {
10568
10513
  await handleRequest();
10569
10514
  }
10570
10515
  });
10571
- // Reject unsupported methods on /mcp
10572
- app.get("/mcp", (_req, res) => {
10573
- res.setHeader("Allow", "POST, DELETE");
10574
- res.status(405).json({
10575
- error: "Method Not Allowed",
10576
- message: "GET /mcp is not supported when STREAMABLE_HTTP is enabled. Use POST to communicate with the MCP server.",
10577
- });
10516
+ // Streamable HTTP GET endpoint for listening to server-sent events (SSE)
10517
+ app.get("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
10518
+ const sessionId = readMcpSessionIdHeader(req);
10519
+ const acceptHeader = readAcceptHeader(req);
10520
+ if (!acceptHeader.includes("text/event-stream")) {
10521
+ res.setHeader("Allow", "GET, POST, DELETE");
10522
+ res.status(406).json({
10523
+ error: "Not Acceptable",
10524
+ message: "Client must accept text/event-stream for GET /mcp",
10525
+ });
10526
+ return;
10527
+ }
10528
+ if (!sessionId) {
10529
+ res.setHeader("Allow", "GET, POST, DELETE");
10530
+ res.status(400).json({
10531
+ error: "Bad Request",
10532
+ message: "Mcp-Session-Id header is required for GET /mcp",
10533
+ });
10534
+ return;
10535
+ }
10536
+ if (OAUTH_STATELESS_MODE && STATELESS_MATERIAL && (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
10537
+ res.setHeader("Allow", "POST");
10538
+ res.status(405).json({
10539
+ error: "Method Not Allowed",
10540
+ message: "GET /mcp SSE stream is not supported in stateless mode. Use POST to communicate with the MCP server.",
10541
+ });
10542
+ return;
10543
+ }
10544
+ const transport = streamableTransports[sessionId];
10545
+ if (!transport) {
10546
+ res.status(404).json({
10547
+ error: "Session not found",
10548
+ });
10549
+ return;
10550
+ }
10551
+ metrics.requestsProcessed++;
10552
+ const usesSessionTimeouts = REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH;
10553
+ if (usesSessionTimeouts) {
10554
+ if (authBySession[sessionId]) {
10555
+ authBySession[sessionId].lastUsed = Date.now();
10556
+ }
10557
+ // Listening on GET /mcp is session activity. Pause inactivity expiry for
10558
+ // the life of the SSE stream so list_changed can still be pushed.
10559
+ clearAuthTimeout(sessionId);
10560
+ }
10561
+ const handleGetRequest = async () => {
10562
+ try {
10563
+ await transport.handleRequest(req, res);
10564
+ }
10565
+ catch (error) {
10566
+ logger.error({ err: error }, "Streamable HTTP GET error");
10567
+ if (!res.headersSent) {
10568
+ res.status(500).json({
10569
+ error: "Internal server error",
10570
+ message: error instanceof Error ? error.message : "Unknown error",
10571
+ });
10572
+ }
10573
+ }
10574
+ };
10575
+ try {
10576
+ if (usesSessionTimeouts && authBySession[sessionId]) {
10577
+ const authData = authBySession[sessionId];
10578
+ const ctx = {
10579
+ sessionId,
10580
+ header: authData.header,
10581
+ token: authData.token,
10582
+ lastUsed: authData.lastUsed,
10583
+ apiUrl: authData.apiUrl,
10584
+ publicBaseUrl: authData.publicBaseUrl,
10585
+ };
10586
+ await sessionAuthStore.run(ctx, handleGetRequest);
10587
+ }
10588
+ else {
10589
+ await handleGetRequest();
10590
+ }
10591
+ }
10592
+ finally {
10593
+ if (usesSessionTimeouts && streamableTransports[sessionId]) {
10594
+ setAuthTimeout(sessionId);
10595
+ }
10596
+ }
10578
10597
  });
10579
10598
  const getMetricsSnapshot = () => ({
10580
10599
  ...metrics,
@@ -10642,6 +10661,14 @@ async function startStreamableHTTPServer() {
10642
10661
  res.status(404).json({ error: "Session not found" });
10643
10662
  }
10644
10663
  });
10664
+ // Reject unsupported methods on /mcp
10665
+ app.all("/mcp", (_req, res) => {
10666
+ res.setHeader("Allow", "GET, POST, DELETE");
10667
+ res.status(405).json({
10668
+ error: "Method Not Allowed",
10669
+ message: "Method Not Allowed. Use GET, POST, or DELETE to communicate with the MCP server.",
10670
+ });
10671
+ });
10645
10672
  // Start server
10646
10673
  const httpServer = app.listen(Number(PORT), HOST, () => {
10647
10674
  logger.info(`GitLab MCP Server running with Streamable HTTP transport`);