@zereight/mcp-gitlab 2.3.2 → 2.3.4

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
@@ -138,7 +138,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
138
138
 
139
139
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
140
140
 
141
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.3.1`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
141
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.3.3`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
142
142
 
143
143
  #### Claude Desktop 원클릭 설치(MCP Bundle)
144
144
 
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # GitLab MCP Server
2
2
 
3
- [![GitHub stars](https://img.shields.io/github/stars/zereight/gitlab-mcp.svg?style=social&label=Star)](https://github.com/zereight/gitlab-mcp)
4
- [![npm downloads](https://img.shields.io/npm/dm/@zereight/mcp-gitlab.svg)](https://www.npmjs.com/package/@zereight/mcp-gitlab)
3
+ [![GitHub stars](https://badgen.net/github/stars/zereight/gitlab-mcp?icon=github&label=Star)](https://github.com/zereight/gitlab-mcp)
4
+ [![npm downloads](https://badgen.net/npm/dm/@zereight/mcp-gitlab)](https://www.npmjs.com/package/@zereight/mcp-gitlab)
5
5
  [![npm](https://img.shields.io/npm/v/@zereight/mcp-gitlab.svg)](https://www.npmjs.com/package/@zereight/mcp-gitlab)
6
- [![GitHub License](https://img.shields.io/github/license/zereight/gitlab-mcp.svg)](https://github.com/zereight/gitlab-mcp/blob/main/LICENSE)
6
+ [![GitHub License](https://badgen.net/github/license/zereight/gitlab-mcp)](https://github.com/zereight/gitlab-mcp/blob/main/LICENSE)
7
7
  [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_GitLab_MCP-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=zereight.gitlab-mcp&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40zereight%2Fmcp-gitlab%40latest%22%5D%2C%22env%22%3A%7B%22GITLAB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agitlab-token%7D%22%2C%22GITLAB_API_URL%22%3A%22https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%22%2C%22GITLAB_PERMISSION_MODE%22%3A%22full%22%7D%7D)
8
8
  [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=zereight.gitlab-mcp&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkB6ZXJlaWdodC9tY3AtZ2l0bGFiQGxhdGVzdCJdLCJlbnYiOnsiR0lUTEFCX1BFUlNPTkFMX0FDQ0VTU19UT0tFTiI6IiR7ZW52OkdJVExBQl9QRVJTT05BTF9BQ0NFU1NfVE9LRU59IiwiR0lUTEFCX0FQSV9VUkwiOiJodHRwczovL2dpdGxhYi5jb20vYXBpL3Y0IiwiR0lUTEFCX1BFUk1JU1NJT05fTU9ERSI6ImZ1bGwifX0%3D)
9
9
  [![Ask DeepWiki](https://img.shields.io/badge/DeepWiki-Ask-blue)](https://deepwiki.com/zereight/gitlab-mcp)
@@ -143,7 +143,7 @@ The store path is pinned by your lock file; update it with `nix flake update git
143
143
 
144
144
  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`.
145
145
 
146
- No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.3.1`. 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`).
146
+ No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.3.3`. 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`).
147
147
 
148
148
  #### Claude Desktop one-click install (MCP Bundle)
149
149
 
package/README.pt-BR.md CHANGED
@@ -138,7 +138,7 @@ O caminho no store é fixado pelo seu lock file; atualize-o com `nix flake updat
138
138
 
139
139
  Os exemplos usam `zereight-mcp-gitlab`, um alias com menor chance de conflito que o binário legado `mcp-gitlab`. Se o seu cliente MCP não o encontrar, use o caminho absoluto retornado por `which zereight-mcp-gitlab`.
140
140
 
141
- Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.3.1`. Se quiser sempre a versão mais recente, use `npx -y @zereight/mcp-gitlab@latest`. O servidor exibe um aviso no stderr durante a inicialização quando existe uma versão mais nova disponível (desative com `GITLAB_DISABLE_VERSION_CHECK=true`).
141
+ Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.3.3`. Se quiser sempre a versão mais recente, use `npx -y @zereight/mcp-gitlab@latest`. O servidor exibe um aviso no stderr durante a inicialização quando existe uma versão mais nova disponível (desative com `GITLAB_DISABLE_VERSION_CHECK=true`).
142
142
 
143
143
  #### Instalação com um clique no Claude Desktop (MCP Bundle)
144
144
 
package/README.zh-CN.md CHANGED
@@ -138,7 +138,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
138
138
 
139
139
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
140
140
 
141
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.3.1`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
141
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.3.3`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
142
142
 
143
143
  #### Claude Desktop 一键安装(MCP Bundle)
144
144
 
package/build/config.js CHANGED
@@ -79,6 +79,7 @@ export const SSE = getConfig("sse", "SSE") === "true";
79
79
  export const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
80
80
  export const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
81
81
  export const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
82
+ export const GITLAB_MCP_REQUEST_BODY_LIMIT = process.env.GITLAB_MCP_REQUEST_BODY_LIMIT?.trim() || "10mb";
82
83
  export const GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY = getConfig("allow-unauthenticated-tool-discovery", "GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY") === "true";
83
84
  export const MCP_TRUST_PROXY = getConfig("mcp-trust-proxy", "MCP_TRUST_PROXY") === "true";
84
85
  // ---------------------------------------------------------------------------
package/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_OAUTH_ALLOWED_REDIRECT_URIS_RAW, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_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, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, GITLAB_DOWNLOAD_DIR, buildDeprecatedEnvInput, } from "./config.js";
2
+ import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_MCP_REQUEST_BODY_LIMIT, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_OAUTH_ALLOWED_REDIRECT_URIS_RAW, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_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, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, GITLAB_DOWNLOAD_DIR, buildDeprecatedEnvInput, } from "./config.js";
3
3
  import { getDeprecatedEnvWarnings } from "./deprecated-env.js";
4
4
  /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
5
5
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
@@ -62,7 +62,7 @@ function isConstantTimeSecretMatch(provided, expected) {
62
62
  import { loadKeyMaterialFromEnv, looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./stateless/index.js";
63
63
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
64
64
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
65
- import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
65
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
66
66
  import { AsyncLocalStorage } from "node:async_hooks";
67
67
  import fetchCookie from "fetch-cookie";
68
68
  import fs from "node:fs";
@@ -90,6 +90,7 @@ import { loadRemoteHttpStack } from "./server/remote-http-stack.js";
90
90
  import { determineTransportMode, TransportMode } from "./server/transport-mode.js";
91
91
  import { formatPrometheusMetrics } from "./server/metrics.js";
92
92
  import { SERVER_VERSION } from "./server/version.js";
93
+ import { listMcpResources, listMcpResourceTemplates, readMcpResource, } from "./server/mcp-resources.js";
93
94
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
94
95
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
95
96
  import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
@@ -98,12 +99,13 @@ import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation
98
99
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
99
100
  import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
100
101
  import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
101
- import { classifyHealthCheckTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
102
- import { redactUrlSecretsInText } from "./utils/redact-url.js";
102
+ import { classifyHealthCheckTransportError, describeFetchTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
103
+ import { redactUrlSecretsInText, splitCommaSeparatedUrls } from "./utils/redact-url.js";
103
104
  import { createMaskingPolicyResolver, getManagedMaskingProjectIds, } from "./masking/index.js";
104
105
  import { checkForNewVersion } from "./utils/version-check.js";
105
106
  import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
106
107
  import { fetchWithValidatedRedirects } from "./utils/safe-redirect-fetch.js";
108
+ import { fetchStatusBeforeDeadline } from "./utils/poll-deadline.js";
107
109
  import { parseGitLabVersionApiResponse, } from "./utils/gitlab-instance-version.js";
108
110
  import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./utils/bulk-publish-options.js";
109
111
  import { appendFilterParam, appendFilterParams, cleanMutuallyExclusiveIdUsernameOptions, dropBlankArrayEntries, isBlankFilterValue, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
@@ -142,6 +144,9 @@ const maskingPolicyResolver = createMaskingPolicyResolver({
142
144
  workspaceDir: GITLAB_MASKING_WORKSPACE_DIR,
143
145
  });
144
146
  const SERVER_NAME = process.env.MCP_SERVER_NAME?.trim() || "zereight-gitlab-mcp-server";
147
+ // No `title`: clients would show it instead of `name`, hiding MCP_SERVER_NAME.
148
+ const SERVER_DESCRIPTION = "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more.";
149
+ const SERVER_WEBSITE_URL = "https://zereight.github.io/gitlab-mcp/";
145
150
  /**
146
151
  * Static MCP prompt catalog. These wrap the multi-step tool workflows documented in
147
152
  * skills/gitlab-mcp/ so MCP clients that surface prompts (e.g. slash commands) can
@@ -239,12 +244,15 @@ function createServer() {
239
244
  const mcpServer = new McpServer({
240
245
  name: SERVER_NAME,
241
246
  version: SERVER_VERSION,
247
+ description: SERVER_DESCRIPTION,
248
+ websiteUrl: SERVER_WEBSITE_URL,
242
249
  }, {
243
250
  capabilities: {
244
251
  tools: {
245
252
  listChanged: true,
246
253
  },
247
254
  prompts: {},
255
+ resources: {},
248
256
  },
249
257
  });
250
258
  mcpServer.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
@@ -273,6 +281,9 @@ function createServer() {
273
281
  ],
274
282
  };
275
283
  });
284
+ mcpServer.server.setRequestHandler(ListResourcesRequestSchema, async () => listMcpResources());
285
+ mcpServer.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => listMcpResourceTemplates());
286
+ mcpServer.server.setRequestHandler(ReadResourceRequestSchema, async (request) => readMcpResource(request.params.uri));
276
287
  mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
277
288
  return {
278
289
  tools: getListedTools(filteredTools, listedToolsCache.revision),
@@ -730,7 +741,8 @@ function validateConfiguration() {
730
741
  }
731
742
  }
732
743
  // Validate GITLAB_API_URL format
733
- const apiUrls = getConfig("api-url", "GITLAB_API_URL")?.split(",") || [];
744
+ const configuredApiUrls = getConfig("api-url", "GITLAB_API_URL");
745
+ const apiUrls = configuredApiUrls === undefined ? [] : splitCommaSeparatedUrls(configuredApiUrls);
734
746
  if (apiUrls.length > 0) {
735
747
  for (const url of apiUrls) {
736
748
  try {
@@ -954,9 +966,7 @@ const hiddenToolSet = new Set((GITLAB_TOOL_POLICY_HIDDEN_RAW || "")
954
966
  }
955
967
  }
956
968
  const clientPool = new GitLabClientPool({
957
- apiUrls: (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
958
- .split(",")
959
- .map(normalizeGitLabApiUrl),
969
+ apiUrls: splitCommaSeparatedUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com").map(normalizeGitLabApiUrl),
960
970
  httpProxy: HTTP_PROXY,
961
971
  httpsProxy: HTTPS_PROXY,
962
972
  noProxy: NO_PROXY,
@@ -1330,9 +1340,7 @@ function resolveTrustedGitLabApiUrl(value) {
1330
1340
  return allowedApiUrl;
1331
1341
  }
1332
1342
  // Use the normalizeGitLabApiUrl function to handle various URL formats
1333
- const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
1334
- .split(",")
1335
- .map(normalizeGitLabApiUrl);
1343
+ const GITLAB_API_URLS = splitCommaSeparatedUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com").map(normalizeGitLabApiUrl);
1336
1344
  const GITLAB_API_URL = GITLAB_API_URLS[0];
1337
1345
  const GITLAB_ALLOWED_API_URLS_BY_HOST = new Map();
1338
1346
  for (const { host, apiUrl } of [
@@ -6363,14 +6371,73 @@ async function erasePipelineJob(projectId, jobId) {
6363
6371
  const TERMINAL_PIPELINE_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
6364
6372
  const TERMINAL_JOB_STATUSES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
6365
6373
  const TERMINAL_REBASE_STATUSES = new Set(["finished"]);
6366
- async function waitForStatus(fetchStatus, terminalStatuses, timeoutSeconds = 300, pollIntervalSeconds = 5) {
6374
+ // GitLab accepts PUT .../rebase before Sidekiq starts the job. Until then, GET
6375
+ // with include_rebase_in_progress can still say rebase_in_progress=false and return
6376
+ // the pre-rebase sha (gitlab-org/gitlab-foss#57481). Recheck that snapshot a few
6377
+ // times, capped so the default 5s poll interval cannot stretch this into 15s.
6378
+ const REBASE_UNCHANGED_HEAD_RECHECK_LIMIT = 3;
6379
+ const REBASE_UNCHANGED_HEAD_RECHECK_INTERVAL_CAP_MS = 1000;
6380
+ function rebaseHeadMoved(previous, current) {
6381
+ return current.sha !== previous.sha;
6382
+ }
6383
+ function rebaseFailureExplained(previous, current) {
6384
+ const mergeError = current.merge_error;
6385
+ if (typeof mergeError !== "string" || mergeError.length === 0) {
6386
+ return false;
6387
+ }
6388
+ return mergeError !== previous.merge_error;
6389
+ }
6390
+ function delayForRebasePoll(delayMs) {
6391
+ return new Promise(resolve => {
6392
+ setTimeout(resolve, delayMs);
6393
+ });
6394
+ }
6395
+ async function getMergeRequestRebaseStatusBeforeDeadline(projectId, mergeRequestIid, deadline) {
6396
+ return fetchStatusBeforeDeadline(signal => getMergeRequestRebaseStatus(projectId, mergeRequestIid, signal), deadline);
6397
+ }
6398
+ /**
6399
+ * Poll until a rebase finishes, without trusting a finished snapshot that still
6400
+ * has the pre-rebase sha and no new merge_error. A later rebase_in_progress true
6401
+ * means the job did start, so wait for that terminal status as usual. Rechecks
6402
+ * share the caller's timeout and never add time beyond it.
6403
+ */
6404
+ async function waitForMergeRequestRebase(projectId, mergeRequestIid, previous, timeoutSeconds = 300, pollIntervalSeconds = 5) {
6367
6405
  const deadline = Date.now() + timeoutSeconds * 1000;
6368
- const fetchBeforeDeadline = async () => {
6406
+ let snapshot = await getMergeRequestRebaseStatusBeforeDeadline(projectId, mergeRequestIid, deadline);
6407
+ let rechecksRemaining = REBASE_UNCHANGED_HEAD_RECHECK_LIMIT;
6408
+ while (true) {
6409
+ if (!TERMINAL_REBASE_STATUSES.has(snapshot.status)) {
6410
+ rechecksRemaining = 0;
6411
+ const remaining = deadline - Date.now();
6412
+ if (remaining <= 0) {
6413
+ break;
6414
+ }
6415
+ await delayForRebasePoll(Math.min(pollIntervalSeconds * 1000, remaining));
6416
+ snapshot = await getMergeRequestRebaseStatusBeforeDeadline(projectId, mergeRequestIid, deadline);
6417
+ continue;
6418
+ }
6419
+ const mergeRequest = snapshot.mergeRequest;
6420
+ if (rebaseHeadMoved(previous, mergeRequest) || rebaseFailureExplained(previous, mergeRequest)) {
6421
+ return mergeRequest;
6422
+ }
6423
+ if (rechecksRemaining <= 0) {
6424
+ return mergeRequest;
6425
+ }
6369
6426
  const remaining = deadline - Date.now();
6370
- if (remaining <= 0)
6371
- throw new Error("Timed out waiting for terminal status");
6372
- return fetchStatus(AbortSignal.timeout(remaining));
6373
- };
6427
+ const delay = Math.min(pollIntervalSeconds * 1000, REBASE_UNCHANGED_HEAD_RECHECK_INTERVAL_CAP_MS, remaining);
6428
+ // The next poll has to land inside the timeout. If one full backoff does not fit, stop.
6429
+ if (remaining <= 0 || delay >= remaining) {
6430
+ return mergeRequest;
6431
+ }
6432
+ rechecksRemaining -= 1;
6433
+ await delayForRebasePoll(delay);
6434
+ snapshot = await getMergeRequestRebaseStatusBeforeDeadline(projectId, mergeRequestIid, deadline);
6435
+ }
6436
+ throw new Error(`Timed out waiting for terminal status; last status: ${snapshot.status}`);
6437
+ }
6438
+ async function waitForStatus(fetchStatus, terminalStatuses, timeoutSeconds = 300, pollIntervalSeconds = 5) {
6439
+ const deadline = Date.now() + timeoutSeconds * 1000;
6440
+ const fetchBeforeDeadline = () => fetchStatusBeforeDeadline(fetchStatus, deadline);
6374
6441
  let value = await fetchBeforeDeadline();
6375
6442
  while (!terminalStatuses.has(value.status)) {
6376
6443
  const remaining = deadline - Date.now();
@@ -8233,7 +8300,7 @@ async function handleToolCall(params) {
8233
8300
  };
8234
8301
  }
8235
8302
  catch (err) {
8236
- const message = err instanceof Error ? err.message : String(err);
8303
+ const message = describeFetchTransportError(err) ?? (err instanceof Error ? err.message : String(err));
8237
8304
  return {
8238
8305
  content: [
8239
8306
  {
@@ -8283,7 +8350,8 @@ async function handleToolCall(params) {
8283
8350
  logger.error({ err: forkError }, "Error forking repository");
8284
8351
  let forkErrorMessage = "Failed to fork repository";
8285
8352
  if (forkError instanceof Error) {
8286
- forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`;
8353
+ const reason = describeFetchTransportError(forkError) ?? forkError.message;
8354
+ forkErrorMessage = `${forkErrorMessage}: ${reason}`;
8287
8355
  }
8288
8356
  return {
8289
8357
  content: [
@@ -8651,7 +8719,7 @@ async function handleToolCall(params) {
8651
8719
  text: JSON.stringify({
8652
8720
  status: "success",
8653
8721
  message: "All pending to-do items marked as done",
8654
- }, null, 2),
8722
+ }),
8655
8723
  },
8656
8724
  ],
8657
8725
  };
@@ -8771,11 +8839,11 @@ async function handleToolCall(params) {
8771
8839
  }
8772
8840
  const { mergeRequest: previous } = await getMergeRequestRebaseStatus(project_id, merge_request_iid);
8773
8841
  await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
8774
- const { mergeRequest } = await waitForStatus(signal => getMergeRequestRebaseStatus(project_id, merge_request_iid, signal), TERMINAL_REBASE_STATUSES, timeout_seconds, poll_interval_seconds);
8842
+ const mergeRequest = await waitForMergeRequestRebase(project_id, merge_request_iid, previous, timeout_seconds, poll_interval_seconds);
8775
8843
  const rebaseResult = {
8776
8844
  ...mergeRequest,
8777
8845
  previous_sha: previous.sha,
8778
- head_changed: mergeRequest.sha !== previous.sha,
8846
+ head_changed: rebaseHeadMoved(previous, mergeRequest),
8779
8847
  };
8780
8848
  return { content: [{ type: "text", text: JSON.stringify(rebaseResult) }] };
8781
8849
  }
@@ -8895,9 +8963,7 @@ async function handleToolCall(params) {
8895
8963
  await handleGitLabError(response);
8896
8964
  const data = await response.json();
8897
8965
  return {
8898
- content: [
8899
- { type: "text", text: JSON.stringify(redactSensitiveGitLabFields(data), null, 2) },
8900
- ],
8966
+ content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(data)) }],
8901
8967
  };
8902
8968
  }
8903
8969
  case "list_project_members": {
@@ -9118,7 +9184,7 @@ async function handleToolCall(params) {
9118
9184
  changes: result.changes,
9119
9185
  summary: result.summary,
9120
9186
  preview: result.preview,
9121
- }, null, 2),
9187
+ }),
9122
9188
  },
9123
9189
  ],
9124
9190
  };
@@ -9157,7 +9223,7 @@ async function handleToolCall(params) {
9157
9223
  web_url: updatedIssue.web_url,
9158
9224
  updated_at: updatedIssue.updated_at,
9159
9225
  },
9160
- }, null, 2),
9226
+ }),
9161
9227
  },
