@zereight/mcp-gitlab 2.3.3 → 2.3.5-next.124

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.2`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `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`를 사용하세요. `main`에 머지된 수정을 다음 안정 버전 전에 확인하려면 프리릴리스 빌드인 `npx -y @zereight/mcp-gitlab@next`를 사용하세요(운영 환경용이 아닙니다). 새 버전이 나오면 서버가 시작 시 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.2`. 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. To try a fix merged to `main` before the next stable release, use `npx -y @zereight/mcp-gitlab@next`, a pre-release build that is not meant for production. 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.2`. 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`. Para testar uma correção já mesclada na `main` antes da próxima versão estável, use `npx -y @zereight/mcp-gitlab@next`, um build de pré-lançamento que não é indicado para produção. 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.2`。如果始终想使用最新版本,请改用 `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`。如需在下一个稳定版本发布前试用已合并到 `main` 的修复,请使用 `npx -y @zereight/mcp-gitlab@next`(预发布版本,不适用于生产环境)。有新版本发布时,服务器会在启动时通过 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 [
@@ -6385,11 +6393,7 @@ function delayForRebasePoll(delayMs) {
6385
6393
  });
6386
6394
  }
6387
6395
  async function getMergeRequestRebaseStatusBeforeDeadline(projectId, mergeRequestIid, deadline) {
6388
- const remaining = deadline - Date.now();
6389
- if (remaining <= 0) {
6390
- throw new Error("Timed out waiting for terminal status");
6391
- }
6392
- return getMergeRequestRebaseStatus(projectId, mergeRequestIid, AbortSignal.timeout(remaining));
6396
+ return fetchStatusBeforeDeadline(signal => getMergeRequestRebaseStatus(projectId, mergeRequestIid, signal), deadline);
6393
6397
  }
6394
6398
  /**
6395
6399
  * Poll until a rebase finishes, without trusting a finished snapshot that still
@@ -6433,12 +6437,7 @@ async function waitForMergeRequestRebase(projectId, mergeRequestIid, previous, t
6433
6437
  }
6434
6438
  async function waitForStatus(fetchStatus, terminalStatuses, timeoutSeconds = 300, pollIntervalSeconds = 5) {
6435
6439
  const deadline = Date.now() + timeoutSeconds * 1000;
6436
- const fetchBeforeDeadline = async () => {
6437
- const remaining = deadline - Date.now();
6438
- if (remaining <= 0)
6439
- throw new Error("Timed out waiting for terminal status");
6440
- return fetchStatus(AbortSignal.timeout(remaining));
6441
- };
6440
+ const fetchBeforeDeadline = () => fetchStatusBeforeDeadline(fetchStatus, deadline);
6442
6441
  let value = await fetchBeforeDeadline();
6443
6442
  while (!terminalStatuses.has(value.status)) {
6444
6443
  const remaining = deadline - Date.now();
@@ -8301,7 +8300,7 @@ async function handleToolCall(params) {
8301
8300
  };
8302
8301
  }
8303
8302
  catch (err) {
8304
- const message = err instanceof Error ? err.message : String(err);
8303
+ const message = describeFetchTransportError(err) ?? (err instanceof Error ? err.message : String(err));
8305
8304
  return {
8306
8305
  content: [
8307
8306
  {
@@ -8351,7 +8350,8 @@ async function handleToolCall(params) {
8351
8350
  logger.error({ err: forkError }, "Error forking repository");
8352
8351
  let forkErrorMessage = "Failed to fork repository";
8353
8352
  if (forkError instanceof Error) {
8354
- forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`;
8353
+ const reason = describeFetchTransportError(forkError) ?? forkError.message;
8354
+ forkErrorMessage = `${forkErrorMessage}: ${reason}`;
8355
8355
  }
8356
8356
  return {
8357
8357
  content: [
@@ -8719,7 +8719,7 @@ async function handleToolCall(params) {
8719
8719
  text: JSON.stringify({
8720
8720
  status: "success",
8721
8721
  message: "All pending to-do items marked as done",
8722
- }, null, 2),
8722
+ }),
8723
8723
  },
8724
8724
  ],
8725
8725
  };
