@zereight/mcp-gitlab 2.3.4 → 2.3.5-next.125

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.3`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `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
 
@@ -304,7 +304,7 @@ MCP 서버가 직접 로컬 브라우저 callback을 받을 때만 `GITLAB_OAUTH
304
304
  | 환경 변수 | 필수 | 설명 |
305
305
  | ----------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------- |
306
306
  | `GITLAB_MCP_OAUTH` | 예 | 활성화하려면 `true` |
307
- | `GITLAB_API_URL` | 예 | GitLab API base URL |
307
+ | `GITLAB_API_URL` | 예 | GitLab API base URL. URL에 자격 증명(`user:pass@`)을 넣지 마세요. 넣어도 무시됩니다. |
308
308
  | `GITLAB_OAUTH_APP_ID` | 예 | GitLab OAuth Application ID |
309
309
  | `MCP_SERVER_URL` | 예 | 이 MCP 서버의 공개 HTTPS URL |
310
310
  | `STREAMABLE_HTTP` | 예 | 반드시 `true` |
@@ -572,7 +572,7 @@ node build/index.js
572
572
  | `GITLAB_MCP_OAUTH` | 예 | 활성화하려면 `true` |
573
573
  | `GITLAB_OAUTH_APP_ID` | 예 | 사전 등록 GitLab OAuth 애플리케이션의 client ID |
574
574
  | `MCP_SERVER_URL` | 예 | MCP 서버의 공개 HTTPS URL |
575
- | `GITLAB_API_URL` | 예 | GitLab 인스턴스 API URL(예: `https://gitlab.com/api/v4`) |
575
+ | `GITLAB_API_URL` | 예 | GitLab 인스턴스 API URL(예: `https://gitlab.com/api/v4`). URL에 자격 증명(`user:pass@`)을 넣지 마세요. 넣어도 무시됩니다. |
576
576
  | `STREAMABLE_HTTP` | 예 | 반드시 `true`(SSE 미지원) |
577
577
  | `GITLAB_OAUTH_SCOPES` | 아니오 | 요청할 GitLab scope 목록(쉼표 구분). 기본값은 `api`이며, 유효 permission mode가 `readonly`일 때 `read_api`입니다. 사전 등록 애플리케이션에 해당 scope가 설정되어 있어야 합니다. |
578
578
  | `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | 아니오 | Dynamic Client Registration(`POST /register`)의 클라이언트 IP당 rolling 한도. 기본 `20`/시간, 범위 `1`–`1000`. IDE 창 여러 개 등으로 등록이 막히면 올리세요. GitLab API 한도와 무관합니다. |
package/README.md CHANGED
@@ -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.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`).
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
 
@@ -326,7 +326,7 @@ exchanging credentials with GitLab on behalf of the client.
326
326
  | Environment Variable | Required | Description |
327
327
  | ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
328
328
  | `GITLAB_MCP_OAUTH` | ✅ | Set to `true` to enable |
329
- | `GITLAB_API_URL` | ✅ | GitLab API base URL |
329
+ | `GITLAB_API_URL` | ✅ | GitLab API base URL. Do not embed credentials (`user:pass@`) in the URL; they are ignored. |
330
330
  | `GITLAB_OAUTH_APP_ID` | ✅ | GitLab OAuth Application ID |
331
331
  | `MCP_SERVER_URL` | ✅ | Public HTTPS URL of this MCP server |
332
332
  | `STREAMABLE_HTTP` | ✅ | Must be `true` |
@@ -614,7 +614,7 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic
614
614
  | `GITLAB_MCP_OAUTH` | Yes | Set to `true` to enable |
615
615
  | `GITLAB_OAUTH_APP_ID` | Yes | Client ID of the pre-registered GitLab OAuth application |
616
616
  | `MCP_SERVER_URL` | Yes | Public HTTPS URL of your MCP server; also allowed for `/mcp` Host/Origin checks |
617
- | `GITLAB_API_URL` | Yes | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`) |
617
+ | `GITLAB_API_URL` | Yes | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`). Do not embed credentials (`user:pass@`) in the URL; they are ignored. |
618
618
  | `STREAMABLE_HTTP` | Yes | Must be `true` (SSE is not supported) |
619
619
  | `GITLAB_OAUTH_SCOPES` | No | Comma-separated GitLab scopes to request (e.g. `api,read_user`). Defaults to `api` (or `read_api` when the effective permission mode is `readonly`). The pre-registered application must be configured with at least these scopes. |
620
620
  | `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | No | Per-IP rolling limit for Dynamic Client Registration (`POST /register`). Default `20`/hour; range `1`–`1000`. Raise when clients (e.g. multiple IDE windows) hit registration throttling. Not a GitLab API limit. |
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.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`).
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
 
@@ -304,7 +304,7 @@ Use `GITLAB_OAUTH_REDIRECT_URI` somente quando o próprio servidor MCP receber o
304
304
  | Variável de ambiente | Obrigatória | Descrição |
305
305
  | ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
306
306
  | `GITLAB_MCP_OAUTH` | sim | Defina como `true` para habilitar |
307
- | `GITLAB_API_URL` | sim | URL base da API do GitLab |
307
+ | `GITLAB_API_URL` | sim | URL base da API do GitLab. Não inclua credenciais (`user:pass@`) na URL; elas são ignoradas. |
308
308
  | `GITLAB_OAUTH_APP_ID` | sim | Application ID do OAuth do GitLab |
309
309
  | `MCP_SERVER_URL` | sim | URL HTTPS pública deste servidor MCP |
310
310
  | `STREAMABLE_HTTP` | sim | Deve ser `true` |
@@ -572,7 +572,7 @@ Não é necessário definir `headers`. O Claude.ai obtém o token via OAuth.
572
572
  | `GITLAB_MCP_OAUTH` | sim | Defina como `true` para habilitar |
573
573
  | `GITLAB_OAUTH_APP_ID` | sim | Client ID do aplicativo OAuth do GitLab previamente registrado |
574
574
  | `MCP_SERVER_URL` | sim | URL HTTPS pública do servidor MCP |
575
- | `GITLAB_API_URL` | sim | URL da API da instância GitLab (por exemplo, `https://gitlab.com/api/v4`) |
575
+ | `GITLAB_API_URL` | sim | URL da API da instância GitLab (por exemplo, `https://gitlab.com/api/v4`). Não inclua credenciais (`user:pass@`) na URL; elas são ignoradas. |
576
576
  | `STREAMABLE_HTTP` | sim | Deve ser `true` (SSE não é suportado) |
577
577
  | `GITLAB_OAUTH_SCOPES` | não | Scopes do GitLab a solicitar, separados por vírgula. O padrão é `api`, ou `read_api` quando o permission mode efetivo é `readonly`. O aplicativo previamente registrado deve ter pelo menos esses scopes configurados. |