9162
9228
  ],
9163
9229
  };
@@ -9169,7 +9235,7 @@ async function handleToolCall(params) {
9169
9235
  content: [
9170
9236
  {
9171
9237
  type: "text",
9172
- text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }, null, 2),
9238
+ text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }),
9173
9239
  },
9174
9240
  ],
9175
9241
  };
@@ -9213,7 +9279,7 @@ async function handleToolCall(params) {
9213
9279
  text: JSON.stringify({
9214
9280
  status: "success",
9215
9281
  message: "Issue link deleted successfully",
9216
- }, null, 2),
9282
+ }),
9217
9283
  },
9218
9284
  ],
9219
9285
  };
@@ -9391,7 +9457,7 @@ async function handleToolCall(params) {
9391
9457
  content: [
9392
9458
  {
9393
9459
  type: "text",
9394
- text: JSON.stringify({ status: "success", message: "Label deleted successfully" }, null, 2),
9460
+ text: JSON.stringify({ status: "success", message: "Label deleted successfully" }),
9395
9461
  },
9396
9462
  ],
9397
9463
  };
@@ -9446,7 +9512,7 @@ async function handleToolCall(params) {
9446
9512
  text: JSON.stringify({
9447
9513
  status: "success",
9448
9514
  message: "Wiki page deleted successfully",
9449
- }, null, 2),
9515
+ }),
9450
9516
  },
