@zereight/mcp-gitlab 2.3.6-next.130 → 2.3.6-next.132
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 +4 -2
- package/README.md +4 -2
- package/README.pt-BR.md +4 -2
- package/README.zh-CN.md +4 -2
- package/build/auth-cli.js +8 -2
- package/build/cli/output.js +4 -5
- package/build/index.js +37 -31
- package/build/utils/logger.js +36 -1
- package/build/utils/redact-url.js +260 -15
- package/build/utils/url.js +88 -0
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -65,6 +65,8 @@ GitLab은 [공식 MCP 서버](https://docs.gitlab.com/user/model_context_protoco
|
|
|
65
65
|
- stdio는 `mcp-remote`를 거치며 Node.js 20 이상이 필요합니다.
|
|
66
66
|
- Toolset은 `X-Gitlab-Enabled-Mcp-Server-Toolsets` 헤더를 사용합니다. 선택 헤더 `X-Gitlab-Mcp-Server-Tool-Name-Prefix`는 도구 이름 접두사의 앞 32자만 유지합니다.
|
|
67
67
|
|
|
68
|
+
[공식 GitLab MCP 서버 전체 비교 →](./docs/comparison/official-gitlab-mcp.md)
|
|
69
|
+
|
|
68
70
|
빠른 시작: 아래에서 Personal Access Token 또는 OAuth2 설정 중 하나를 선택하고 `@zereight/mcp-gitlab`을 설치한 뒤 MCP 클라이언트 설정에서 `zereight-mcp-gitlab`을 사용하세요.
|
|
69
71
|
|
|
70
72
|
### 클라이언트 설정 가이드
|
|
@@ -304,7 +306,7 @@ MCP 서버가 직접 로컬 브라우저 callback을 받을 때만 `GITLAB_OAUTH
|
|
|
304
306
|
| 환경 변수 | 필수 | 설명 |
|
|
305
307
|
| ----------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
306
308
|
| `GITLAB_MCP_OAUTH` | 예 | 활성화하려면 `true` |
|
|
307
|
-
| `GITLAB_API_URL` | 예 | GitLab API base URL. URL에 자격 증명(`user:pass@`)을 넣지 마세요. 넣어도 무시됩니다.
|
|
309
|
+
| `GITLAB_API_URL` | 예 | GitLab API base URL. URL에 자격 증명(`user:pass@`)을 넣지 마세요. 넣어도 무시됩니다. 비밀번호의 쉼표는 반드시 `%2C`로 적어야 합니다. |
|
|
308
310
|
| `GITLAB_OAUTH_APP_ID` | 예 | GitLab OAuth Application ID |
|
|
309
311
|
| `MCP_SERVER_URL` | 예 | 이 MCP 서버의 공개 HTTPS URL |
|
|
310
312
|
| `STREAMABLE_HTTP` | 예 | 반드시 `true` |
|
|
@@ -572,7 +574,7 @@ node build/index.js
|
|
|
572
574
|
| `GITLAB_MCP_OAUTH` | 예 | 활성화하려면 `true` |
|
|
573
575
|
| `GITLAB_OAUTH_APP_ID` | 예 | 사전 등록 GitLab OAuth 애플리케이션의 client ID |
|
|
574
576
|
| `MCP_SERVER_URL` | 예 | MCP 서버의 공개 HTTPS URL |
|
|
575
|
-
| `GITLAB_API_URL` | 예 | GitLab 인스턴스 API URL(예: `https://gitlab.com/api/v4`). URL에 자격 증명(`user:pass@`)을 넣지 마세요. 넣어도 무시됩니다.
|
|
577
|
+
| `GITLAB_API_URL` | 예 | GitLab 인스턴스 API URL(예: `https://gitlab.com/api/v4`). URL에 자격 증명(`user:pass@`)을 넣지 마세요. 넣어도 무시됩니다. 비밀번호의 쉼표는 반드시 `%2C`로 적어야 합니다. |
|
|
576
578
|
| `STREAMABLE_HTTP` | 예 | 반드시 `true`(SSE 미지원) |
|
|
577
579
|
| `GITLAB_OAUTH_SCOPES` | 아니오 | 요청할 GitLab scope 목록(쉼표 구분). 기본값은 `api`이며, 유효 permission mode가 `readonly`일 때 `read_api`입니다. 사전 등록 애플리케이션에 해당 scope가 설정되어 있어야 합니다. |
|
|
578
580
|
| `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | 아니오 | Dynamic Client Registration(`POST /register`)의 클라이언트 IP당 rolling 한도. 기본 `20`/시간, 범위 `1`–`1000`. IDE 창 여러 개 등으로 등록이 막히면 올리세요. GitLab API 한도와 무관합니다. |
|
package/README.md
CHANGED
|
@@ -70,6 +70,8 @@ GitLab also ships an [official MCP server](https://docs.gitlab.com/user/model_co
|
|
|
70
70
|
- stdio goes through `mcp-remote` and needs Node.js 20 or later.
|
|
71
71
|
- Toolsets use the `X-Gitlab-Enabled-Mcp-Server-Toolsets` header. An optional `X-Gitlab-Mcp-Server-Tool-Name-Prefix` header keeps the first 32 characters of a tool-name prefix.
|
|
72
72
|
|
|
73
|
+
[Full comparison with the official GitLab MCP server →](./docs/comparison/official-gitlab-mcp.md)
|
|
74
|
+
|
|
73
75
|
Quick start: choose either Personal Access Token or OAuth2 setup below, install `@zereight/mcp-gitlab`, and use `zereight-mcp-gitlab` in your MCP client configuration.
|
|
74
76
|
|
|
75
77
|
### Client Setup Guides
|
|
@@ -326,7 +328,7 @@ exchanging credentials with GitLab on behalf of the client.
|
|
|
326
328
|
| Environment Variable | Required | Description |
|
|
327
329
|
| ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
328
330
|
| `GITLAB_MCP_OAUTH` | ✅ | Set to `true` to enable |
|
|
329
|
-
| `GITLAB_API_URL` | ✅ | GitLab API base URL. Do not embed credentials (`user:pass@`) in the URL; they are ignored.
|
|
331
|
+
| `GITLAB_API_URL` | ✅ | GitLab API base URL. Do not embed credentials (`user:pass@`) in the URL; they are ignored. A comma in a password must be written as `%2C`. |
|
|
330
332
|
| `GITLAB_OAUTH_APP_ID` | ✅ | GitLab OAuth Application ID |
|
|
331
333
|
| `MCP_SERVER_URL` | ✅ | Public HTTPS URL of this MCP server |
|
|
332
334
|
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
@@ -614,7 +616,7 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic
|
|
|
614
616
|
| `GITLAB_MCP_OAUTH` | Yes | Set to `true` to enable |
|
|
615
617
|
| `GITLAB_OAUTH_APP_ID` | Yes | Client ID of the pre-registered GitLab OAuth application |
|
|
616
618
|
| `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`). Do not embed credentials (`user:pass@`) in the URL; they are ignored.
|
|
619
|
+
| `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. A comma in a password must be written as `%2C`. |
|
|
618
620
|
| `STREAMABLE_HTTP` | Yes | Must be `true` (SSE is not supported) |
|
|
619
621
|
| `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
622
|
| `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
|
@@ -65,6 +65,8 @@ O GitLab também inclui um [servidor MCP oficial](https://docs.gitlab.com/user/m
|
|
|
65
65
|
- O stdio passa por `mcp-remote` e exige Node.js 20 ou posterior.
|
|
66
66
|
- Os toolsets usam o cabeçalho `X-Gitlab-Enabled-Mcp-Server-Toolsets`. O cabeçalho opcional `X-Gitlab-Mcp-Server-Tool-Name-Prefix` mantém os primeiros 32 caracteres de um prefixo de nome de ferramenta.
|
|
67
67
|
|
|
68
|
+
[Comparação completa com o servidor MCP oficial do GitLab →](./docs/comparison/official-gitlab-mcp.md)
|
|
69
|
+
|
|
68
70
|
Início rápido: escolha abaixo uma configuração com Personal Access Token ou OAuth2, instale `@zereight/mcp-gitlab` e use `zereight-mcp-gitlab` na configuração do seu cliente MCP.
|
|
69
71
|
|
|
70
72
|
### Guias de configuração de clientes
|
|
@@ -304,7 +306,7 @@ Use `GITLAB_OAUTH_REDIRECT_URI` somente quando o próprio servidor MCP receber o
|
|
|
304
306
|
| Variável de ambiente | Obrigatória | Descrição |
|
|
305
307
|
| ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
306
308
|
| `GITLAB_MCP_OAUTH` | sim | Defina como `true` para habilitar |
|
|
307
|
-
| `GITLAB_API_URL` | sim | URL base da API do GitLab. Não inclua credenciais (`user:pass@`) na URL; elas são ignoradas.
|
|
309
|
+
| `GITLAB_API_URL` | sim | URL base da API do GitLab. Não inclua credenciais (`user:pass@`) na URL; elas são ignoradas. Uma vírgula na senha deve ser escrita como `%2C`. |
|
|
308
310
|
| `GITLAB_OAUTH_APP_ID` | sim | Application ID do OAuth do GitLab |
|
|
309
311
|
| `MCP_SERVER_URL` | sim | URL HTTPS pública deste servidor MCP |
|
|
310
312
|
| `STREAMABLE_HTTP` | sim | Deve ser `true` |
|
|
@@ -572,7 +574,7 @@ Não é necessário definir `headers`. O Claude.ai obtém o token via OAuth.
|
|
|
572
574
|
| `GITLAB_MCP_OAUTH` | sim | Defina como `true` para habilitar |
|
|
573
575
|
| `GITLAB_OAUTH_APP_ID` | sim | Client ID do aplicativo OAuth do GitLab previamente registrado |
|
|
574
576
|
| `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`). Não inclua credenciais (`user:pass@`) na URL; elas são ignoradas.
|
|
577
|
+
| `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. Uma vírgula na senha deve ser escrita como `%2C`. |
|
|
576
578
|
| `STREAMABLE_HTTP` | sim | Deve ser `true` (SSE não é suportado) |
|
|
577
579
|
| `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
580
|
| `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
|
@@ -65,6 +65,8 @@ GitLab 还提供[官方 MCP 服务器](https://docs.gitlab.com/user/model_contex
|
|
|
65
65
|
- stdio 通过 `mcp-remote`,需要 Node.js 20 或更高版本。
|
|
66
66
|
- Toolset 使用请求头 `X-Gitlab-Enabled-Mcp-Server-Toolsets`。可选请求头 `X-Gitlab-Mcp-Server-Tool-Name-Prefix` 只保留工具名前缀的前 32 个字符。
|
|
67
67
|
|
|
68
|
+
[与官方 GitLab MCP 服务器的完整对比 →](./docs/comparison/official-gitlab-mcp.md)
|
|
69
|
+
|
|
68
70
|
快速开始:在下面选择 Personal Access Token 或 OAuth2 设置,安装 `@zereight/mcp-gitlab`,并在 MCP 客户端配置中使用 `zereight-mcp-gitlab`。
|
|
69
71
|
|
|
70
72
|
### 客户端设置指南
|
|
@@ -303,7 +305,7 @@ OpenCode、MCPJam、Claude.ai 等远程 MCP 客户端可能会在授权时发送
|
|
|
303
305
|
| 环境变量 | 必需 | 说明 |
|
|
304
306
|
| ----------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- |
|
|
305
307
|
| `GITLAB_MCP_OAUTH` | 是 | 设置为 `true` 以启用 |
|
|
306
|
-
| `GITLAB_API_URL` | 是 | GitLab API base URL。不要在 URL 中嵌入凭据(`user:pass
|
|
308
|
+
| `GITLAB_API_URL` | 是 | GitLab API base URL。不要在 URL 中嵌入凭据(`user:pass@`),嵌入的凭据会被忽略。密码中的逗号必须写成 `%2C`。 |
|
|
307
309
|
| `GITLAB_OAUTH_APP_ID` | 是 | GitLab OAuth Application ID |
|
|
308
310
|
| `MCP_SERVER_URL` | 是 | 此 MCP 服务器的公开 HTTPS URL |
|
|
309
311
|
| `STREAMABLE_HTTP` | 是 | 必须为 `true` |
|
|
@@ -571,7 +573,7 @@ node build/index.js
|
|
|
571
573
|
| `GITLAB_MCP_OAUTH` | 是 | 设置为 `true` 以启用 |
|
|
572
574
|
| `GITLAB_OAUTH_APP_ID` | 是 | 预先注册的 GitLab OAuth 应用 client ID |
|
|
573
575
|
| `MCP_SERVER_URL` | 是 | MCP 服务器的公开 HTTPS URL |
|
|
574
|
-
| `GITLAB_API_URL` | 是 | GitLab 实例 API URL(例如 `https://gitlab.com/api/v4`)。不要在 URL 中嵌入凭据(`user:pass
|
|
576
|
+
| `GITLAB_API_URL` | 是 | GitLab 实例 API URL(例如 `https://gitlab.com/api/v4`)。不要在 URL 中嵌入凭据(`user:pass@`),嵌入的凭据会被忽略。密码中的逗号必须写成 `%2C`。 |
|
|
575
577
|
| `STREAMABLE_HTTP` | 是 | 必须为 `true`(不支持 SSE) |
|
|
576
578
|
| `GITLAB_OAUTH_SCOPES` | 否 | 要请求的 GitLab scopes,以逗号分隔。默认值为 `api`;有效 permission mode 为 `readonly` 时为 `read_api`。预注册应用必须配置至少这些 scopes。 |
|
|
577
579
|
| `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
|
@@ -5,7 +5,7 @@ import { deprecatedEnvInputFromSources, getDeprecatedEnvWarnings } from "./depre
|
|
|
5
5
|
import { GitLabOAuth } from "./oauth.js";
|
|
6
6
|
import { createLogger, flushStartupLogs } from "./utils/logger.js";
|
|
7
7
|
import { stripUrlUserinfo } from "./utils/redact-url.js";
|
|
8
|
-
import { apiUrlUserinfoWarning } from "./utils/url.js";
|
|
8
|
+
import { apiUrlError, apiUrlUserinfoWarning } from "./utils/url.js";
|
|
9
9
|
const logger = createLogger("gitlab-mcp-auth");
|
|
10
10
|
const DEFAULT_API_URL = "https://gitlab.com";
|
|
11
11
|
const DEFAULT_REDIRECT_URI = "http://127.0.0.1:8888/callback";
|
|
@@ -77,12 +77,18 @@ export async function runAuthCommandAsync(input = {}) {
|
|
|
77
77
|
throw new Error("Missing OAuth client ID. Pass --client-id or set GITLAB_OAUTH_CLIENT_ID.");
|
|
78
78
|
}
|
|
79
79
|
const apiUrlFlag = readFlag(argv, "api-url");
|
|
80
|
+
const apiUrlSource = apiUrlFlag !== undefined ? "--api-url" : "GITLAB_API_URL";
|
|
80
81
|
const configuredApiUrl = stripUrlUserinfo(apiUrlFlag ?? env.GITLAB_API_URL ?? DEFAULT_API_URL);
|
|
81
82
|
if (configuredApiUrl.hadUserinfo) {
|
|
82
|
-
logger.warn(apiUrlUserinfoWarning(
|
|
83
|
+
logger.warn(apiUrlUserinfoWarning(apiUrlSource));
|
|
83
84
|
flushStartupLogs(logger);
|
|
84
85
|
}
|
|
85
86
|
const apiUrl = configuredApiUrl.url;
|
|
87
|
+
// The fetch error for a URL that does not parse would print it.
|
|
88
|
+
const invalidApiUrl = apiUrlError(apiUrlSource, apiUrl);
|
|
89
|
+
if (invalidApiUrl !== null) {
|
|
90
|
+
throw new Error(invalidApiUrl);
|
|
91
|
+
}
|
|
86
92
|
const tokenPath = readFlag(argv, "token-path") ??
|
|
87
93
|
env.GITLAB_OAUTH_TOKEN_PATH ??
|
|
88
94
|
path.join(os.homedir(), ".gitlab-mcp-token.json");
|
package/build/cli/output.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { renderTable } from "./tables.js";
|
|
2
|
-
import {
|
|
2
|
+
import { redactUrlSecretsInErrorMessage } from "../utils/redact-url.js";
|
|
3
3
|
export function formatToolOutput(input) {
|
|
4
4
|
const isError = resultIsError(input.result);
|
|
5
5
|
const text = unwrapToolText(input.result);
|
|
@@ -18,8 +18,7 @@ export function formatToolOutput(input) {
|
|
|
18
18
|
return { stdout: rendered.stdout, stderr: rendered.stderr, isError };
|
|
19
19
|
}
|
|
20
20
|
export function formatCliError(error) {
|
|
21
|
-
|
|
22
|
-
return `${redactSecrets(message)}\n`;
|
|
21
|
+
return `${redactGitLabTokens(redactUrlSecretsInErrorMessage(error))}\n`;
|
|
23
22
|
}
|
|
24
23
|
export function resultIsError(result) {
|
|
25
24
|
return isRecord(result) && result.isError === true;
|
|
@@ -58,8 +57,8 @@ function stringifyUnknown(value) {
|
|
|
58
57
|
function ensureTrailingNewline(text) {
|
|
59
58
|
return text.endsWith("\n") ? text : `${text}\n`;
|
|
60
59
|
}
|
|
61
|
-
function
|
|
62
|
-
return
|
|
60
|
+
function redactGitLabTokens(message) {
|
|
61
|
+
return message.replace(/glpat-[A-Za-z0-9_-]+/g, "[REDACTED]");
|
|
63
62
|
}
|
|
64
63
|
function isRecord(value) {
|
|
65
64
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
package/build/index.js
CHANGED
|
@@ -94,14 +94,14 @@ import { listMcpResources, listMcpResourceTemplates, readMcpResource, } from "./
|
|
|
94
94
|
import { getMcpPrompt, listMcpPrompts } from "./server/mcp-prompts.js";
|
|
95
95
|
import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
|
|
96
96
|
export { readMcpSessionIdHeader } from "./server/request-helpers.js";
|
|
97
|
-
import { apiUrlUserinfoWarning, encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, parseConfiguredGitLabApiUrls, } from "./utils/url.js";
|
|
97
|
+
import { apiUrlHostnameForLog, apiUrlUserinfoWarning, commaSplitHint, configuredApiUrlErrors, encodeGitLabPath, encodeGitLabPathSegment, invalidListEntryError, isCleartextRemoteUrl, normalizeGitLabApiUrl, parseConfiguredGitLabApiUrls, } from "./utils/url.js";
|
|
98
98
|
import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, resolveDownloadRoot, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
|
|
99
99
|
import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
|
|
100
100
|
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
101
101
|
import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
|
|
102
102
|
import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
|
|
103
103
|
import { classifyHealthCheckTransportError, describeFetchTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
|
|
104
|
-
import { redactUrlSecretsInError, redactUrlSecretsInErrorMessage, redactUrlSecretsInText,
|
|
104
|
+
import { redactUrlSecretsInError, redactUrlSecretsInErrorMessage, redactUrlSecretsInText, } from "./utils/redact-url.js";
|
|
105
105
|
import { createMaskingPolicyResolver, getManagedMaskingProjectIds, } from "./masking/index.js";
|
|
106
106
|
import { checkForNewVersion } from "./utils/version-check.js";
|
|
107
107
|
import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
|
|
@@ -692,37 +692,31 @@ function validateConfiguration() {
|
|
|
692
692
|
errors.push(`PORT must be between 1 and 65535, got: ${portStr}`);
|
|
693
693
|
}
|
|
694
694
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const apiUrls = configuredApiUrls === undefined ? [] : splitCommaSeparatedUrls(configuredApiUrls);
|
|
698
|
-
if (apiUrls.length > 0) {
|
|
699
|
-
for (const url of apiUrls) {
|
|
700
|
-
try {
|
|
701
|
-
new URL(url.trim());
|
|
702
|
-
}
|
|
703
|
-
catch {
|
|
704
|
-
errors.push(`GITLAB_API_URL contains an invalid URL: ${redactUrlSecretsInText(url.trim())}`);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
}
|
|
695
|
+
errors.push(...configuredGitLabApiUrlErrors());
|
|
696
|
+
// No entry is echoed: a comma inside a password splits these lists too.
|
|
708
697
|
const allowedHosts = getConfig("allowed-hosts", "GITLAB_ALLOWED_HOSTS")?.split(",") || [];
|
|
709
|
-
|
|
698
|
+
let hasInvalidAllowedHost = false;
|
|
699
|
+
allowedHosts.forEach((host, index) => {
|
|
710
700
|
if (host.trim() && !toAllowedGitLabApiUrl(host)) {
|
|
711
|
-
|
|
701
|
+
hasInvalidAllowedHost = true;
|
|
702
|
+
errors.push(invalidListEntryError("GITLAB_ALLOWED_HOSTS", index, allowedHosts.length, "is not a valid host or URL"));
|
|
712
703
|
}
|
|
704
|
+
});
|
|
705
|
+
if (hasInvalidAllowedHost && allowedHosts.length > 1) {
|
|
706
|
+
errors.push(commaSplitHint("GITLAB_ALLOWED_HOSTS"));
|
|
713
707
|
}
|
|
714
708
|
const mcpAllowedHosts = getConfig("mcp-allowed-hosts", "MCP_ALLOWED_HOSTS")?.split(",") || [];
|
|
715
|
-
|
|
709
|
+
mcpAllowedHosts.forEach((host, index) => {
|
|
716
710
|
if (host.trim() && !toAllowedMcpHost(host)) {
|
|
717
|
-
errors.push(
|
|
711
|
+
errors.push(invalidListEntryError("MCP_ALLOWED_HOSTS", index, mcpAllowedHosts.length, "is not a valid host or URL"));
|
|
718
712
|
}
|
|
719
|
-
}
|
|
713
|
+
});
|
|
720
714
|
const mcpAllowedOrigins = getConfig("mcp-allowed-origins", "MCP_ALLOWED_ORIGINS")?.split(",") || [];
|
|
721
|
-
|
|
715
|
+
mcpAllowedOrigins.forEach((origin, index) => {
|
|
722
716
|
if (origin.trim() && !toAllowedMcpOrigin(origin)) {
|
|
723
|
-
errors.push(
|
|
717
|
+
errors.push(invalidListEntryError("MCP_ALLOWED_ORIGINS", index, mcpAllowedOrigins.length, "is not a valid origin URL"));
|
|
724
718
|
}
|
|
725
|
-
}
|
|
719
|
+
});
|
|
726
720
|
// Validate auth configuration
|
|
727
721
|
const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
728
722
|
const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
|
|
@@ -811,6 +805,13 @@ let STATELESS_MATERIAL = null;
|
|
|
811
805
|
// Userinfo is dropped here, before the client pool or any request URL is built.
|
|
812
806
|
// Defined before the first emitDeprecatedEnvWarnings() call, which reports it.
|
|
813
807
|
const CONFIGURED_GITLAB_API_URLS = parseConfiguredGitLabApiUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com");
|
|
808
|
+
const API_URL_SOURCE = cliArgs["api-url"] ? "--api-url" : "GITLAB_API_URL";
|
|
809
|
+
// Checked before the first request: a fetch error would print an entry that
|
|
810
|
+
// does not parse, and that entry can be the start of a password.
|
|
811
|
+
function configuredGitLabApiUrlErrors() {
|
|
812
|
+
const configured = getConfig("api-url", "GITLAB_API_URL");
|
|
813
|
+
return configured === undefined ? [] : configuredApiUrlErrors(API_URL_SOURCE, configured);
|
|
814
|
+
}
|
|
814
815
|
let hasEmittedDeprecatedEnvWarnings = false;
|
|
815
816
|
function emitDeprecatedEnvWarnings() {
|
|
816
817
|
if (hasEmittedDeprecatedEnvWarnings) {
|
|
@@ -819,9 +820,7 @@ function emitDeprecatedEnvWarnings() {
|
|
|
819
820
|
hasEmittedDeprecatedEnvWarnings = true;
|
|
820
821
|
const warnings = [
|
|
821
822
|
...getDeprecatedEnvWarnings(buildDeprecatedEnvInput()),
|
|
822
|
-
...(CONFIGURED_GITLAB_API_URLS.hadUserinfo
|
|
823
|
-
? [apiUrlUserinfoWarning(cliArgs["api-url"] ? "--api-url" : "GITLAB_API_URL")]
|
|
824
|
-
: []),
|
|
823
|
+
...(CONFIGURED_GITLAB_API_URLS.hadUserinfo ? [apiUrlUserinfoWarning(API_URL_SOURCE)] : []),
|
|
825
824
|
];
|
|
826
825
|
for (const warning of warnings) {
|
|
827
826
|
logger.warn(warning);
|
|
@@ -12959,7 +12958,9 @@ function initOAuthClient() {
|
|
|
12959
12958
|
return;
|
|
12960
12959
|
}
|
|
12961
12960
|
if (isCleartextRemoteUrl(GITLAB_API_URL)) {
|
|
12962
|
-
throw new Error(
|
|
12961
|
+
throw new Error(
|
|
12962
|
+
// The host name only: a port can hold the start of a password (see apiUrlHostnameForLog).
|
|
12963
|
+
`Refusing to send OAuth tokens over cleartext HTTP to http://${apiUrlHostnameForLog(GITLAB_API_URL)}. ` +
|
|
12963
12964
|
"Use an https:// GITLAB_API_URL (http:// is only allowed for localhost).");
|
|
12964
12965
|
}
|
|
12965
12966
|
oauthClient = createGitLabOAuthClient(GITLAB_API_URL.replace(/\/api\/v4$/, ""));
|
|
@@ -12999,8 +13000,9 @@ async function runServer() {
|
|
|
12999
13000
|
}
|
|
13000
13001
|
});
|
|
13001
13002
|
}
|
|
13002
|
-
|
|
13003
|
-
logger.info(`
|
|
13003
|
+
// Host names only. GITLAB_API_URL is GITLAB_API_URLS[0], so it is logged the same way.
|
|
13004
|
+
logger.info(`Configured GitLab API URLs: ${GITLAB_API_URLS.map(apiUrlHostnameForLog).join(", ")}`);
|
|
13005
|
+
logger.info(`Default GitLab API URL: ${apiUrlHostnameForLog(GITLAB_API_URL)}`);
|
|
13004
13006
|
if (GITLAB_OAUTH_ALLOWED_GROUPS) {
|
|
13005
13007
|
logger.info(`Group access control enabled for: ${GITLAB_OAUTH_ALLOWED_GROUPS.join(", ")}`);
|
|
13006
13008
|
}
|
|
@@ -13028,8 +13030,7 @@ async function main() {
|
|
|
13028
13030
|
process.exit(0);
|
|
13029
13031
|
}
|
|
13030
13032
|
catch (error) {
|
|
13031
|
-
|
|
13032
|
-
process.stderr.write(`${redactUrlSecretsInText(message)}\n`);
|
|
13033
|
+
process.stderr.write(`${redactUrlSecretsInErrorMessage(error)}\n`);
|
|
13033
13034
|
logger.error({ err: error }, "auth command failed");
|
|
13034
13035
|
exitAfterFlush(1);
|
|
13035
13036
|
}
|
|
@@ -13040,6 +13041,11 @@ async function main() {
|
|
|
13040
13041
|
process.stderr.write("Missing GitLab credentials. Set --token or GITLAB_PERSONAL_ACCESS_TOKEN, or run `auth` with GITLAB_USE_OAUTH=true.\n");
|
|
13041
13042
|
process.exit(2);
|
|
13042
13043
|
}
|
|
13044
|
+
const apiUrlErrors = configuredGitLabApiUrlErrors();
|
|
13045
|
+
if (apiUrlErrors.length > 0) {
|
|
13046
|
+
process.stderr.write(apiUrlErrors.map(error => `${error}\n`).join(""));
|
|
13047
|
+
process.exit(2);
|
|
13048
|
+
}
|
|
13043
13049
|
try {
|
|
13044
13050
|
initOAuthClient();
|
|
13045
13051
|
const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
|
package/build/utils/logger.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { pino } from "pino";
|
|
3
|
-
import { redactUrlSecretsInLogLine } from "./redact-url.js";
|
|
3
|
+
import { redactUrlSecretsInError, redactUrlSecretsInLogLine } from "./redact-url.js";
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
5
|
const REDACT_PATHS = [
|
|
6
6
|
"token",
|
|
@@ -117,6 +117,35 @@ export function flushStartupLogs(logger) {
|
|
|
117
117
|
export function flushBufferedLogs(logger) {
|
|
118
118
|
flushDestination(logger);
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* pino's error serializer, run on the redacted error. It copies the error's own
|
|
122
|
+
* fields, and the `input` of an ERR_INVALID_URL can be the start of a password
|
|
123
|
+
* that the line redaction cannot recognize.
|
|
124
|
+
*/
|
|
125
|
+
export function serializeLoggedError(error) {
|
|
126
|
+
return pino.stdSerializers.err(redactUrlSecretsInError(error));
|
|
127
|
+
}
|
|
128
|
+
// Keys whose error pino or pino-pretty prints even though no serializer runs on it.
|
|
129
|
+
const LOGGED_ERROR_KEYS = ["err", "error"];
|
|
130
|
+
/**
|
|
131
|
+
* Redacts a logged error before pino reads it. Without a message argument pino
|
|
132
|
+
* takes `msg` from the error's own message, and an error under `error` is
|
|
133
|
+
* copied as it is.
|
|
134
|
+
*/
|
|
135
|
+
function redactLoggedErrors(args, method) {
|
|
136
|
+
const first = args[0];
|
|
137
|
+
if (first instanceof Error) {
|
|
138
|
+
redactUrlSecretsInError(first);
|
|
139
|
+
}
|
|
140
|
+
else if (typeof first === "object" && first !== null) {
|
|
141
|
+
for (const key of LOGGED_ERROR_KEYS) {
|
|
142
|
+
if (key in first) {
|
|
143
|
+
redactUrlSecretsInError(first[key]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
method.apply(this, args);
|
|
148
|
+
}
|
|
120
149
|
/**
|
|
121
150
|
* Create a pino logger with consistent redaction, level, format, and
|
|
122
151
|
* destination across the entire codebase.
|
|
@@ -128,6 +157,8 @@ export function flushBufferedLogs(logger) {
|
|
|
128
157
|
* - All loggers redact the same set of sensitive fields.
|
|
129
158
|
* - Every line, in either format, has URL userinfo and token query values
|
|
130
159
|
* redacted, including text from thrown fetch/URL errors and their causes.
|
|
160
|
+
* - A logged error, as the first argument or under `err` or `error`, is
|
|
161
|
+
* redacted before pino copies its fields or its message.
|
|
131
162
|
*/
|
|
132
163
|
export function createLogger(name) {
|
|
133
164
|
const isJson = process.env.LOG_FORMAT === "json";
|
|
@@ -138,7 +169,11 @@ export function createLogger(name) {
|
|
|
138
169
|
paths: REDACT_PATHS,
|
|
139
170
|
censor: "[REDACTED]",
|
|
140
171
|
},
|
|
172
|
+
serializers: {
|
|
173
|
+
err: serializeLoggedError,
|
|
174
|
+
},
|
|
141
175
|
hooks: {
|
|
176
|
+
logMethod: redactLoggedErrors,
|
|
142
177
|
streamWrite: redactUrlSecretsInLogLine,
|
|
143
178
|
},
|
|
144
179
|
};
|
|
@@ -39,13 +39,23 @@ export function redactUrlSecretsInText(text) {
|
|
|
39
39
|
* Redacts an error in place: `message`, `stack`, and every `cause` below it.
|
|
40
40
|
* undici and `new URL()` put the URL they rejected into the message, so a
|
|
41
41
|
* credentialed URL would otherwise reach the MCP client and the log verbatim.
|
|
42
|
+
* The `input` and `base` of an ERR_INVALID_URL are replaced, not redacted:
|
|
43
|
+
* text that did not parse has no host to keep, and it can be the start of a
|
|
44
|
+
* password cut off at a comma ("http://alice:p/api/v4"), with no "@" for the
|
|
45
|
+
* text redaction to find. The same text is replaced wherever another error
|
|
46
|
+
* of the chain quotes it ("Failed to parse URL from ...", or a wrapper that
|
|
47
|
+
* copies that message): every such text, in the message and stack of every
|
|
48
|
+
* error of the chain.
|
|
42
49
|
* A thrown string is returned redacted; other values are returned unchanged.
|
|
43
50
|
*/
|
|
44
51
|
export function redactUrlSecretsInError(error) {
|
|
45
52
|
if (typeof error === "string") {
|
|
46
53
|
return redactUrlSecretsInText(error);
|
|
47
54
|
}
|
|
48
|
-
|
|
55
|
+
const chain = readErrorChain(error);
|
|
56
|
+
for (const item of chain.errors) {
|
|
57
|
+
redactChainedError(item, chain);
|
|
58
|
+
}
|
|
49
59
|
return error;
|
|
50
60
|
}
|
|
51
61
|
/**
|
|
@@ -54,38 +64,273 @@ export function redactUrlSecretsInError(error) {
|
|
|
54
64
|
* as an isError result.
|
|
55
65
|
*/
|
|
56
66
|
export function redactUrlSecretsInErrorMessage(error) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
if (!(error instanceof Error)) {
|
|
68
|
+
return redactUrlSecretsInText(String(error));
|
|
69
|
+
}
|
|
70
|
+
return redactUrlSecretsInText(withoutInvalidUrls(error.message, readErrorChain(error)));
|
|
71
|
+
}
|
|
72
|
+
const REDACTED = "[REDACTED]";
|
|
73
|
+
// Own enumerable fields of Node's ERR_INVALID_URL, which pino's error serializer copies.
|
|
74
|
+
const INVALID_URL_FIELDS = ["input", "base"];
|
|
75
|
+
// The input and base of each ERR_INVALID_URL as first read. A wrapper that quoted
|
|
76
|
+
// them can be redacted after the ERR_INVALID_URL already was.
|
|
77
|
+
const invalidUrlTexts = new WeakMap();
|
|
78
|
+
function isInvalidUrlError(value) {
|
|
79
|
+
return value instanceof Error && value.code === "ERR_INVALID_URL";
|
|
80
|
+
}
|
|
81
|
+
function readInvalidUrlTexts(error) {
|
|
82
|
+
if (!isInvalidUrlError(error)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const known = invalidUrlTexts.get(error);
|
|
86
|
+
if (known !== undefined) {
|
|
87
|
+
return known;
|
|
88
|
+
}
|
|
89
|
+
const texts = [];
|
|
90
|
+
for (const field of INVALID_URL_FIELDS) {
|
|
91
|
+
const value = error[field];
|
|
92
|
+
if (typeof value === "string" && value.length > 0 && value !== REDACTED) {
|
|
93
|
+
texts.push(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
invalidUrlTexts.set(error, texts);
|
|
97
|
+
return texts;
|
|
98
|
+
}
|
|
99
|
+
// Every error reachable through `cause` and AggregateError `errors`, each once.
|
|
100
|
+
function readErrorChain(root) {
|
|
101
|
+
const errors = [];
|
|
102
|
+
const all = new Set();
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const pending = [root];
|
|
105
|
+
while (pending.length > 0) {
|
|
106
|
+
const value = pending.pop();
|
|
107
|
+
if (!(value instanceof Error) || seen.has(value)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
seen.add(value);
|
|
111
|
+
errors.push(value);
|
|
112
|
+
for (const text of readInvalidUrlTexts(value)) {
|
|
113
|
+
all.add(text);
|
|
114
|
+
}
|
|
115
|
+
pending.push(value.cause);
|
|
116
|
+
if (value instanceof AggregateError && Array.isArray(value.errors)) {
|
|
117
|
+
for (const inner of value.errors) {
|
|
118
|
+
pending.push(inner);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
62
121
|
}
|
|
63
|
-
|
|
122
|
+
const texts = [...all];
|
|
123
|
+
return {
|
|
124
|
+
errors,
|
|
125
|
+
texts,
|
|
126
|
+
heads: texts.length > 0 ? buildTextHeads(texts) : undefined,
|
|
127
|
+
matcher: undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function redactChainedError(error, chain) {
|
|
131
|
+
// Before the text redaction can change the quoted text.
|
|
132
|
+
hideInvalidUrls(error, "message", chain);
|
|
133
|
+
hideInvalidUrls(error, "stack", chain);
|
|
64
134
|
redactErrorField(error, "message");
|
|
65
135
|
redactErrorField(error, "stack");
|
|
136
|
+
if (isInvalidUrlError(error)) {
|
|
137
|
+
for (const field of INVALID_URL_FIELDS) {
|
|
138
|
+
const value = error[field];
|
|
139
|
+
if (typeof value === "string" && value.length > 0) {
|
|
140
|
+
setErrorField(error, field, REDACTED);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
66
144
|
if (typeof error.cause === "string") {
|
|
67
145
|
redactErrorField(error, "cause");
|
|
68
146
|
}
|
|
69
|
-
|
|
70
|
-
|
|
147
|
+
}
|
|
148
|
+
function hideInvalidUrls(error, field, chain) {
|
|
149
|
+
const value = error[field];
|
|
150
|
+
if (typeof value !== "string") {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const hidden = withoutInvalidUrls(value, chain);
|
|
154
|
+
if (hidden !== value) {
|
|
155
|
+
setErrorField(error, field, hidden);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// One pass to rule out the text, and at most one more to hide what it quotes.
|
|
159
|
+
function withoutInvalidUrls(text, chain) {
|
|
160
|
+
if (chain.heads === undefined || !mayQuoteText(text, chain.heads)) {
|
|
161
|
+
return text;
|
|
71
162
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
163
|
+
chain.matcher ??= buildTextMatcher(chain.texts);
|
|
164
|
+
return hideMatchedTexts(text, chain.matcher);
|
|
165
|
+
}
|
|
166
|
+
// Long enough that few windows look like the start of a URL, short enough to hash fast.
|
|
167
|
+
const MAX_HEAD_WIDTH = 16;
|
|
168
|
+
const HASH_BASE = 0x01000193;
|
|
169
|
+
// Keeps each hash a small integer.
|
|
170
|
+
const HASH_MASK = 0x3fffffff;
|
|
171
|
+
function buildTextHeads(texts) {
|
|
172
|
+
let width = MAX_HEAD_WIDTH;
|
|
173
|
+
for (const text of texts) {
|
|
174
|
+
width = Math.min(width, text.length);
|
|
175
|
+
}
|
|
176
|
+
const hashes = new Set();
|
|
177
|
+
for (const text of texts) {
|
|
178
|
+
let hash = 0;
|
|
179
|
+
for (let index = 0; index < width; index += 1) {
|
|
180
|
+
hash = (Math.imul(hash, HASH_BASE) + text.charCodeAt(index)) | 0;
|
|
181
|
+
}
|
|
182
|
+
hashes.add(hash & HASH_MASK);
|
|
183
|
+
}
|
|
184
|
+
return { width, hashes };
|
|
185
|
+
}
|
|
186
|
+
// A rolling hash over each window of the text, in one pass.
|
|
187
|
+
function mayQuoteText(text, heads) {
|
|
188
|
+
const { width, hashes } = heads;
|
|
189
|
+
// HASH_BASE ** (width - 1), to take out the code unit that leaves the window.
|
|
190
|
+
let top = 1;
|
|
191
|
+
for (let index = 1; index < width; index += 1) {
|
|
192
|
+
top = Math.imul(top, HASH_BASE);
|
|
193
|
+
}
|
|
194
|
+
let hash = 0;
|
|
195
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
196
|
+
if (index >= width) {
|
|
197
|
+
hash = (hash - Math.imul(text.charCodeAt(index - width), top)) | 0;
|
|
198
|
+
}
|
|
199
|
+
hash = (Math.imul(hash, HASH_BASE) + text.charCodeAt(index)) | 0;
|
|
200
|
+
if (index + 1 >= width && hashes.has(hash & HASH_MASK)) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const CODE_UNITS = 0x10000;
|
|
207
|
+
// The slot of the edge from a node on a code unit, or the empty slot it would take.
|
|
208
|
+
function edgeSlot(keys, node, code) {
|
|
209
|
+
const key = node * CODE_UNITS + code + 1;
|
|
210
|
+
const mask = keys.length - 1;
|
|
211
|
+
let hash = Math.imul(node, 0x9e3779b1) ^ code;
|
|
212
|
+
hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b);
|
|
213
|
+
let slot = (hash ^ (hash >>> 13)) & mask;
|
|
214
|
+
while (keys[slot] !== 0 && keys[slot] !== key) {
|
|
215
|
+
slot = (slot + 1) & mask;
|
|
216
|
+
}
|
|
217
|
+
return slot;
|
|
218
|
+
}
|
|
219
|
+
function buildTextMatcher(texts) {
|
|
220
|
+
let size = 1;
|
|
221
|
+
for (const text of texts) {
|
|
222
|
+
size += text.length;
|
|
223
|
+
}
|
|
224
|
+
// A power of two, at least twice the number of edges.
|
|
225
|
+
let slots = 2;
|
|
226
|
+
while (slots < 2 * size) {
|
|
227
|
+
slots *= 2;
|
|
228
|
+
}
|
|
229
|
+
const keys = new Float64Array(slots);
|
|
230
|
+
const targets = new Int32Array(slots);
|
|
231
|
+
const longest = new Int32Array(size);
|
|
232
|
+
// The children of a node as a list: its first child, then each child's sibling.
|
|
233
|
+
// 0 ends a list, because node 0 is no node's child.
|
|
234
|
+
const firstChild = new Int32Array(size);
|
|
235
|
+
const sibling = new Int32Array(size);
|
|
236
|
+
const unit = new Int32Array(size);
|
|
237
|
+
let nodes = 1;
|
|
238
|
+
for (const text of texts) {
|
|
239
|
+
let node = 0;
|
|
240
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
241
|
+
const code = text.charCodeAt(index);
|
|
242
|
+
const slot = edgeSlot(keys, node, code);
|
|
243
|
+
if (keys[slot] === 0) {
|
|
244
|
+
keys[slot] = node * CODE_UNITS + code + 1;
|
|
245
|
+
targets[slot] = nodes;
|
|
246
|
+
unit[nodes] = code;
|
|
247
|
+
sibling[nodes] = firstChild[node];
|
|
248
|
+
firstChild[node] = nodes;
|
|
249
|
+
nodes += 1;
|
|
250
|
+
}
|
|
251
|
+
node = targets[slot];
|
|
252
|
+
}
|
|
253
|
+
longest[node] = text.length;
|
|
254
|
+
}
|
|
255
|
+
// Breadth first: a fail link points to a shorter node, whose own link and
|
|
256
|
+
// longest text are then already known.
|
|
257
|
+
const matcher = { keys, targets, fail: new Int32Array(size), longest };
|
|
258
|
+
const queue = new Int32Array(nodes);
|
|
259
|
+
let queued = 0;
|
|
260
|
+
for (let child = firstChild[0]; child !== 0; child = sibling[child]) {
|
|
261
|
+
queue[queued] = child;
|
|
262
|
+
queued += 1;
|
|
263
|
+
}
|
|
264
|
+
for (let head = 0; head < queued; head += 1) {
|
|
265
|
+
const node = queue[head];
|
|
266
|
+
if (longest[node] === 0) {
|
|
267
|
+
longest[node] = longest[matcher.fail[node]];
|
|
268
|
+
}
|
|
269
|
+
for (let child = firstChild[node]; child !== 0; child = sibling[child]) {
|
|
270
|
+
matcher.fail[child] = nextNode(matcher, matcher.fail[node], unit[child]);
|
|
271
|
+
queue[queued] = child;
|
|
272
|
+
queued += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return matcher;
|
|
276
|
+
}
|
|
277
|
+
function nextNode(matcher, node, code) {
|
|
278
|
+
for (let from = node;; from = matcher.fail[from]) {
|
|
279
|
+
const slot = edgeSlot(matcher.keys, from, code);
|
|
280
|
+
if (matcher.keys[slot] !== 0) {
|
|
281
|
+
return matcher.targets[slot];
|
|
282
|
+
}
|
|
283
|
+
if (from === 0) {
|
|
284
|
+
return 0;
|
|
75
285
|
}
|
|
76
286
|
}
|
|
77
287
|
}
|
|
288
|
+
// Each part of the text covered by a text of the matcher becomes one [REDACTED].
|
|
289
|
+
// Overlapping matches are merged, so a shorter text inside a longer one leaves
|
|
290
|
+
// nothing behind; matches that only touch stay apart, as split and join leave them.
|
|
291
|
+
function hideMatchedTexts(text, matcher) {
|
|
292
|
+
const starts = [];
|
|
293
|
+
const ends = [];
|
|
294
|
+
let node = 0;
|
|
295
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
296
|
+
node = nextNode(matcher, node, text.charCodeAt(index));
|
|
297
|
+
const length = matcher.longest[node];
|
|
298
|
+
if (length === 0) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
let start = index + 1 - length;
|
|
302
|
+
while (ends.length > 0 && ends[ends.length - 1] > start) {
|
|
303
|
+
ends.pop();
|
|
304
|
+
start = Math.min(start, starts.pop() ?? start);
|
|
305
|
+
}
|
|
306
|
+
starts.push(start);
|
|
307
|
+
ends.push(index + 1);
|
|
308
|
+
}
|
|
309
|
+
if (starts.length === 0) {
|
|
310
|
+
return text;
|
|
311
|
+
}
|
|
312
|
+
const parts = [];
|
|
313
|
+
let from = 0;
|
|
314
|
+
for (let range = 0; range < starts.length; range += 1) {
|
|
315
|
+
parts.push(text.slice(from, starts[range]), REDACTED);
|
|
316
|
+
from = ends[range];
|
|
317
|
+
}
|
|
318
|
+
parts.push(text.slice(from));
|
|
319
|
+
return parts.join("");
|
|
320
|
+
}
|
|
78
321
|
function redactErrorField(error, field) {
|
|
79
322
|
const value = error[field];
|
|
80
323
|
if (typeof value !== "string") {
|
|
81
324
|
return;
|
|
82
325
|
}
|
|
83
326
|
const redacted = redactUrlSecretsInText(value);
|
|
84
|
-
if (redacted
|
|
85
|
-
|
|
327
|
+
if (redacted !== value) {
|
|
328
|
+
setErrorField(error, field, redacted);
|
|
86
329
|
}
|
|
330
|
+
}
|
|
331
|
+
function setErrorField(error, field, value) {
|
|
87
332
|
try {
|
|
88
|
-
error[field] =
|
|
333
|
+
error[field] = value;
|
|
89
334
|
}
|
|
90
335
|
catch {
|
|
91
336
|
// A frozen error keeps its text; the logger still redacts its own copy.
|
package/build/utils/url.js
CHANGED
|
@@ -27,6 +27,75 @@ export function apiUrlUserinfoWarning(source) {
|
|
|
27
27
|
return (`${source} contains a username or password before the host. It is ignored: requests ` +
|
|
28
28
|
"go to the URL without it. Use a token or OAuth configuration to authenticate instead.");
|
|
29
29
|
}
|
|
30
|
+
const HIDDEN_VALUE_NOTE = "(value not shown: it may contain a password)";
|
|
31
|
+
const COMMA_IN_PASSWORD_HINT = "write a comma inside a username or password as %2C";
|
|
32
|
+
/**
|
|
33
|
+
* Startup error for one entry of a comma-separated URL or host list. The entry
|
|
34
|
+
* is named by its position only. A comma inside a password splits the list
|
|
35
|
+
* there ("http://alice:p,http://w@host" gives "http://alice:p"), and an entry
|
|
36
|
+
* that does not parse has no host that is safe to show: any of its text can
|
|
37
|
+
* be part of the password.
|
|
38
|
+
*/
|
|
39
|
+
export function invalidListEntryError(source, index, total, problem) {
|
|
40
|
+
return `${source} entry ${index + 1} of ${total} ${problem} ${HIDDEN_VALUE_NOTE}`;
|
|
41
|
+
}
|
|
42
|
+
/** Follows the entry errors of a list that had more than one entry. */
|
|
43
|
+
export function commaSplitHint(source) {
|
|
44
|
+
return `${source} is split at commas: ${COMMA_IN_PASSWORD_HINT}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Startup errors for a configured GITLAB_API_URL list, one per bad entry, and
|
|
48
|
+
* none of them echo the entry. Each entry must be an http or https URL:
|
|
49
|
+
* fetch() cannot use another scheme, and a value without "//" such as
|
|
50
|
+
* "alice:p,w@host" parses as a URL whose scheme is the username. The scheme
|
|
51
|
+
* is checked, not the "//": fetch() also accepts "https:/host".
|
|
52
|
+
*/
|
|
53
|
+
export function configuredApiUrlErrors(source, value) {
|
|
54
|
+
const entries = splitCommaSeparatedUrls(value);
|
|
55
|
+
const errors = [];
|
|
56
|
+
let hasInvalidEntry = false;
|
|
57
|
+
entries.forEach((entry, index) => {
|
|
58
|
+
if (entry.length === 0) {
|
|
59
|
+
errors.push(`${source} entry ${index + 1} of ${entries.length} is empty`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const problem = apiUrlProblem(entry);
|
|
63
|
+
if (problem !== null) {
|
|
64
|
+
hasInvalidEntry = true;
|
|
65
|
+
errors.push(invalidListEntryError(source, index, entries.length, problem));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
// With only empty entries wrong, the list has an extra comma ("a,,b" or a
|
|
69
|
+
// trailing one), and the hint about a comma inside a password would mislead.
|
|
70
|
+
if (hasInvalidEntry && entries.length > 1) {
|
|
71
|
+
errors.push(commaSplitHint(source));
|
|
72
|
+
}
|
|
73
|
+
return errors;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The error for one API URL that is not split at commas (the `auth` command),
|
|
77
|
+
* or null when it is an http or https URL.
|
|
78
|
+
*/
|
|
79
|
+
export function apiUrlError(source, url) {
|
|
80
|
+
const problem = apiUrlProblem(url.trim());
|
|
81
|
+
if (problem === null) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const hint = url.includes(",") ? `; ${COMMA_IN_PASSWORD_HINT}` : "";
|
|
85
|
+
return `${source} ${problem} ${HIDDEN_VALUE_NOTE}${hint}`;
|
|
86
|
+
}
|
|
87
|
+
function apiUrlProblem(url) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(url);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return "is not a valid URL";
|
|
94
|
+
}
|
|
95
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
|
96
|
+
? null
|
|
97
|
+
: "is not an http or https URL";
|
|
98
|
+
}
|
|
30
99
|
/**
|
|
31
100
|
* Split a comma-separated GITLAB_API_URL, drop userinfo from each entry, and
|
|
32
101
|
* normalize it. fetch() refuses a URL with credentials, so a URL that kept
|
|
@@ -39,6 +108,25 @@ export function parseConfiguredGitLabApiUrls(value) {
|
|
|
39
108
|
hadUserinfo: entries.some(entry => entry.hadUserinfo),
|
|
40
109
|
};
|
|
41
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The host name of a configured API URL, for the startup log. A password with a
|
|
113
|
+
* literal comma can be split into two URLs, and "http://alice:12" then reads
|
|
114
|
+
* the start of the password as a port, which text redaction keeps. The port,
|
|
115
|
+
* userinfo, path, and query are left out. An IPv6 host keeps its brackets.
|
|
116
|
+
* An "@" after the host means the host can be part of a password
|
|
117
|
+
* ("http://sec/ret@host" from "alice:12,http://sec/ret@host"), so it is not shown.
|
|
118
|
+
*/
|
|
119
|
+
export function apiUrlHostnameForLog(url) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = new URL(url);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return "<invalid>";
|
|
126
|
+
}
|
|
127
|
+
const atAfterHost = parsed.pathname.includes("@") || parsed.search.includes("@") || parsed.hash.includes("@");
|
|
128
|
+
return parsed.hostname.length === 0 || atAfterHost ? "<invalid>" : parsed.hostname;
|
|
129
|
+
}
|
|
42
130
|
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
43
131
|
/**
|
|
44
132
|
* 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.6-next.
|
|
3
|
+
"version": "2.3.6-next.132",
|
|
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": [
|