578
578
  | `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | não | Limite rolling de Dynamic Client Registration (`POST /register`) por IP do cliente. Padrão `20`/hora, intervalo `1`–`1000`. Aumente se múltiplas janelas de IDE estiverem atingindo o limite. Não está relacionado aos limites da API do GitLab. |
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.3`。如果始终想使用最新版本,请改用 `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
 
@@ -303,7 +303,7 @@ OpenCode、MCPJam、Claude.ai 等远程 MCP 客户端可能会在授权时发送
303
303
  | 环境变量 | 必需 | 说明 |
304
304
  | ----------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- |
305
305
  | `GITLAB_MCP_OAUTH` | 是 | 设置为 `true` 以启用 |
306
- | `GITLAB_API_URL` | 是 | GitLab API base URL |
306
+ | `GITLAB_API_URL` | 是 | GitLab API base URL。不要在 URL 中嵌入凭据(`user:pass@`),嵌入的凭据会被忽略。 |
307
307
  | `GITLAB_OAUTH_APP_ID` | 是 | GitLab OAuth Application ID |
308
308
  | `MCP_SERVER_URL` | 是 | 此 MCP 服务器的公开 HTTPS URL |
309
309
  | `STREAMABLE_HTTP` | 是 | 必须为 `true` |
@@ -571,7 +571,7 @@ node build/index.js
571
571
  | `GITLAB_MCP_OAUTH` | 是 | 设置为 `true` 以启用 |
572
572
  | `GITLAB_OAUTH_APP_ID` | 是 | 预先注册的 GitLab OAuth 应用 client ID |
573
573
  | `MCP_SERVER_URL` | 是 | MCP 服务器的公开 HTTPS URL |
574
- | `GITLAB_API_URL` | 是 | GitLab 实例 API URL(例如 `https://gitlab.com/api/v4`) |
574
+ | `GITLAB_API_URL` | 是 | GitLab 实例 API URL(例如 `https://gitlab.com/api/v4`)。不要在 URL 中嵌入凭据(`user:pass@`),嵌入的凭据会被忽略。 |
575
575
  | `STREAMABLE_HTTP` | 是 | 必须为 `true`(不支持 SSE) |
576
576
  | `GITLAB_OAUTH_SCOPES` | 否 | 要请求的 GitLab scopes,以逗号分隔。默认值为 `api`;有效 permission mode 为 `readonly` 时为 `read_api`。预注册应用必须配置至少这些 scopes。 |