9451
9517
  ],
9452
9518
  };
@@ -9499,7 +9565,7 @@ async function handleToolCall(params) {
9499
9565
  text: JSON.stringify({
9500
9566
  status: "success",
9501
9567
  message: "Group wiki page deleted successfully",
9502
- }, null, 2),
9568
+ }),
9503
9569
  },
9504
9570
  ],
9505
9571
  };
@@ -9924,7 +9990,7 @@ async function handleToolCall(params) {
9924
9990
  topics: args.topics,
9925
9991
  verificationLevel: args.verification_level,
9926
9992
  });
9927
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
9993
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
9928
9994
  }
9929
9995
  case "get_ci_catalog_resource": {
9930
9996
  const args = GetCiCatalogResourceSchema.parse(params.arguments);
@@ -9996,7 +10062,7 @@ async function handleToolCall(params) {
9996
10062
  }
9997
10063
  }
9998
10064
  }
9999
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
10065
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
10000
10066
  }
10001
10067
  case "create_pipeline": {
10002
10068
  const { project_id, ref, variables, inputs } = CreatePipelineSchema.parse(params.arguments);
@@ -10039,14 +10105,14 @@ async function handleToolCall(params) {
10039
10105
  const { project_id, ...options } = args;
10040
10106
  const schedules = await listPipelineSchedules(project_id, options);
10041
10107
  return {
10042
- content: [{ type: "text", text: JSON.stringify(schedules, null, 2) }],
10108
+ content: [{ type: "text", text: JSON.stringify(schedules) }],
10043
10109
  };
10044
10110
  }
10045
10111
  case "get_pipeline_schedule": {
10046
10112
  const { project_id, pipeline_schedule_id } = GetPipelineScheduleSchema.parse(params.arguments);
10047
10113
  const schedule = await getPipelineSchedule(project_id, pipeline_schedule_id);
10048
10114
  return {
10049
- content: [{ type: "text", text: JSON.stringify(schedule, null, 2) }],
10115
+ content: [{ type: "text", text: JSON.stringify(schedule) }],
10050
10116
  };
10051
10117
  }
10052
10118
  case "list_pipeline_schedule_pipelines": {
@@ -10054,7 +10120,7 @@ async function handleToolCall(params) {
10054
10120
  const { project_id, pipeline_schedule_id, ...options } = args;
10055
10121
  const pipelines = await listPipelineSchedulePipelines(project_id, pipeline_schedule_id, options);
10056
10122
  return {
10057
- content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }],
10123
+ content: [{ type: "text", text: JSON.stringify(pipelines) }],
10058
10124
  };
10059
10125
  }