@@ -8963,9 +8963,7 @@ async function handleToolCall(params) {
8963
8963
  await handleGitLabError(response);
8964
8964
  const data = await response.json();
8965
8965
  return {
8966
- content: [
8967
- { type: "text", text: JSON.stringify(redactSensitiveGitLabFields(data), null, 2) },
8968
- ],
8966
+ content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(data)) }],
8969
8967
  };
8970
8968
  }
8971
8969
  case "list_project_members": {
@@ -9186,7 +9184,7 @@ async function handleToolCall(params) {
9186
9184
  changes: result.changes,
9187
9185
  summary: result.summary,
9188
9186
  preview: result.preview,
9189
- }, null, 2),
9187
+ }),
9190
9188
  },
9191
9189
  ],
9192
9190
  };
@@ -9225,7 +9223,7 @@ async function handleToolCall(params) {
9225
9223
  web_url: updatedIssue.web_url,
9226
9224
  updated_at: updatedIssue.updated_at,
9227
9225
  },
9228
- }, null, 2),
9226
+ }),
9229
9227
  },
9230
9228
  ],
9231
9229
  };
@@ -9237,7 +9235,7 @@ async function handleToolCall(params) {
9237
9235
  content: [
9238
9236
  {
9239
9237
  type: "text",
9240
- text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }, null, 2),
9238
+ text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }),
9241
9239
  },
9242
9240
  ],
9243
9241
  };
@@ -9281,7 +9279,7 @@ async function handleToolCall(params) {
9281
9279
  text: JSON.stringify({
9282
9280
  status: "success",
9283
9281
  message: "Issue link deleted successfully",
9284
- }, null, 2),
9282
+ }),
9285
9283
  },
9286
9284
  ],
9287
9285
  };
@@ -9459,7 +9457,7 @@ async function handleToolCall(params) {
9459
9457
  content: [
9460
9458
  {
9461
9459
  type: "text",
9462
- text: JSON.stringify({ status: "success", message: "Label deleted successfully" }, null, 2),
9460
+ text: JSON.stringify({ status: "success", message: "Label deleted successfully" }),
9463
9461
  },
9464
9462
  ],
9465
9463
  };
@@ -9514,7 +9512,7 @@ async function handleToolCall(params) {
9514
9512
  text: JSON.stringify({
9515
9513
  status: "success",
9516
9514
  message: "Wiki page deleted successfully",
9517
- }, null, 2),
9515
+ }),
9518
9516
  },
9519
9517
  ],
9520
9518
  };
@@ -9567,7 +9565,7 @@ async function handleToolCall(params) {
9567
9565
  text: JSON.stringify({
9568
9566
  status: "success",
9569
9567
  message: "Group wiki page deleted successfully",
9570
- }, null, 2),
9568
+ }),
9571
9569
  },
9572
9570
  ],
9573
9571
  };
@@ -9992,7 +9990,7 @@ async function handleToolCall(params) {
9992
9990
  topics: args.topics,
9993
9991
  verificationLevel: args.verification_level,
9994
9992
  });
9995
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
9993
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
9996
9994
  }
9997
9995
  case "get_ci_catalog_resource": {
9998
9996
  const args = GetCiCatalogResourceSchema.parse(params.arguments);
@@ -10064,7 +10062,7 @@ async function handleToolCall(params) {
10064
10062
  }
10065
10063
  }
10066
10064
  }
10067
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
10065
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
10068
10066
  }
10069
10067
  case "create_pipeline": {
10070
10068
  const { project_id, ref, variables, inputs } = CreatePipelineSchema.parse(params.arguments);
@@ -10107,14 +10105,14 @@ async function handleToolCall(params) {
10107
10105
  const { project_id, ...options } = args;
10108
10106
  const schedules = await listPipelineSchedules(project_id, options);
10109
10107
  return {
10110
- content: [{ type: "text", text: JSON.stringify(schedules, null, 2) }],
10108
+ content: [{ type: "text", text: JSON.stringify(schedules) }],
10111
10109
  };
10112
10110
  }
10113
10111
  case "get_pipeline_schedule": {
10114
10112
  const { project_id, pipeline_schedule_id } = GetPipelineScheduleSchema.parse(params.arguments);
10115
10113
  const schedule = await getPipelineSchedule(project_id, pipeline_schedule_id);
10116
10114
  return {
10117
- content: [{ type: "text", text: JSON.stringify(schedule, null, 2) }],
10115
+ content: [{ type: "text", text: JSON.stringify(schedule) }],
10118
10116
  };
10119
10117
  }
10120
10118
  case "list_pipeline_schedule_pipelines": {
@@ -10122,7 +10120,7 @@ async function handleToolCall(params) {
10122
10120
  const { project_id, pipeline_schedule_id, ...options } = args;
10123
10121
  const pipelines = await listPipelineSchedulePipelines(project_id, pipeline_schedule_id, options);
10124
10122
  return {
10125
- content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }],
10123
+ content: [{ type: "text", text: JSON.stringify(pipelines) }],
10126
10124
  };
10127
10125
  }