577
577
  | `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | 否 | Dynamic Client Registration(`POST /register`)的每客户端 IP rolling 限制。默认 `20`/小时,范围 `1`–`1000`。多个 IDE 窗口等导致注册被限流时可调高。与 GitLab API 限额无关。 |
package/build/auth-cli.js CHANGED
@@ -4,6 +4,8 @@ import { parseCliArgs } from "./cli-boolean-flags.js";
4
4
  import { deprecatedEnvInputFromSources, getDeprecatedEnvWarnings } from "./deprecated-env.js";
5
5
  import { GitLabOAuth } from "./oauth.js";
6
6
  import { createLogger, flushStartupLogs } from "./utils/logger.js";
7
+ import { stripUrlUserinfo } from "./utils/redact-url.js";
8
+ import { apiUrlUserinfoWarning } from "./utils/url.js";
7
9
  const logger = createLogger("gitlab-mcp-auth");
8
10
  const DEFAULT_API_URL = "https://gitlab.com";
9
11
  const DEFAULT_REDIRECT_URI = "http://127.0.0.1:8888/callback";
@@ -74,7 +76,13 @@ export async function runAuthCommandAsync(input = {}) {
74
76
  if (!clientId) {
75
77
  throw new Error("Missing OAuth client ID. Pass --client-id or set GITLAB_OAUTH_CLIENT_ID.");
76
78
  }
77
- const apiUrl = readFlag(argv, "api-url") ?? env.GITLAB_API_URL ?? DEFAULT_API_URL;
79
+ const apiUrlFlag = readFlag(argv, "api-url");
80
+ const configuredApiUrl = stripUrlUserinfo(apiUrlFlag ?? env.GITLAB_API_URL ?? DEFAULT_API_URL);
81
+ if (configuredApiUrl.hadUserinfo) {
82
+ logger.warn(apiUrlUserinfoWarning(apiUrlFlag !== undefined ? "--api-url" : "GITLAB_API_URL"));
83
+ flushStartupLogs(logger);
84
+ }
85
+ const apiUrl = configuredApiUrl.url;
78
86
  const tokenPath = readFlag(argv, "token-path") ??
79
87
  env.GITLAB_OAUTH_TOKEN_PATH ??
80
88
  path.join(os.homedir(), ".gitlab-mcp-token.json");
@@ -1,4 +1,5 @@
1
1
  import { renderTable } from "./tables.js";
2
+ import { redactUrlSecretsInText } from "../utils/redact-url.js";
2
3
  export function formatToolOutput(input) {
3
4
  const isError = resultIsError(input.result);
4
5
  const text = unwrapToolText(input.result);
@@ -58,7 +59,7 @@ function ensureTrailingNewline(text) {
58
59
  return text.endsWith("\n") ? text : `${text}\n`;
59
60
  }
60
61
  function redactSecrets(message) {
61
- return message.replace(/glpat-[A-Za-z0-9_-]+/g, "[REDACTED]");
62
+ return redactUrlSecretsInText(message).replace(/glpat-[A-Za-z0-9_-]+/g, "[REDACTED]");
62
63
  }
63
64
  function isRecord(value) {
64
65
  return typeof value === "object" && value !== null && !Array.isArray(value);
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_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";
2
+ import { cliArgs, 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;
@@ -93,14 +93,14 @@ import { SERVER_VERSION } from "./server/version.js";
93
93
  import { listMcpResources, listMcpResourceTemplates, readMcpResource, } from "./server/mcp-resources.js";
94
94
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
95
95
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
96
- import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
96
+ import { apiUrlUserinfoWarning, encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, parseConfiguredGitLabApiUrls, } from "./utils/url.js";
97
97
  import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, resolveDownloadRoot, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
98
98
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
99
99
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
100
100
  import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
101
101
  import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
102
102
  import { classifyHealthCheckTransportError, describeFetchTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
103
- import { redactUrlSecretsInText, splitCommaSeparatedUrls } from "./utils/redact-url.js";
103
+ import { redactUrlSecretsInError, redactUrlSecretsInErrorMessage, redactUrlSecretsInText, splitCommaSeparatedUrls, } from "./utils/redact-url.js";
104
104
  import { createMaskingPolicyResolver, getManagedMaskingProjectIds, } from "./masking/index.js";
105
105
  import { checkForNewVersion } from "./utils/version-check.js";
106
106
  import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
@@ -303,9 +303,11 @@ function createServer() {
303
303
  const isPlainTextResult = toolName === "get_pipeline_job_output" ||
304
304
  toolName === "get_job_artifact_file" ||
305
305
  (toolName === "download_release_asset" && !IS_REMOTE);
306
- const filtered = applyJmespathToToolResult(result, jmespathExpression, {
306
+ const filtered = applyJmespathToToolResult(redactErrorResultUrls(result), jmespathExpression, {
307
307
  ...completionOptions,
308
- maskValue: maskingEngine ? (value) => maskingEngine.maskValue(value) : undefined,
308
+ maskValue: maskingEngine
309
+ ? (value) => maskingEngine.maskValue(value)
310
+ : undefined,
309
311
  });
310
312
  const masked = maskingEngine
311
313
  ? maskingEngine.maskToolResult(filtered, {
@@ -322,7 +324,10 @@ function createServer() {
322
324
  };
323
325
  const logError = (error) => {
324
326
  const durationMs = Date.now() - start;
325
- const safeError = maskingEngine ? maskingEngine.maskError(error) : error;
327
+ // fetch() and new URL() quote the URL they rejected, userinfo included. The
328
+ // redacted error is what gets logged, returned, or rethrown to the client.
329
+ const redactedError = redactUrlSecretsInError(error);
330
+ const safeError = maskingEngine ? maskingEngine.maskError(redactedError) : redactedError;
326
331
  logger.error({
327
332
  tool: toolName,
328
333
  event: "tool_call_error",
@@ -573,6 +578,23 @@ function createServer() {
573
578
  });
574
579
  return mcpServer;
575
580
  }
581
+ /**
582
+ * An isError tool result can quote fetch/URL error text too; see redactUrlSecretsInError.
583
+ * Other results carry GitLab data, which is returned as is; a handler that puts
584
+ * a caught error into its data redacts it there (redactUrlSecretsInErrorMessage).
585
+ */
586
+ function redactErrorResultUrls(result) {
587
+ if (!isRecord(result) || result.isError !== true || !Array.isArray(result.content)) {
588
+ return result;
589
+ }
590
+ // Same shape as the input; only text values change.
591
+ return {
592
+ ...result,
593
+ content: result.content.map((item) => isRecord(item) && typeof item.text === "string"
594
+ ? { ...item, text: redactUrlSecretsInText(item.text) }
595
+ : item),
596
+ };
597
+ }
576
598
  function formatHostWithPort(host, port) {
577
599
  const normalized = host
578
600
  .trim()
@@ -856,13 +878,21 @@ let oauthClient = null;
856
878
  * switch to signed/sealed opaque values instead of per-pod in-memory caches.
857
879
  */
858
880
  let STATELESS_MATERIAL = null;
881
+ // Userinfo is dropped here, before the client pool or any request URL is built.
882
+ // Defined before the first emitDeprecatedEnvWarnings() call, which reports it.
883
+ const CONFIGURED_GITLAB_API_URLS = parseConfiguredGitLabApiUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com");
859
884
  let hasEmittedDeprecatedEnvWarnings = false;
860
885
  function emitDeprecatedEnvWarnings() {
861
886
  if (hasEmittedDeprecatedEnvWarnings) {
862
887
  return;
863
888
  }
864
889
  hasEmittedDeprecatedEnvWarnings = true;
865
- const warnings = getDeprecatedEnvWarnings(buildDeprecatedEnvInput());
890
+ const warnings = [
891
+ ...getDeprecatedEnvWarnings(buildDeprecatedEnvInput()),
892
+ ...(CONFIGURED_GITLAB_API_URLS.hadUserinfo
893
+ ? [apiUrlUserinfoWarning(cliArgs["api-url"] ? "--api-url" : "GITLAB_API_URL")]
894
+ : []),
895
+ ];
866
896
  for (const warning of warnings) {
867
897
  logger.warn(warning);
868
898
  }
@@ -966,7 +996,7 @@ const hiddenToolSet = new Set((GITLAB_TOOL_POLICY_HIDDEN_RAW || "")
966
996
  }
967
997
  }
968
998
  const clientPool = new GitLabClientPool({
969
- apiUrls: splitCommaSeparatedUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com").map(normalizeGitLabApiUrl),
999
+ apiUrls: CONFIGURED_GITLAB_API_URLS.urls,
970
1000
  httpProxy: HTTP_PROXY,
971
1001
  httpsProxy: HTTPS_PROXY,
972
1002
  noProxy: NO_PROXY,
@@ -1315,6 +1345,9 @@ function toAllowedGitLabApiUrl(value) {
1315
1345
  const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
1316
1346
  if (url.protocol !== "http:" && url.protocol !== "https:")
1317
1347
  return null;
1348
+ // Same rule as GITLAB_API_URL: fetch() rejects a URL that carries userinfo.
1349
+ url.username = "";
1350
+ url.password = "";
1318
1351
  return { host: url.host, apiUrl: normalizeGitLabApiUrl(url.toString()) };
1319
1352
  }
1320
1353
  catch {
@@ -1339,8 +1372,7 @@ function resolveTrustedGitLabApiUrl(value) {
1339
1372
  }
1340
1373
  return allowedApiUrl;
1341
1374
  }
1342
- // Use the normalizeGitLabApiUrl function to handle various URL formats
1343
- const GITLAB_API_URLS = splitCommaSeparatedUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com").map(normalizeGitLabApiUrl);
1375
+ const GITLAB_API_URLS = CONFIGURED_GITLAB_API_URLS.urls;
1344
1376
  const GITLAB_API_URL = GITLAB_API_URLS[0];
1345
1377
  const GITLAB_ALLOWED_API_URLS_BY_HOST = new Map();
1346
1378
  for (const { host, apiUrl } of [
@@ -4070,7 +4102,7 @@ async function buildMergeRequestCommitAdditionSummary(projectId, mergeRequest) {
4070
4102
  };
4071
4103
  }
4072
4104
  catch (error) {
4073
- const unavailableReason = error instanceof Error ? error.message : String(error);
4105
+ const unavailableReason = redactUrlSecretsInErrorMessage(error);
4074
4106
  return {
4075
4107
  target_branch: mergeRequest.target_branch,
4076
4108
  source_commits_count: null,
@@ -4098,7 +4130,7 @@ async function buildMergeRequestApprovalSummary(projectId, mergeRequestIid) {
4098
4130
  };
4099
4131
  }
4100
4132
  catch (error) {
4101
- const unavailableReason = error instanceof Error ? error.message : String(error);
4133
+ const unavailableReason = redactUrlSecretsInErrorMessage(error);
4102
4134
  return {
4103
4135
  approved: null,
4104
4136
  user_has_approved: null,
@@ -4196,7 +4228,7 @@ async function buildMergeRequestDeploymentSummary(projectId, mergeRequest) {
4196
4228
  };
4197
4229
  }
4198
4230
  catch (error) {
4199
- const unavailableReason = error instanceof Error ? error.message : String(error);
4231
+ const unavailableReason = redactUrlSecretsInErrorMessage(error);
4200
4232
  return {
4201
4233
  lookup_sha: lookupSha,
4202
4234
  sort: "created_at_desc",
@@ -8300,7 +8332,8 @@ async function handleToolCall(params) {
8300
8332
  };
8301
8333
  }
8302
8334
  catch (err) {
8303
- const message = describeFetchTransportError(err) ?? (err instanceof Error ? err.message : String(err));
8335
+ // Returned as data, not isError, so the central error redaction does not see it.
8336
+ const message = describeFetchTransportError(err) ?? redactUrlSecretsInErrorMessage(err);
8304
8337
  return {
8305
8338
  content: [
8306
8339
  {
@@ -8350,7 +8383,7 @@ async function handleToolCall(params) {
8350
8383
  logger.error({ err: forkError }, "Error forking repository");
8351
8384
  let forkErrorMessage = "Failed to fork repository";
8352
8385
  if (forkError instanceof Error) {
8353
- const reason = describeFetchTransportError(forkError) ?? forkError.message;
8386
+ const reason = describeFetchTransportError(forkError) ?? redactUrlSecretsInErrorMessage(forkError);
8354
8387
  forkErrorMessage = `${forkErrorMessage}: ${reason}`;
8355
8388
  }
8356
8389
  return {
@@ -9204,7 +9237,7 @@ async function handleToolCall(params) {
9204
9237
  catch (noteError) {
9205
9238
  noteResult = {
9206
9239
  status: "failed",
9207
- message: `Note creation failed: ${noteError.message ?? noteError}`,
9240
+ message: redactUrlSecretsInText(`Note creation failed: ${noteError.message ?? noteError}`),
9208
9241
  };
9209
9242
  }
9210
9243
  }
@@ -13066,7 +13099,7 @@ async function main() {
13066
13099
  }
13067
13100
  catch (error) {
13068
13101
  const message = error instanceof Error ? error.message : String(error);
13069
- process.stderr.write(`${message}\n`);
13102
+ process.stderr.write(`${redactUrlSecretsInText(message)}\n`);
13070
13103
  logger.error({ err: error }, "auth command failed");
13071
13104
  exitAfterFlush(1);
13072
13105
  }
@@ -13080,7 +13113,7 @@ async function main() {
13080
13113
  try {
13081
13114
  initOAuthClient();
13082
13115
  const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
13083
- const masked = maskCliToolResult(cli.toolName, result);
13116
+ const masked = maskCliToolResult(cli.toolName, redactErrorResultUrls(result));
13084
13117
  const formatted = formatToolOutput({
13085
13118
  result: masked,
13086
13119
  mode: cli.output,
@@ -13107,9 +13140,8 @@ function isRecord(value) {
13107
13140
  return typeof value === "object" && value !== null && !Array.isArray(value);
13108
13141
  }
13109
13142
  function maskCliToolResult(toolName, result) {
13110
- const gitlabInstance = getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com";
13111
13143
  const engine = maskingPolicyResolver?.select({
13112
- gitlabInstance,
13144
+ gitlabInstance: GITLAB_API_URL,
13113
13145
  allowBuiltinForUnscoped: true,
13114
13146
  });
13115
13147
  if (!engine || !isRecord(result)) {
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { pino } from "pino";
3
+ import { redactUrlSecretsInLogLine } from "./redact-url.js";
3
4
  const require = createRequire(import.meta.url);
4
5
  const REDACT_PATHS = [
5
6
  "token",
@@ -125,6 +126,8 @@ export function flushBufferedLogs(logger) {
125
126
  * to stderr.
126
127
  * - Any other value, including unset, keeps human-readable pino-pretty output.
127
128
  * - All loggers redact the same set of sensitive fields.
129
+ * - Every line, in either format, has URL userinfo and token query values
130
+ * redacted, including text from thrown fetch/URL errors and their causes.
128
131
  */
129
132
  export function createLogger(name) {
130
133
  const isJson = process.env.LOG_FORMAT === "json";
@@ -135,6 +138,9 @@ export function createLogger(name) {
135
138
  paths: REDACT_PATHS,
136
139
  censor: "[REDACTED]",
137
140
  },
141
+ hooks: {
142
+ streamWrite: redactUrlSecretsInLogLine,
143
+ },
138
144
  };
139
145
  if (isJson) {
140
146
  const destination = createAsyncStderr();
@@ -1,3 +1,4 @@
1
+ import { isIP } from "node:net";
1
2
  const SENSITIVE_QUERY_NAMES = new Set([
2
3
  "access_token",
3
4
  "private_token",
@@ -7,8 +8,9 @@ const SENSITIVE_QUERY_NAMES = new Set([
7
8
  ]);
8
9
  /**
9
10
  * Removes userinfo from a GitLab API URL before it is returned to the client.
10
- * A password with an unencoded slash is not a valid WHATWG URL, so that form
11
- * uses the same text redaction as transport error messages.
11
+ * A password with an unencoded slash is not a valid WHATWG URL, or parses as a
12
+ * host and port ("https://u:12/ab@host" has host "u"), so those forms use the
13
+ * same text redaction as transport error messages.
12
14
  */
13
15
  export function redactGitLabUrlCredentials(url) {
14
16
  try {
@@ -18,7 +20,7 @@ export function redactGitLabUrlCredentials(url) {
18
20
  parsed.username = "";
19
21
  parsed.password = "";
20
22
  }
21
- const serialized = hadUserinfo ? parsed.toString() : url;
23
+ const serialized = hadUserinfo ? parsed.toString() : redactUrlUserinfoInText(url);
22
24
  return redactSensitiveQuery(serialized);
23
25
  }
24
26
  catch {
@@ -33,8 +35,125 @@ export function redactGitLabUrlCredentials(url) {
33
35
  export function redactUrlSecretsInText(text) {
34
36
  return redactSensitiveQuery(redactUrlUserinfoInText(redactParseableUrlUserinfo(text)));
35
37
  }
38
+ /**
39
+ * Redacts an error in place: `message`, `stack`, and every `cause` below it.
40
+ * undici and `new URL()` put the URL they rejected into the message, so a
41
+ * credentialed URL would otherwise reach the MCP client and the log verbatim.
42
+ * A thrown string is returned redacted; other values are returned unchanged.
43
+ */
44
+ export function redactUrlSecretsInError(error) {
45
+ if (typeof error === "string") {
46
+ return redactUrlSecretsInText(error);
47
+ }
48
+ redactErrorChain(error, new Set());
49
+ return error;
50
+ }
51
+ /**
52
+ * The redacted message of a caught error, for a tool result that reports the
53
+ * failure in its data (`{ "error": "GraphQL request failed: ..." }`) instead of
54
+ * as an isError result.
55
+ */
56
+ export function redactUrlSecretsInErrorMessage(error) {
57
+ return redactUrlSecretsInText(error instanceof Error ? error.message : String(error));
58
+ }
59
+ function redactErrorChain(error, seen) {
60
+ if (!(error instanceof Error) || seen.has(error)) {
61
+ return;
62
+ }
63
+ seen.add(error);
64
+ redactErrorField(error, "message");
65
+ redactErrorField(error, "stack");
66
+ if (typeof error.cause === "string") {
67
+ redactErrorField(error, "cause");
68
+ }
69
+ else {
70
+ redactErrorChain(error.cause, seen);
71
+ }
72
+ if (error instanceof AggregateError && Array.isArray(error.errors)) {
73
+ for (const inner of error.errors) {
74
+ redactErrorChain(inner, seen);
75
+ }
76
+ }
77
+ }
78
+ function redactErrorField(error, field) {
79
+ const value = error[field];
80
+ if (typeof value !== "string") {
81
+ return;
82
+ }
83
+ const redacted = redactUrlSecretsInText(value);
84
+ if (redacted === value) {
85
+ return;
86
+ }
87
+ try {
88
+ error[field] = redacted;
89
+ }
90
+ catch {
91
+ // A frozen error keeps its text; the logger still redacts its own copy.
92
+ }
93
+ }
94
+ /**
95
+ * Redacts one serialized pino line. Each JSON string is redacted after
96
+ * JSON.parse, so a URL next to a quote cannot pull the rest of the line into
97
+ * the userinfo match. A line that is not JSON is redacted as text.
98
+ */
99
+ export function redactUrlSecretsInLogLine(line) {
100
+ if (!line.includes("@") && !line.includes("=")) {
101
+ return line;
102
+ }
103
+ const newline = line.endsWith("\n") ? "\n" : "";
104
+ const body = newline ? line.slice(0, -1) : line;
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(body);
108
+ }
109
+ catch {
110
+ return redactUrlSecretsInText(line);
111
+ }
112
+ let changed = false;
113
+ const redacted = mapJsonStrings(parsed, value => {
114
+ const next = redactUrlSecretsInText(value);
115
+ changed ||= next !== value;
116
+ return next;
117
+ });
118
+ return changed ? `${JSON.stringify(redacted)}${newline}` : line;
119
+ }
120
+ function mapJsonStrings(value, map) {
121
+ if (typeof value === "string") {
122
+ return map(value);
123
+ }
124
+ if (Array.isArray(value)) {
125
+ return value.map(item => mapJsonStrings(item, map));
126
+ }
127
+ if (typeof value === "object" && value !== null) {
128
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapJsonStrings(item, map)]));
129
+ }
130
+ return value;
131
+ }
132
+ /**
133
+ * Drops userinfo from a configured GitLab API URL. fetch() refuses a URL with
134
+ * credentials, and GitLab API authentication goes in request headers, so the
135
+ * userinfo is removed before any request is built. A value that does not parse
136
+ * is returned unchanged for the config validation to report.
137
+ */
138
+ export function stripUrlUserinfo(url) {
139
+ const trimmed = url.trim();
140
+ const parsed = tryParseUrl(trimmed);
141
+ if (parsed === null || (parsed.username.length === 0 && parsed.password.length === 0)) {
142
+ return { url, hadUserinfo: false };
143
+ }
144
+ parsed.username = "";
145
+ parsed.password = "";
146
+ const serialized = parsed.toString();
147
+ // "https://user:pass@host" serializes as "https://host/"; keep the input's form.
148
+ const addedRootSlash = parsed.pathname === "/" &&
149
+ parsed.search.length === 0 &&
150
+ parsed.hash.length === 0 &&
151
+ !trimmed.endsWith("/");
152
+ return { url: addedRootSlash ? serialized.slice(0, -1) : serialized, hadUserinfo: true };
153
+ }
36
154
  function redactSensitiveQuery(url) {
37
- return url.replace(/([?&#])([^=&\s#]+)=[^&\s#]+/g, (match, separator, rawName) => {
155
+ // The name cannot hold a separator, so a run of "?" is not rescanned from each one.
156
+ return url.replace(/([?&#])([^=&\s#?]+)=[^&\s#]+/g, (match, separator, rawName) => {
38
157
  if (!isSensitiveQueryName(rawName)) {
39
158
  return match;
40
159
  }
@@ -52,14 +171,116 @@ function decodeQueryName(rawName) {
52
171
  return rawName;
53
172
  }
54
173
  }
174
+ // Every scan below runs in time linear in the text: log lines can carry
175
+ // client-chosen strings such as an unknown tool name.
55
176
  function redactUrlUserinfoInText(message) {
56
- const withoutCompactUserinfo = message.replace(/\b[a-z][a-z0-9+.-]*:(?:\/\/|\/)?[^\s]+/gi, urlToken => redactCompactUserinfo(urlToken));
57
- return withoutCompactUserinfo.replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^\s/:@.]+(?::[^\s/:@.]+)+(?:\s+[^\s/:@.]+)+)@/gi, (match, scheme, userinfo, offset, source) => {
58
- if (looksLikeHostPort(userinfo, source.slice(offset + match.length))) {
59
- return match;
177
+ // A token can hold a comma-separated GITLAB_API_URL list; each entry has its own userinfo.
178
+ const withoutCompactUserinfo = replaceUrlTokens(message, urlToken => splitCommaSeparatedUrls(urlToken).map(redactCompactUserinfo).join(","));
179
+ return redactSpacedUserinfo(withoutCompactUserinfo);
180
+ }
181
+ const WORD_CHAR_PATTERN = /\w/;
182
+ const WHITESPACE_PATTERN = /\s/;
183
+ const NEXT_WHITESPACE_PATTERN = /\s/g;
184
+ // The tokens /\b[a-z][a-z0-9+.-]*:\S+/gi matches: a scheme, then the text up
185
+ // to the next whitespace.
186
+ function replaceUrlTokens(text, replace) {
187
+ const parts = [];
188
+ let copied = 0;
189
+ let colon = text.indexOf(":");
190
+ while (colon !== -1) {
191
+ const followedByText = colon + 1 < text.length && !WHITESPACE_PATTERN.test(text[colon + 1]);
192
+ const start = followedByText ? findSchemeStart(text, colon) : -1;
193
+ if (start === -1) {
194
+ colon = text.indexOf(":", colon + 1);
195
+ continue;
60
196
  }
61
- return `${scheme}[REDACTED]@`;
62
- });
197
+ const end = indexOfPattern(text, NEXT_WHITESPACE_PATTERN, colon + 1);
198
+ parts.push(text.slice(copied, start), replace(text.slice(start, end)));
199
+ copied = end;
200
+ colon = text.indexOf(":", end);
201
+ }
202
+ parts.push(text.slice(copied));
203
+ return parts.join("");
204
+ }
205
+ // "scheme://user:pa ss@host": a password with whitespace splits the token above.
206
+ function redactSpacedUserinfo(text) {
207
+ const parts = [];
208
+ let copied = 0;
209
+ let colon = text.indexOf("://");
210
+ while (colon !== -1) {
211
+ const start = findSchemeStart(text, colon);
212
+ const authorityStart = colon + 3;
213
+ const atIndex = start === -1 ? -1 : findSpacedUserinfoEnd(text, authorityStart);
214
+ if (atIndex === -1) {
215
+ colon = text.indexOf("://", colon + 1);
216
+ continue;
217
+ }
218
+ const end = atIndex + 1;
219
+ if (!looksLikeHostPort(text.slice(authorityStart, atIndex), text.slice(end))) {
220
+ parts.push(text.slice(copied, start), `${text.slice(start, authorityStart)}[REDACTED]@`);
221
+ copied = end;
222
+ }
223
+ colon = text.indexOf("://", end);
224
+ }
225
+ parts.push(text.slice(copied));
226
+ return parts.join("");
227
+ }
228
+ const SPACED_USERINFO_STOP_PATTERN = /[\s/:@.]/;
229
+ // Index of the "@" that ends /[^\s/:@.]+(?::[^\s/:@.]+)+(?:\s+[^\s/:@.]+)+@/ at
230
+ // `start`, or -1. Its parts cannot overlap, so one pass decides the match, and
231
+ // the scan stops at the next "://".
232
+ function findSpacedUserinfoEnd(text, start) {
233
+ let index = skipUserinfoRun(text, start);
234
+ if (index === start || text[index] !== ":") {
235
+ return -1;
236
+ }
237
+ while (text[index] === ":") {
238
+ const runEnd = skipUserinfoRun(text, index + 1);
239
+ if (runEnd === index + 1) {
240
+ return -1;
241
+ }
242
+ index = runEnd;
243
+ }
244
+ let spacedRuns = 0;
245
+ while (index < text.length && WHITESPACE_PATTERN.test(text[index])) {
246
+ let runStart = index + 1;
247
+ while (runStart < text.length && WHITESPACE_PATTERN.test(text[runStart])) {
248
+ runStart += 1;
249
+ }
250
+ const runEnd = skipUserinfoRun(text, runStart);
251
+ if (runEnd === runStart) {
252
+ return -1;
253
+ }
254
+ index = runEnd;
255
+ spacedRuns += 1;
256
+ }
257
+ return spacedRuns > 0 && text[index] === "@" ? index : -1;
258
+ }
259
+ function skipUserinfoRun(text, from) {
260
+ let index = from;
261
+ while (index < text.length && !SPACED_USERINFO_STOP_PATTERN.test(text[index])) {
262
+ index += 1;
263
+ }
264
+ return index;
265
+ }
266
+ // Start of the scheme that ends at `colon`, where /\b[a-z][a-z0-9+.-]*:/i
267
+ // would match it, or -1. Walking back from the ":" reads a run like "a.a.a.a"
268
+ // once; the regex would rescan it from every letter.
269
+ function findSchemeStart(text, colon) {
270
+ let start = -1;
271
+ for (let index = colon - 1; index >= 0 && SCHEME_CHAR_PATTERN.test(text[index]); index -= 1) {
272
+ const atWordBoundary = index === 0 || !WORD_CHAR_PATTERN.test(text[index - 1]);
273
+ if (atWordBoundary && SCHEME_START_PATTERN.test(text[index])) {
274
+ start = index;
275
+ }
276
+ }
277
+ return start;
278
+ }
279
+ // Index of the first match of a global `pattern` at or after `from`, or the text length.
280
+ function indexOfPattern(text, pattern, from) {
281
+ pattern.lastIndex = from;
282
+ const match = pattern.exec(text);
283
+ return match === null ? text.length : match.index;
63
284
  }
64
285
  function redactParseableUrlUserinfo(text) {
65
286
  const trimmed = text.trim();
@@ -106,16 +327,176 @@ function splitAuthorityUserinfo(url) {
106
327
  afterAt: `${authority.slice(atIndex + 1)}${rest.slice(authorityEnd)}`,
107
328
  };
108
329
  }
330
+ // A URL inside another URL's path or query ("...?next=https://user:pass@host")
331
+ // has its own authority. The text regex sees both as one token.
332
+ // One pass per URL in the token, outer first, so a long token of nested URLs
333
+ // does not grow the call stack. URLs nested in one authority share its end, and
334
+ // the authority reader reads that text once for all of them.
109
335
  function redactCompactUserinfo(urlToken) {
110
- const prefix = readSchemePrefix(urlToken);
111
- if (prefix === null) {
112
- return urlToken;
336
+ // Without an "@" after a URL's scheme there is no userinfo to find, so
337
+ // segments past the last "@" skip the authority scan.
338
+ const lastAt = urlToken.lastIndexOf("@");
339
+ const lastUserinfoAt = lastAtOutsidePathSegmentStart(urlToken);
340
+ const authorities = createAuthorityReader(urlToken);
341
+ const parts = [];
342
+ let offset = 0;
343
+ for (;;) {
344
+ const prefix = readSchemePrefix(urlToken.slice(offset));
345
+ if (prefix === null) {
346
+ parts.push(urlToken.slice(offset));
347
+ break;
348
+ }
349
+ const restStart = offset + prefix.scheme.length;
350
+ const hasAuthority = prefix.hasAuthoritySlashes || SPECIAL_SCHEMES.has(prefix.name);
351
+ const atIndex = hasAuthority && lastAt >= restStart
352
+ ? findUserinfoEnd(urlToken, restStart, authorities, lastAt, lastUserinfoAt)
353
+ : -1;
354
+ const nestedFrom = atIndex > 0 ? atIndex + 1 : 0;
355
+ parts.push(atIndex > 0 ? `${prefix.scheme}[REDACTED]@` : prefix.scheme);
356
+ const rest = prefix.rest.slice(nestedFrom);
357
+ const nestedStart = findNestedUrlStart(rest);
358
+ if (nestedStart === -1) {
359
+ parts.push(rest);
360
+ break;
361
+ }
362
+ parts.push(rest.slice(0, nestedStart));
363
+ offset = restStart + nestedFrom + nestedStart;
113
364
  }
114
- const atIndex = prefix.rest.lastIndexOf("@");
115
- if (atIndex <= 0) {
116
- return urlToken;
365
+ return parts.join("");
366
+ }
367
+ const SCHEME_CHAR_PATTERN = /[a-z0-9+.-]/i;
368
+ const SCHEME_START_PATTERN = /[a-z]/i;
369
+ // Index of the first match of /[a-z][a-z0-9+.-]*:\S/i, or -1. It walks back
370
+ // from each ":" instead of trying every start position, so a long run of
371
+ // letters in a path is read once rather than once per letter.
372
+ function findNestedUrlStart(text) {
373
+ for (let colon = text.indexOf(":"); colon !== -1; colon = text.indexOf(":", colon + 1)) {
374
+ if (colon + 1 >= text.length || /\s/.test(text[colon + 1])) {
375
+ continue;
376
+ }
377
+ let start = -1;
378
+ for (let index = colon - 1; index >= 0 && SCHEME_CHAR_PATTERN.test(text[index]); index -= 1) {
379
+ if (SCHEME_START_PATTERN.test(text[index])) {
380
+ start = index;
381
+ }
382
+ }
383
+ if (start !== -1) {
384
+ return start;
385
+ }
117
386
  }
118
- return `${prefix.scheme}[REDACTED]@${prefix.rest.slice(atIndex + 1)}`;
387
+ return -1;
388
+ }
389
+ // WHATWG special schemes read userinfo without "//" ("https:user:pass@host").
390
+ // Other schemes have an authority only after "//", so "mailto:a@b.com" has none.
391
+ const SPECIAL_SCHEMES = new Set(["http:", "https:", "ws:", "wss:", "ftp:"]);
392
+ // Path, query, fragment, and the quote or bracket that closes a URL in prose or JSON.
393
+ const AUTHORITY_END_PATTERN = /[/\\?#"'<>`]/g;
394
+ const DIGITS_PATTERN = /^\d*$/;
395
+ const DOTTED_HOST_NAME_PATTERN = /^[a-z0-9_.-]+$/i;
396
+ const LOCALHOST_PATTERN = /^localhost$/i;
397
+ // Starts only move forward, and so do both positions here, as in
398
+ // splitCommaSeparatedUrls: the authorities of a token are read once in total.
399
+ function createAuthorityReader(token) {
400
+ let text;
401
+ let closeBracket = -1;
402
+ return {
403
+ read(start) {
404
+ if (text === undefined || text.end < start) {
405
+ text = readAuthorityText(token, start);
406
+ }
407
+ return text;
408
+ },
409
+ closeBracket(start) {
410
+ if (closeBracket < start) {
411
+ const index = token.indexOf("]", start);
412
+ closeBracket = index === -1 ? token.length : index;
413
+ }
414
+ return closeBracket;
415
+ },
416
+ };
417
+ }
418
+ function readAuthorityText(token, from) {
419
+ const end = indexOfPattern(token, AUTHORITY_END_PATTERN, from);
420
+ let lastAt = -1;
421
+ let lastColon = -1;
422
+ let colonBeforeLast = -1;
423
+ for (let index = from; index < end; index += 1) {
424
+ if (token[index] === "@") {
425
+ lastAt = index;
426
+ }
427
+ else if (token[index] === ":") {
428
+ colonBeforeLast = lastColon;
429
+ lastColon = index;
430
+ }
431
+ }
432
+ const port = lastColon === -1 ? "" : token.slice(lastColon + 1, end);
433
+ const digitsAfterLastColon = DIGITS_PATTERN.test(port);
434
+ return {
435
+ end,
436
+ closed: end < token.length,
437
+ lastAt,
438
+ lastColon,
439
+ colonBeforeLast,
440
+ digitsAfterLastColon,
441
+ // WHATWG reads ":0000080" as port 80.
442
+ portTooLarge: digitsAfterLastColon && Number(port) > 65535,
443
+ };
444
+ }
445
+ // The userinfo ends at the last "@" before the path, so "https://host/a?x=1@2"
446
+ // and ".../npm/@scope/pkg" keep their "@". A password with an unencoded "/"
447
+ // ("user:p/ss@host") moves that "@" past the first "/"; it is told apart from
448
+ // a URL by the "user:p" before the "/", which is not a host[:port].
449
+ // A password of digits first ("u:12/ab@host", "u:123?x@host") reads as a port.
450
+ // So when the name before the port cannot be a host (a dotted name of letters,
451
+ // digits, "-" and "_", localhost, or an IPv6 literal), or the digits are above
452
+ // 65535, the last "@" counts unless it starts a path segment ("/@scope"), and
453
+ // "gitlab.example:8443/users?search=a@b.com" keeps its "@".
454
+ // Two shapes stay ambiguous. A username that is also a dotted host name
455
+ // ("first.last:12/ab@host") is left unchanged. A single-label host on a port
456
+ // ("gitlab:8080/users?search=a@b.com") is redacted up to that "@".
457
+ // `start` is the authority start in `token`; the result is an index from it.
458
+ function findUserinfoEnd(token, start, authorities, lastAt, lastUserinfoAt) {
459
+ const text = authorities.read(start);
460
+ if (text.lastAt >= start || !text.closed) {
461
+ return Math.max(text.lastAt - start, -1);
462
+ }
463
+ const { end, lastColon } = text;
464
+ const hasColon = lastColon >= start;
465
+ const oneColon = hasColon && text.colonBeforeLast < start;
466
+ // ":" and then only digits, maybe none.
467
+ const portDigits = hasColon && text.digitsAfterLastColon;
468
+ const bracketEnd = token[start] === "[" ? authorities.closeBracket(start) : -1;
469
+ // /^(?:\[[^\]]*\]|[^:]*):\d+$/: "host:port" or "[...]:port".
470
+ if (portDigits && lastColon + 1 < end && (oneColon || bracketEnd === lastColon - 1)) {
471
+ const host = token.slice(start, lastColon);
472
+ return !text.portTooLarge && couldBeHostName(host) ? -1 : Math.max(lastUserinfoAt - start, -1);
473
+ }
474
+ // The rest of /^(?:\[[^\]]*\]|[^:]*)(?::\d*)?$/: "host", "host:", "[...]", or "[...]:".
475
+ const hostWithoutPort = !hasColon ||
476
+ (oneColon && portDigits) ||
477
+ bracketEnd === end - 1 ||
478
+ (portDigits && bracketEnd === lastColon - 1);
479
+ return hostWithoutPort ? -1 : lastAt - start;
480
+ }
481
+ // "%" (an encoded "@" in an email username) or "+" cannot appear in a host name.
482
+ // The character test comes first and stops at the first other character.
483
+ function couldBeHostName(host) {
484
+ if (host.startsWith("[")) {
485
+ return host.endsWith("]") && isIP(host.slice(1, -1)) === 6;
486
+ }
487
+ return (LOCALHOST_PATTERN.test(host) ||
488
+ (DOTTED_HOST_NAME_PATTERN.test(host) &&
489
+ host.includes(".") &&
490
+ !host.startsWith(".") &&
491
+ !host.includes("..")));
492
+ }
493
+ // Found once per token, so each nested URL reads it in constant time.
494
+ function lastAtOutsidePathSegmentStart(urlToken) {
495
+ let index = urlToken.lastIndexOf("@");
496
+ while (index > 0 && urlToken[index - 1] === "/") {
497
+ index = urlToken.lastIndexOf("@", index - 1);
498
+ }
499
+ return index;
119
500
  }