10060
10126
  case "create_pipeline_schedule": {
@@ -10123,7 +10189,7 @@ async function handleToolCall(params) {
10123
10189
  const { project_id, pipeline_schedule_id, key } = GetPipelineScheduleVariableSchema.parse(params.arguments);
10124
10190
  const variable = await getPipelineScheduleVariable(project_id, pipeline_schedule_id, key);
10125
10191
  return {
10126
- content: [{ type: "text", text: JSON.stringify(variable, null, 2) }],
10192
+ content: [{ type: "text", text: JSON.stringify(variable) }],
10127
10193
  };
10128
10194
  }
10129
10195
  case "create_pipeline_schedule_variable": {
@@ -10354,7 +10420,7 @@ async function handleToolCall(params) {
10354
10420
  text: JSON.stringify({
10355
10421
  status: "success",
10356
10422
  message: "Milestone deleted successfully",
10357
- }, null, 2),
10423
+ }),
10358
10424
  },
10359
10425
  ],
10360
10426
  };
@@ -10470,7 +10536,7 @@ async function handleToolCall(params) {
10470
10536
  text: JSON.stringify({
10471
10537
  status: "success",
10472
10538
  message: "Group milestone deleted successfully",
10473
- }, null, 2),
10539
+ }),
10474
10540
  },