10128
10126
  case "create_pipeline_schedule": {
@@ -10191,7 +10189,7 @@ async function handleToolCall(params) {
10191
10189
  const { project_id, pipeline_schedule_id, key } = GetPipelineScheduleVariableSchema.parse(params.arguments);
10192
10190
  const variable = await getPipelineScheduleVariable(project_id, pipeline_schedule_id, key);
10193
10191
  return {
10194
- content: [{ type: "text", text: JSON.stringify(variable, null, 2) }],
10192
+ content: [{ type: "text", text: JSON.stringify(variable) }],
10195
10193
  };
10196
10194
  }
10197
10195
  case "create_pipeline_schedule_variable": {
@@ -10422,7 +10420,7 @@ async function handleToolCall(params) {
10422
10420
  text: JSON.stringify({
10423
10421
  status: "success",
10424
10422
  message: "Milestone deleted successfully",
10425
- }, null, 2),
10423
+ }),
10426
10424
  },
10427
10425
  ],
10428
10426
  };
@@ -10538,7 +10536,7 @@ async function handleToolCall(params) {
10538
10536
  text: JSON.stringify({
10539
10537
  status: "success",
10540
10538
  message: "Group milestone deleted successfully",
10541
- }, null, 2),
10539
+ }),
10542
10540
  },
10543
10541
  ],
10544
10542
  };
@@ -10666,7 +10664,10 @@ async function handleToolCall(params) {
10666
10664
  content: [
10667
10665
  {
10668
10666
  type: "text",
10669
- 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
+ }),
10670
10671
  },
10671
10672
  ],
10672
10673
  };
@@ -10706,7 +10707,10 @@ async function handleToolCall(params) {
10706
10707
  content: [
10707
10708
  {
10708
10709
  type: "text",
10709
- 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
+ }),
10710
10714
  },
10711
10715
  ],
10712
10716
  };
@@ -10745,7 +10749,10 @@ async function handleToolCall(params) {
10745
10749
  content: [
10746
10750
  {
10747
10751
  type: "text",
10748
- 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
+ }),
10749
10756
  },
10750
10757
  ],
10751
10758
  };
@@ -10914,7 +10921,11 @@ async function handleToolCall(params) {
10914
10921
  content: [
10915
10922
  {
10916
10923
  type: "text",
10917
- 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
+ }),
10918
10929
  },
10919
10930
  ],
10920
10931
  };
@@ -10926,7 +10937,10 @@ async function handleToolCall(params) {
10926
10937
  content: [
10927
10938
  {
10928
10939
  type: "text",
10929
- 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
+ }),
10930
10944
  },
10931
10945
  ],
10932
10946
  };
@@ -10986,7 +11000,10 @@ async function handleToolCall(params) {
10986
11000
  content: [
10987
11001
  {
10988
11002
  type: "text",
10989
- 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
+ }),
10990
11007
  },
10991
11008
  ],
10992
11009
  };
@@ -11074,7 +11091,7 @@ async function handleToolCall(params) {
11074
11091
  const { project_id, ...options } = args;
11075
11092
  const snippets = await listSnippets(project_id, options);
11076
11093
  return {
11077
- content: [{ type: "text", text: JSON.stringify(snippets, null, 2) }],
11094
+ content: [{ type: "text", text: JSON.stringify(snippets) }],
11078
11095
  };
11079
11096
  }
11080
11097
  case "get_snippet": {
@@ -11111,7 +11128,7 @@ async function handleToolCall(params) {
11111
11128
  }
11112
11129
  }