120
501
  const SCHEME_PREFIX_PATTERN = /^([a-z][a-z0-9+.-]*:)(\/\/|\/)?/i;
121
502
  function readSchemePrefix(value) {
@@ -126,54 +507,69 @@ function readSchemePrefix(value) {
126
507
  const scheme = schemeMatch[0];
127
508
  return {
128
509
  scheme,
510
+ name: schemeMatch[1].toLowerCase(),
511
+ hasAuthoritySlashes: schemeMatch[2] === "//",
129
512
  rest: value.slice(scheme.length),
130
513
  };
131
514
  }
515
+ // A comma is inside a password when the entry's authority is still open (no
516
+ // "@", "/", "?", or "#" after the scheme yet) and the next of those after the
517
+ // comma, or whitespace, is an "@".
518
+ const AUTHORITY_CLOSE_PATTERN = /[@/?#]/g;
519
+ const USERINFO_STOP_PATTERN = /[@/?# \t]/g;
132
520
  export function splitCommaSeparatedUrls(value) {
133
521
  const parts = [];
134
522
  let start = 0;
135
- for (let index = 0; index < value.length; index += 1) {
136
- if (value[index] !== ",") {
137
- continue;
523
+ // Where the current entry's authority starts, -1 without a scheme; read at its first comma.
524
+ let restStart;
525
+ let inUserinfo = false;
526
+ // Both positions only move forward, so a value with many commas is read once.
527
+ let closeIndex = -1;
528
+ let stopIndex = -1;
529
+ for (let comma = value.indexOf(","); comma !== -1; comma = value.indexOf(",", comma + 1)) {
530
+ if (restStart === undefined) {
531
+ restStart = readRestStart(value, start, comma);
138
532
  }
139
- if (commaIsInsideUserinfo(value, start, index)) {
140
- continue;
533
+ if (restStart !== -1) {
534
+ if (closeIndex < restStart) {
535
+ closeIndex = indexOfPattern(value, AUTHORITY_CLOSE_PATTERN, restStart);
536
+ }
537
+ if (stopIndex < comma) {
538
+ stopIndex = indexOfPattern(value, USERINFO_STOP_PATTERN, comma + 1);
539
+ }
540
+ const commaInUserinfo = closeIndex > comma &&
541
+ value[stopIndex] === "@" &&
542
+ (inUserinfo || !startsNextUrl(value, start, comma));
543
+ if (commaInUserinfo) {
544
+ inUserinfo = true;
545
+ continue;
546
+ }
141
547
  }
142
- parts.push(value.slice(start, index).trim());
143
- start = index + 1;
548
+ parts.push(value.slice(start, comma).trim());
549
+ start = comma + 1;
550
+ restStart = undefined;
551
+ inUserinfo = false;
144
552
  }
145
553
  parts.push(value.slice(start).trim());
146
554
  return parts;
147
555
  }
556
+ function readRestStart(value, start, comma) {
557
+ const raw = value.slice(start, comma);
558
+ const prefix = readSchemePrefix(raw.trim());
559
+ if (prefix === null) {
560
+ return -1;
561
+ }
562
+ return start + raw.length - raw.trimStart().length + prefix.scheme.length;
563
+ }
148
564
  // "https://a.example,https:user:pass@b.example" is two URLs; the "@" after the
149
565
  // comma belongs to the next one. The text before the comma must parse as a URL
150
566
  // 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;
567
+ // Only http(s) counts, so "pass,abc:def@host" stays. Checked at an entry's
568
+ // first comma only: once a comma is in the password, so is every comma up to its "@".
569
+ const NEXT_HTTP_URL_PATTERN = /https?:/iy;
570
+ function startsNextUrl(value, start, comma) {
571
+ NEXT_HTTP_URL_PATTERN.lastIndex = comma + 1;
572
+ return (NEXT_HTTP_URL_PATTERN.test(value) && tryParseUrl(value.slice(start, comma).trim()) !== null);
177
573
  }
178
574
  // "user:pa ss@host" is a password. "localhost:3000 a@b.com" is a host, then an email.
179
575
  // A numeric port counts only when one token follows it and the text after "@" is an email domain.
@@ -1,6 +1,7 @@
1
1
  import { lookup } from "node:dns/promises";
2
2
  import { isIP } from "node:net";
3
3
  import { fetch as undiciFetch } from "undici";
4
+ import { redactUrlSecretsInText } from "./redact-url.js";
4
5
  /** Maximum number of redirect hops an outbound download request may follow. */
5
6
  export const DEFAULT_MAX_REDIRECTS = 5;
6
7
  /**
@@ -308,7 +309,8 @@ export async function fetchWithValidatedRedirects(url, options) {
308
309
  // keeps — on the wire in cleartext, so it is refused before the destination is
309
310
  // evaluated and before any trust decision can re-enable the credentials.
310
311
  if (currentProtocol === "https:" && target.protocol === "http:") {
311
- throw new UnsafeRedirectError(`Refusing to follow redirect from ${currentUrl} to cleartext ${target.toString()}`);
312
+ // The download proxy returns this message to the client, and Location can carry userinfo.
313
+ throw new UnsafeRedirectError(redactUrlSecretsInText(`Refusing to follow redirect from ${currentUrl} to cleartext ${target.toString()}`));
312
314
  }
313
315
  // A hop that stays on the originating origin needs no destination check. It keeps
314
316
  // the credentials only when they were not withheld on the way here: a target that
@@ -1,3 +1,4 @@
1
+ import { splitCommaSeparatedUrls, stripUrlUserinfo } from "./redact-url.js";
1
2
  /**
2
3
  * Smart URL handling for GitLab API
3
4
  *
@@ -17,6 +18,27 @@ export function normalizeGitLabApiUrl(url) {
17
18
  }
18
19
  return normalizedUrl;
19
20
  }
21
+ /**
22
+ * Startup warning for a configured API URL that had userinfo. `source` names
23
+ * where the URL came from. The advice stays generic: a remote-authorization or
24
+ * MCP OAuth deployment must not be told to add a server token.
25
+ */
26
+ export function apiUrlUserinfoWarning(source) {
27
+ return (`${source} contains a username or password before the host. It is ignored: requests ` +
28
+ "go to the URL without it. Use a token or OAuth configuration to authenticate instead.");
29
+ }
30
+ /**
31
+ * Split a comma-separated GITLAB_API_URL, drop userinfo from each entry, and
32
+ * normalize it. fetch() refuses a URL with credentials, so a URL that kept
33
+ * them would fail every request and echo the password in the error.
34
+ */
35
+ export function parseConfiguredGitLabApiUrls(value) {
36
+ const entries = splitCommaSeparatedUrls(value).map(stripUrlUserinfo);
37
+ return {
38
+ urls: entries.map(entry => normalizeGitLabApiUrl(entry.url)),
39
+ hadUserinfo: entries.some(entry => entry.hadUserinfo),
40
+ };
41
+ }
20
42
  const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
21
43
  /**
22
44
  * True when the URL is plain `http://` to a non-loopback host, i.e. credentials sent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.3.4",
3
+ "version": "2.3.5-next.125",
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": [