10475
10541
  ],
10476
10542
  };
@@ -10598,7 +10664,10 @@ async function handleToolCall(params) {
10598
10664
  content: [
10599
10665
  {
10600
10666
  type: "text",
10601
- text: JSON.stringify({ status: "success", message: `Variable '${args.key}' deleted from project` }, null, 2),
10667
+ text: JSON.stringify({
10668
+ status: "success",
10669
+ message: `Variable '${args.key}' deleted from project`,
10670
+ }),
10602
10671
  },
10603
10672
  ],
10604
10673
  };
@@ -10638,7 +10707,10 @@ async function handleToolCall(params) {
10638
10707
  content: [
10639
10708
  {
10640
10709
  type: "text",
10641
- text: JSON.stringify({ status: "success", message: `Variable '${args.key}' deleted from group` }, null, 2),
10710
+ text: JSON.stringify({
10711
+ status: "success",
10712
+ message: `Variable '${args.key}' deleted from group`,
10713
+ }),
10642
10714
  },
10643
10715
  ],
10644
10716
  };
@@ -10677,7 +10749,10 @@ async function handleToolCall(params) {
10677
10749
  content: [
10678
10750
  {
10679
10751
  type: "text",
10680
- text: JSON.stringify({ status: "success", message: "Dependency proxy cache purge scheduled" }, null, 2),
10752
+ text: JSON.stringify({
10753
+ status: "success",
10754
+ message: "Dependency proxy cache purge scheduled",
10755
+ }),
10681
10756
  },
10682
10757
  ],
10683
10758
  };
@@ -10846,7 +10921,11 @@ async function handleToolCall(params) {
10846
10921
  content: [
10847
10922
  {
10848
10923
  type: "text",
10849
- text: JSON.stringify({ status: "success", message: "Release deleted successfully", release }, null, 2),
10924
+ text: JSON.stringify({
10925
+ status: "success",
10926
+ message: "Release deleted successfully",
10927
+ release,
10928
+ }),
10850
10929
  },
10851
10930
  ],
10852
10931
  };
@@ -10858,7 +10937,10 @@ async function handleToolCall(params) {
10858
10937
  content: [
10859
10938
  {
10860
10939
  type: "text",
10861
- text: JSON.stringify({ status: "success", message: "Release evidence created successfully" }, null, 2),
10940
+ text: JSON.stringify({
10941
+ status: "success",
10942
+ message: "Release evidence created successfully",
10943
+ }),
10862
10944
  },
10863
10945
  ],
10864
10946
  };
@@ -10918,7 +11000,10 @@ async function handleToolCall(params) {
10918
11000
  content: [
10919
11001
  {
10920
11002
  type: "text",
10921
- text: JSON.stringify({ status: "success", message: `Tag '${args.tag_name}' deleted successfully` }, null, 2),
11003
+ text: JSON.stringify({
11004
+ status: "success",
11005
+ message: `Tag '${args.tag_name}' deleted successfully`,
11006
+ }),
10922
11007
  },
10923
11008
  ],
10924
11009
  };
@@ -11006,7 +11091,7 @@ async function handleToolCall(params) {
11006
11091
  const { project_id, ...options } = args;
11007
11092
  const snippets = await listSnippets(project_id, options);
11008
11093
  return {
11009
- content: [{ type: "text", text: JSON.stringify(snippets, null, 2) }],
11094
+ content: [{ type: "text", text: JSON.stringify(snippets) }],
11010
11095
  };
11011
11096
  }
11012
11097
  case "get_snippet": {
@@ -11043,7 +11128,7 @@ async function handleToolCall(params) {
11043
11128
  }
11044
11129
  }
11045
11130
  return {
11046
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
11131
+ content: [{ type: "text", text: JSON.stringify(result) }],
11047
11132
  };
11048
11133
  }
11049
11134
  case "create_snippet": {
@@ -11051,7 +11136,7 @@ async function handleToolCall(params) {
11051
11136
  const { project_id, ...options } = args;
11052
11137
  const snippet = await createSnippet(project_id, options);
11053
11138
  return {
11054
- content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }],
11139
+ content: [{ type: "text", text: JSON.stringify(snippet) }],
11055
11140
  };
11056
11141
  }
11057
11142
  case "update_snippet": {
@@ -11059,7 +11144,7 @@ async function handleToolCall(params) {
11059
11144
  const { project_id, ...options } = args;
11060
11145
  const snippet = await updateSnippet(project_id, options);
11061
11146
  return {
11062
- content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }],
11147
+ content: [{ type: "text", text: JSON.stringify(snippet) }],
11063
11148
  };
11064
11149
  }
11065
11150
  case "delete_snippet": {
@@ -11072,7 +11157,7 @@ async function handleToolCall(params) {
11072
11157
  text: JSON.stringify({
11073
11158
  status: "success",
11074
11159
  message: `Snippet ${args.snippet_id} deleted successfully`,
11075
- }, null, 2),
11160
+ }),
11076
11161
  },
11077
11162
  ],
11078
11163
  };