11113
11130
  return {
11114
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
11131
+ content: [{ type: "text", text: JSON.stringify(result) }],
11115
11132
  };
11116
11133
  }
11117
11134
  case "create_snippet": {
@@ -11119,7 +11136,7 @@ async function handleToolCall(params) {
11119
11136
  const { project_id, ...options } = args;
11120
11137
  const snippet = await createSnippet(project_id, options);
11121
11138
  return {
11122
- content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }],
11139
+ content: [{ type: "text", text: JSON.stringify(snippet) }],
11123
11140
  };
11124
11141
  }
11125
11142
  case "update_snippet": {
@@ -11127,7 +11144,7 @@ async function handleToolCall(params) {
11127
11144
  const { project_id, ...options } = args;
11128
11145
  const snippet = await updateSnippet(project_id, options);
11129
11146
  return {
11130
- content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }],
11147
+ content: [{ type: "text", text: JSON.stringify(snippet) }],
11131
11148
  };
11132
11149
  }
11133
11150
  case "delete_snippet": {
@@ -11140,7 +11157,7 @@ async function handleToolCall(params) {
11140
11157
  text: JSON.stringify({
11141
11158
  status: "success",
11142
11159
  message: `Snippet ${args.snippet_id} deleted successfully`,
11143
- }, null, 2),
11160
+ }),
11144
11161
  },
11145
11162
  ],
11146
11163
  };
@@ -11189,7 +11206,9 @@ async function handleToolCall(params) {
11189
11206
  content: [
11190
11207
  {
11191
11208
  type: "text",
11192
- 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
+ }),
11193
11212
  },
11194
11213
  ],
11195
11214
  };
@@ -11416,6 +11435,11 @@ async function handleToolCall(params) {
11416
11435
  // ToolInputError is returned as an isError tool result, so the model can fix and retry.
11417
11436
  throw new ToolInputError(`Invalid arguments for ${params.name}: ${detail}`);
11418
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
+ }
11419
11443
  throw error;
11420
11444
  }
11421
11445
  }
@@ -11457,6 +11481,7 @@ function buildDownloadProxyDeps() {
11457
11481
  async function startSSEServer() {
11458
11482
  const { express, rateLimit, ipKeyGenerator, SSEServerTransport } = await loadRemoteHttpStack();
11459
11483
  const app = express();
11484
+ const sseJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT });
11460
11485
  const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
11461
11486
  if (MCP_TRUST_PROXY) {
11462
11487
  app.set("trust proxy", 1);
@@ -11609,12 +11634,20 @@ async function startSSEServer() {
11609
11634
  }
11610
11635
  }
11611
11636
  });
11612
- 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) => {
11613
11646
  const sessionId = req.query.sessionId;
11614
11647
  const transport = transports[sessionId];
11615
11648
  if (transport) {
11616
11649
  sessionLastActivity.set(sessionId, Date.now());
11617
- await transport.handlePostMessage(req, res);
11650
+ await transport.handlePostMessage(req, res, req.body);
11618
11651
  }
11619
11652
  else {
11620
11653
  res.status(400).send("No transport found for sessionId");
@@ -12187,7 +12220,7 @@ async function startStreamableHTTPServer() {
12187
12220
  app.set("trust proxy", 1);
12188
12221
  }
12189
12222
  app.use("/mcp", requireMcpHostAndOrigin);
12190
- app.use(express.json());
12223
+ const mcpJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT });
12191
12224
  registerDownloadProxy(app, buildDownloadProxyDeps());
12192
12225
  const mcpRateLimitKeyGenerator = (req) => ipKeyGenerator(normalizeProxyClientIpForRateLimit(req.ip ?? ""));
12193
12226
  const mcpRequestRateLimit = rateLimit({
@@ -12431,7 +12464,7 @@ async function startStreamableHTTPServer() {
12431
12464
  next();
12432
12465
  };
12433
12466
  // Streamable HTTP endpoint - handles both session creation and message handling
12434
- app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
12467
+ app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, mcpJsonBodyParser, async (req, res) => {
12435
12468
  const sessionId = readMcpSessionIdHeader(req);
12436
12469
  const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
12437
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.3",
3
+ "version": "2.3.5-next.124",
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"