@@ -11121,7 +11206,9 @@ async function handleToolCall(params) {
11121
11206
  content: [
11122
11207
  {
11123
11208
  type: "text",
11124
- text: JSON.stringify({ error: `Webhook event ${args.event_id} not found ${searchScope}` }, null, 2),
11209
+ text: JSON.stringify({
11210
+ error: `Webhook event ${args.event_id} not found ${searchScope}`,
11211
+ }),
11125
11212
  },
11126
11213
  ],
11127
11214
  };
@@ -11348,6 +11435,11 @@ async function handleToolCall(params) {
11348
11435
  // ToolInputError is returned as an isError tool result, so the model can fix and retry.
11349
11436
  throw new ToolInputError(`Invalid arguments for ${params.name}: ${detail}`);
11350
11437
  }
11438
+ // undici says only "fetch failed"; name the reason (TLS, DNS, refused, ...).
11439
+ const transportMessage = describeFetchTransportError(error);
11440
+ if (transportMessage !== null) {
11441
+ throw new Error(transportMessage, { cause: error });
11442
+ }
11351
11443
  throw error;
11352
11444
  }
11353
11445
  }
@@ -11389,6 +11481,7 @@ function buildDownloadProxyDeps() {
11389
11481
  async function startSSEServer() {
11390
11482
  const { express, rateLimit, ipKeyGenerator, SSEServerTransport } = await loadRemoteHttpStack();
11391
11483
  const app = express();
11484
+ const sseJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT });
11392
11485
  const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
11393
11486
  if (MCP_TRUST_PROXY) {
11394
11487
  app.set("trust proxy", 1);
@@ -11541,12 +11634,20 @@ async function startSSEServer() {
11541
11634
  }
11542
11635
  }
11543
11636
  });
11544
- app.post("/messages", rejectDnsRebinding, requireSseAuth, async (req, res) => {
11637
+ const requireSseSession = (req, res, next) => {
11638
+ const sessionId = req.query.sessionId;
11639
+ if (typeof sessionId !== "string" || !transports[sessionId]) {
11640
+ res.status(400).send("No transport found for sessionId");
11641
+ return;
11642
+ }
11643
+ next();
11644
+ };
11645
+ app.post("/messages", rejectDnsRebinding, requireSseAuth, requireSseSession, sseJsonBodyParser, async (req, res) => {
11545
11646
  const sessionId = req.query.sessionId;
11546
11647
  const transport = transports[sessionId];
11547
11648
  if (transport) {
11548
11649
  sessionLastActivity.set(sessionId, Date.now());
11549
- await transport.handlePostMessage(req, res);
11650
+ await transport.handlePostMessage(req, res, req.body);
11550
11651
  }
11551
11652
  else {
11552
11653
  res.status(400).send("No transport found for sessionId");
@@ -12119,7 +12220,7 @@ async function startStreamableHTTPServer() {
12119
12220
  app.set("trust proxy", 1);
12120
12221
  }
12121
12222
  app.use("/mcp", requireMcpHostAndOrigin);
12122
- app.use(express.json());
12223
+ const mcpJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT });
12123
12224
  registerDownloadProxy(app, buildDownloadProxyDeps());
12124
12225
  const mcpRateLimitKeyGenerator = (req) => ipKeyGenerator(normalizeProxyClientIpForRateLimit(req.ip ?? ""));
12125
12226
  const mcpRequestRateLimit = rateLimit({
@@ -12363,7 +12464,7 @@ async function startStreamableHTTPServer() {
12363
12464
  next();
12364
12465
  };
12365
12466
  // Streamable HTTP endpoint - handles both session creation and message handling
12366
- app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
12467
+ app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, mcpJsonBodyParser, async (req, res) => {
12367
12468
  const sessionId = readMcpSessionIdHeader(req);
12368
12469
  const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
12369
12470
  // Track request
@@ -0,0 +1,51 @@
1
+ // npm ships build/ only, so the text is inlined.
2
+ import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
3
+ const TEXT_MARKDOWN = "text/markdown";
4
+ const resources = [
5
+ {
6
+ uri: "gitlab-mcp://docs/auth-setup",
7
+ name: "auth-setup",
8
+ description: "Local token auth and browser OAuth.",
9
+ text: "A personal access token is the local default path. Set GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_API_URL. OAuth is the browser path. Enable it with GITLAB_USE_OAUTH and set GITLAB_OAUTH_CLIENT_ID. GITLAB_OAUTH_TOKEN_SCRIPT is a separate option: it runs a command that prints a token instead of opening a browser.",
10
+ },
11
+ {
12
+ uri: "gitlab-mcp://docs/toolsets",
13
+ name: "toolsets",
14
+ description: "How the enabled toolset list is chosen.",
15
+ text: "When GITLAB_TOOLSETS is unset, the server uses the lean core toolset. GITLAB_TOOLSETS or --toolsets sets an explicit list. An explicit list replaces core. It does not merge with core.",
16
+ },
17
+ {
18
+ uri: "gitlab-mcp://docs/permission-modes",
19
+ name: "permission-modes",
20
+ description: "How permission mode limits tool calls.",
21
+ text: "Set GITLAB_PERMISSION_MODE or pass --permission-mode. readonly blocks writes. modify blocks delete and teardown. full is the default.",
22
+ },
23
+ ];
24
+ export function listMcpResources() {
25
+ return {
26
+ resources: resources.map(resource => ({
27
+ uri: resource.uri,
28
+ name: resource.name,
29
+ description: resource.description,
30
+ mimeType: TEXT_MARKDOWN,
31
+ })),
32
+ };
33
+ }
34
+ export function listMcpResourceTemplates() {
35
+ return { resourceTemplates: [] };
36
+ }
37
+ export function readMcpResource(uri) {
38
+ const resource = resources.find(entry => entry.uri === uri);
39
+ if (resource === undefined) {
40
+ throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`);
41
+ }
42
+ return {
43
+ contents: [
44
+ {
45
+ uri: resource.uri,
46
+ mimeType: TEXT_MARKDOWN,
47
+ text: resource.text,
48
+ },
49
+ ],
50
+ };
51
+ }
@@ -3,6 +3,8 @@
3
3
  * health_check must report these as a normal tool result: the caller is
4
4
  * already asking whether the connection works, so a JSON-RPC -32603 hides the
5
5
  * only signal they came for.
6
+ * Other tools keep the JSON-RPC error, but describeFetchTransportError puts the
7
+ * reason in its message.
6
8
  *
7
9
  * node-fetch puts the OS code on `code` / `errno`. undici leaves `code` empty
8
10
  * on the TypeError and stores it on `cause.code` (errno there is numeric).
@@ -25,6 +27,14 @@ const TIMEOUT_ERROR_NAMES = new Set(["AbortError", "TimeoutError"]);
25
27
  const REQUEST_TIMEOUT_TYPE = "request-timeout";
26
28
  const TLS_CA_HINT = "Make sure NODE_EXTRA_CA_CERTS or GITLAB_CA_CERT_PATH contains the full certificate chain, including intermediate CAs.";
27
29
  const FALLBACK_MESSAGE = "GitLab request failed before an HTTP response";
30
+ // undici rejects with exactly this TypeError message and keeps the reason on `cause`.
31
+ const UNDICI_FETCH_FAILED_MESSAGE = "fetch failed";
32
+ const TRANSPORT_HINTS = {
33
+ tls: TLS_CA_HINT,
34
+ dns: "Check the host in the GitLab API URL and the DNS or proxy settings.",
35
+ timeout: "Check that GitLab is reachable from this host and the proxy settings.",
36
+ network: "Check the GitLab API URL, that GitLab is reachable from this host, and the proxy settings.",
37
+ };
28
38
  export function classifyHealthCheckTransportError(error) {
29
39
  const code = readTransportCode(error);
30
40
  const kind = classifyKind(error, code);
@@ -38,6 +48,20 @@ export function classifyHealthCheckTransportError(error) {
38
48
  }
39
49
  return failure;
40
50
  }
51
+ /**
52
+ * Message for a tool call whose fetch never got an HTTP response, e.g.
53
+ * "GitLab request failed before an HTTP response (ENOTFOUND): fetch failed:
54
+ * getaddrinfo ENOTFOUND gitlab.example.com. Check the host ...".
55
+ * Returns null for anything that is not undici's "fetch failed" TypeError.
56
+ */
57
+ export function describeFetchTransportError(error) {
58
+ if (!(error instanceof TypeError) || error.message !== UNDICI_FETCH_FAILED_MESSAGE) {
59
+ return null;
60
+ }
61
+ const failure = classifyHealthCheckTransportError(error);
62
+ const reason = failure.code ?? failure.kind;
63
+ return `${FALLBACK_MESSAGE} (${reason}): ${failure.message}. ${TRANSPORT_HINTS[failure.kind]}`;
64
+ }
41
65
  function classifyKind(error, code) {
42
66
  if (code !== null) {
43
67
  return kindFromCode(code);
@@ -33,6 +33,9 @@ export function applyJmespathFilter(data, expression) {
33
33
  function isContentBlock(value) {
34
34
  return Boolean(value && typeof value === "object");
35
35
  }
36
+ function isJsonObject(value) {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
36
39
  function prepareDataForJmespath(data, maskValue) {
37
40
  return maskValue ? maskValue(data) : data;
38
41
  }
@@ -68,9 +71,15 @@ export function applyJmespathToToolResult(result, expression, options) {
68
71
  return parsed.ok ? { ...block, text: JSON.stringify(filter(parsed.value)) } : block;
69
72
  })
70
73
  : result.content;
71
- return result.structuredContent === undefined
72
- ? { ...result, content }
73
- : { ...result, content, structuredContent: filter(result.structuredContent) };
74
+ if (result.structuredContent === undefined)
75
+ return { ...result, content };
76
+ // MCP structuredContent must be a JSON object, and the SDK rejects the whole tools/call
77
+ // result otherwise. "items[].title" or a missing path is not one, so drop it; the text keeps it.
78
+ const structuredContent = filter(result.structuredContent);
79
+ if (isJsonObject(structuredContent))
80
+ return { ...result, content, structuredContent };
81
+ const { structuredContent: _dropped, ...rest } = result;
82
+ return { ...rest, content };
74
83
  }
75
84
  catch {
76
85
  return result;
@@ -0,0 +1,27 @@
1
+ export const POLL_TIMEOUT_MESSAGE = "Timed out waiting for terminal status";
2
+ function isAbortError(error) {
3
+ return error instanceof Error && error.name === "AbortError";
4
+ }
5
+ /**
6
+ * Fetch one poll result inside the deadline. When the deadline signal aborts the
7
+ * request, the wait has timed out, so report that instead of the abort. Any other
8
+ * rejection passes through unchanged, even one that lands after the deadline.
9
+ */
10
+ export async function fetchStatusBeforeDeadline(fetchStatus, deadline) {
11
+ const remaining = deadline - Date.now();
12
+ if (remaining <= 0) {
13
+ throw new Error(POLL_TIMEOUT_MESSAGE);
14
+ }
15
+ const signal = AbortSignal.timeout(remaining);
16
+ try {
17
+ return await fetchStatus(signal);
18
+ }
19
+ catch (error) {
20
+ // fetch rejects with signal.reason itself when this signal aborts it. A body read
21
+ // that starts after the abort throws a new AbortError instead.
22
+ if (signal.aborted && (error === signal.reason || isAbortError(error))) {
23
+ throw new Error(POLL_TIMEOUT_MESSAGE, { cause: error });
24
+ }
25
+ throw error;
26
+ }
27
+ }
@@ -34,7 +34,7 @@ export function redactUrlSecretsInText(text) {
34
34
  return redactSensitiveQuery(redactUrlUserinfoInText(redactParseableUrlUserinfo(text)));
35
35
  }
36
36
  function redactSensitiveQuery(url) {
37
- return url.replace(/([?&])([^=&\s#]+)=[^&\s#]+/g, (match, separator, rawName) => {
37
+ return url.replace(/([?&#])([^=&\s#]+)=[^&\s#]+/g, (match, separator, rawName) => {
38
38
  if (!isSensitiveQueryName(rawName)) {
39
39
  return match;
40
40
  }
@@ -53,7 +53,7 @@ function decodeQueryName(rawName) {
53
53
  }
54
54
  }
55
55
  function redactUrlUserinfoInText(message) {
56
- const withoutCompactUserinfo = message.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, urlToken => redactCompactUserinfo(urlToken));
56
+ const withoutCompactUserinfo = message.replace(/\b[a-z][a-z0-9+.-]*:(?:\/\/|\/)?[^\s]+/gi, urlToken => redactCompactUserinfo(urlToken));
57
57
  return withoutCompactUserinfo.replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^\s/:@.]+(?::[^\s/:@.]+)+(?:\s+[^\s/:@.]+)+)@/gi, (match, scheme, userinfo, offset, source) => {
58
58
  if (looksLikeHostPort(userinfo, source.slice(offset + match.length))) {
59
59
  return match;
@@ -84,13 +84,13 @@ function tryParseUrl(value) {
84
84
  }
85
85
  }
86
86
  function splitAuthorityUserinfo(url) {
87
- // WHATWG special schemes still expose userinfo for "https:user:pass@host".
88
- const schemeMatch = /^([a-z][a-z0-9+.-]*:)(\/\/)?/i.exec(url);
89
- if (schemeMatch === null) {
87
+ // WHATWG special schemes still expose userinfo for "https:user:pass@host"
88
+ // and for one slash, "https:/user:pass@host".
89
+ const prefix = readSchemePrefix(url);
90
+ if (prefix === null) {
90
91
  return null;
91
92
  }
92
- const schemeEnd = schemeMatch[0].length;
93
- const rest = url.slice(schemeEnd);
93
+ const rest = prefix.rest;
94
94
  const slash = rest.indexOf("/");
95
95
  const query = rest.indexOf("?");
96
96
  const hash = rest.indexOf("#");
@@ -102,18 +102,78 @@ function splitAuthorityUserinfo(url) {
102
102
  return null;
103
103
  }
104
104
  return {
105
- scheme: url.slice(0, schemeEnd),
105
+ scheme: prefix.scheme,
106
106
  afterAt: `${authority.slice(atIndex + 1)}${rest.slice(authorityEnd)}`,
107
107
  };
108
108
  }
109
109
  function redactCompactUserinfo(urlToken) {
110
- const schemeIndex = urlToken.indexOf("://");
111
- const rest = urlToken.slice(schemeIndex + 3);
112
- const atIndex = rest.lastIndexOf("@");
110
+ const prefix = readSchemePrefix(urlToken);
111
+ if (prefix === null) {
112
+ return urlToken;
113
+ }
114
+ const atIndex = prefix.rest.lastIndexOf("@");
113
115
  if (atIndex <= 0) {
114
116
  return urlToken;
115
117
  }
116
- return `${urlToken.slice(0, schemeIndex + 3)}[REDACTED]@${rest.slice(atIndex + 1)}`;
118
+ return `${prefix.scheme}[REDACTED]@${prefix.rest.slice(atIndex + 1)}`;
119
+ }
120
+ const SCHEME_PREFIX_PATTERN = /^([a-z][a-z0-9+.-]*:)(\/\/|\/)?/i;
121
+ function readSchemePrefix(value) {
122
+ const schemeMatch = SCHEME_PREFIX_PATTERN.exec(value);
123
+ if (schemeMatch === null) {
124
+ return null;
125
+ }
126
+ const scheme = schemeMatch[0];
127
+ return {
128
+ scheme,
129
+ rest: value.slice(scheme.length),
130
+ };
131
+ }
132
+ export function splitCommaSeparatedUrls(value) {
133
+ const parts = [];
134
+ let start = 0;
135
+ for (let index = 0; index < value.length; index += 1) {
136
+ if (value[index] !== ",") {
137
+ continue;
138
+ }
139
+ if (commaIsInsideUserinfo(value, start, index)) {
140
+ continue;
141
+ }
142
+ parts.push(value.slice(start, index).trim());
143
+ start = index + 1;
144
+ }
145
+ parts.push(value.slice(start).trim());
146
+ return parts;
147
+ }
148
+ // "https://a.example,https:user:pass@b.example" is two URLs; the "@" after the
149
+ // comma belongs to the next one. The text before the comma must parse as a URL
150
+ // by itself, so "https://alice:secret,https:word@host" stays one entry.
151
+ // Only http(s) counts, so "pass,abc:def@host" stays.
152
+ const NEXT_HTTP_URL_PATTERN = /^https?:/i;
153
+ function commaIsInsideUserinfo(value, start, commaIndex) {
154
+ const entry = value.slice(start, commaIndex).trim();
155
+ const prefix = readSchemePrefix(entry);
156
+ if (prefix === null || authorityAlreadyClosed(prefix.rest)) {
157
+ return false;
158
+ }
159
+ const afterComma = value.slice(commaIndex + 1);
160
+ const startsNextUrl = NEXT_HTTP_URL_PATTERN.test(afterComma) && tryParseUrl(entry) !== null;
161
+ return !startsNextUrl && userinfoAtFollows(afterComma);
162
+ }
163
+ function authorityAlreadyClosed(rest) {
164
+ return rest.includes("@") || rest.includes("/") || rest.includes("?") || rest.includes("#");
165
+ }
166
+ function userinfoAtFollows(afterComma) {
167
+ for (let index = 0; index < afterComma.length; index += 1) {
168
+ const char = afterComma[index];
169
+ if (char === "@") {
170
+ return true;
171
+ }
172
+ if (char === "/" || char === "?" || char === "#" || char === " " || char === "\t") {
173
+ return false;
174
+ }
175
+ }
176
+ return false;
117
177
  }
118
178
  // "user:pa ss@host" is a password. "localhost:3000 a@b.com" is a host, then an email.
119
179
  // A numeric port counts only when one token follows it and the text after "@" is an email domain.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
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": [
@@ -32,6 +32,7 @@
32
32
  "files": [
33
33
  "build"
34
34
  ],
35
+ "homepage": "https://zereight.github.io/gitlab-mcp/",
35
36
  "repository": {
36
37
  "type": "git",
37
38
  "url": "git+https://github.com/zereight/gitlab-mcp.git"