@zereight/mcp-gitlab 2.3.6-next.129 → 2.3.6-next.131

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
@@ -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
  ### 클라이언트 설정 가이드
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
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
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
  ### 客户端设置指南
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(apiUrlFlag !== undefined ? "--api-url" : "GITLAB_API_URL"));
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");
@@ -1,5 +1,5 @@
1
1
  import { renderTable } from "./tables.js";
2
- import { redactUrlSecretsInText } from "../utils/redact-url.js";
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
- const message = error instanceof Error ? error.message : String(error);
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 redactSecrets(message) {
62
- return redactUrlSecretsInText(message).replace(/glpat-[A-Za-z0-9_-]+/g, "[REDACTED]");
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
@@ -91,16 +91,17 @@ import { determineTransportMode, TransportMode } from "./server/transport-mode.j
91
91
  import { formatPrometheusMetrics } from "./server/metrics.js";
92
92
  import { SERVER_VERSION } from "./server/version.js";
93
93
  import { listMcpResources, listMcpResourceTemplates, readMcpResource, } from "./server/mcp-resources.js";
94
+ import { getMcpPrompt, listMcpPrompts } from "./server/mcp-prompts.js";
94
95
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
95
96
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
96
- import { apiUrlUserinfoWarning, encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, parseConfiguredGitLabApiUrls, } from "./utils/url.js";
97
+ import { apiUrlUserinfoWarning, commaSplitHint, configuredApiUrlErrors, encodeGitLabPath, encodeGitLabPathSegment, invalidListEntryError, isCleartextRemoteUrl, normalizeGitLabApiUrl, parseConfiguredGitLabApiUrls, } from "./utils/url.js";
97
98
  import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, resolveDownloadRoot, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
98
99
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
99
100
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
100
101
  import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
101
102
  import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
102
103
  import { classifyHealthCheckTransportError, describeFetchTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
103
- import { redactUrlSecretsInError, redactUrlSecretsInErrorMessage, redactUrlSecretsInText, splitCommaSeparatedUrls, } from "./utils/redact-url.js";
104
+ import { redactUrlSecretsInError, redactUrlSecretsInErrorMessage, redactUrlSecretsInText, } from "./utils/redact-url.js";
104
105
  import { createMaskingPolicyResolver, getManagedMaskingProjectIds, } from "./masking/index.js";
105
106
  import { checkForNewVersion } from "./utils/version-check.js";
106
107
  import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
@@ -147,53 +148,6 @@ const SERVER_NAME = process.env.MCP_SERVER_NAME?.trim() || "zereight-gitlab-mcp-
147
148
  // No `title`: clients would show it instead of `name`, hiding MCP_SERVER_NAME.
148
149
  const SERVER_DESCRIPTION = "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more.";
149
150
  const SERVER_WEBSITE_URL = "https://zereight.github.io/gitlab-mcp/";
150
- /**
151
- * Static MCP prompt catalog. These wrap the multi-step tool workflows documented in
152
- * skills/gitlab-mcp/ so MCP clients that surface prompts (e.g. slash commands) can
153
- * trigger them without the agent needing to read the Skill first. Kept inline (not
154
- * loaded from skills/gitlab-mcp/*.md) since only "build" ships in the npm package.
155
- */
156
- const MCP_PROMPTS = [
157
- {
158
- name: "review_merge_request",
159
- description: "Review a merge request's diff in batches and leave threaded comments.",
160
- arguments: [
161
- { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
162
- { name: "merge_request_iid", description: "Merge request IID", required: true },
163
- ],
164
- render: (args) => `Review merge request !${args.merge_request_iid ?? "<iid>"} in project ${args.project_id ?? "<project_id>"}:\n` +
165
- `1. Call list_merge_request_changed_files to get the changed file list (use excluded_file_patterns for lockfiles/dist/generated files).\n` +
166
- `2. Call get_merge_request_file_diff in batches of 3-5 files, prioritizing source over config/tests.\n` +
167
- `3. Leave inline findings with create_merge_request_thread (position: new_path/new_line). For a general note, call discover_tools with category "merge_requests", then create_merge_request_note.\n` +
168
- `4. Summarize the review verdict in a final top-level note.`,
169
- },
170
- {
171
- name: "triage_pipeline_failure",
172
- description: "Find the failing job in a pipeline and pull its log tail for diagnosis.",
173
- arguments: [
174
- { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
175
- { name: "pipeline_id", description: "Pipeline ID", required: true },
176
- ],
177
- render: (args) => `Diagnose pipeline ${args.pipeline_id ?? "<pipeline_id>"} in project ${args.project_id ?? "<project_id>"}:\n` +
178
- `1. Call list_pipeline_jobs to find jobs with status "failed".\n` +
179
- `2. Call get_pipeline_job_output (or the job trace tool) on each failed job, focusing on the tail of the log.\n` +
180
- `3. Identify the root cause (compile error, test failure, timeout, runner issue) and quote the relevant log lines.\n` +
181
- `4. Suggest the fix or the next diagnostic step.`,
182
- },
183
- {
184
- name: "triage_vulnerabilities",
185
- description: "List a project's open vulnerabilities and propose a remediation priority order.",
186
- arguments: [
187
- { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
188
- ],
189
- render: (args) => `Triage vulnerabilities for project ${args.project_id ?? "<project_id>"}:\n` +
190
- `1. Call list_project_vulnerabilities once with state=detected and once with state=confirmed ` +
191
- `(the tool only accepts one state per call). Follow pageInfo.endCursor with the "after" ` +
192
- `param while pageInfo.hasNextPage is true, and aggregate both states' results.\n` +
193
- `2. Group the aggregated results by severity (critical > high > medium > low) and by whether a fix is already available.\n` +
194
- `3. Propose a remediation order, calling out any critical/high items with no available fix.`,
195
- },
196
- ];
197
151
  /**
198
152
  * Error text for a call to a tool that tools/list omits.
199
153
  * Suggests `discover_tools` only when that call would add the tool.
@@ -255,32 +209,8 @@ function createServer() {
255
209
  resources: {},
256
210
  },
257
211
  });
258
- mcpServer.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
259
- prompts: MCP_PROMPTS.map(({ name, description, arguments: args }) => ({
260
- name,
261
- description,
262
- arguments: args,
263
- })),
264
- }));
265
- mcpServer.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
266
- const prompt = MCP_PROMPTS.find(p => p.name === request.params.name);
267
- if (!prompt) {
268
- throw new Error(`Unknown prompt: ${request.params.name}`);
269
- }
270
- const args = (request.params.arguments ?? {});
271
- return {
272
- description: prompt.description,
273
- messages: [
274
- {
275
- role: "user",
276
- content: {
277
- type: "text",
278
- text: prompt.render(args),
279
- },
280
- },
281
- ],
282
- };
283
- });
212
+ mcpServer.server.setRequestHandler(ListPromptsRequestSchema, async () => listMcpPrompts());
213
+ mcpServer.server.setRequestHandler(GetPromptRequestSchema, async (request) => getMcpPrompt(request.params.name, (request.params.arguments ?? {})));
284
214
  mcpServer.server.setRequestHandler(ListResourcesRequestSchema, async () => listMcpResources());
285
215
  mcpServer.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => listMcpResourceTemplates());
286
216
  mcpServer.server.setRequestHandler(ReadResourceRequestSchema, async (request) => readMcpResource(request.params.uri));
@@ -762,37 +692,31 @@ function validateConfiguration() {
762
692
  errors.push(`PORT must be between 1 and 65535, got: ${portStr}`);
763
693
  }
764
694
  }
765
- // Validate GITLAB_API_URL format
766
- const configuredApiUrls = getConfig("api-url", "GITLAB_API_URL");
767
- const apiUrls = configuredApiUrls === undefined ? [] : splitCommaSeparatedUrls(configuredApiUrls);
768
- if (apiUrls.length > 0) {
769
- for (const url of apiUrls) {
770
- try {
771
- new URL(url.trim());
772
- }
773
- catch {
774
- errors.push(`GITLAB_API_URL contains an invalid URL: ${redactUrlSecretsInText(url.trim())}`);
775
- }
776
- }
777
- }
695
+ errors.push(...configuredGitLabApiUrlErrors());
696
+ // No entry is echoed: a comma inside a password splits these lists too.
778
697
  const allowedHosts = getConfig("allowed-hosts", "GITLAB_ALLOWED_HOSTS")?.split(",") || [];
779
- for (const host of allowedHosts) {
698
+ let hasInvalidAllowedHost = false;
699
+ allowedHosts.forEach((host, index) => {
780
700
  if (host.trim() && !toAllowedGitLabApiUrl(host)) {
781
- errors.push(`GITLAB_ALLOWED_HOSTS contains an invalid host or URL: ${host.trim()}`);
701
+ hasInvalidAllowedHost = true;
702
+ errors.push(invalidListEntryError("GITLAB_ALLOWED_HOSTS", index, allowedHosts.length, "is not a valid host or URL"));
782
703
  }
704
+ });
705
+ if (hasInvalidAllowedHost && allowedHosts.length > 1) {
706
+ errors.push(commaSplitHint("GITLAB_ALLOWED_HOSTS"));
783
707
  }
784
708
  const mcpAllowedHosts = getConfig("mcp-allowed-hosts", "MCP_ALLOWED_HOSTS")?.split(",") || [];
785
- for (const host of mcpAllowedHosts) {
709
+ mcpAllowedHosts.forEach((host, index) => {
786
710
  if (host.trim() && !toAllowedMcpHost(host)) {
787
- errors.push(`MCP_ALLOWED_HOSTS contains an invalid host or URL: ${host.trim()}`);
711
+ errors.push(invalidListEntryError("MCP_ALLOWED_HOSTS", index, mcpAllowedHosts.length, "is not a valid host or URL"));
788
712
  }
789
- }
713
+ });
790
714
  const mcpAllowedOrigins = getConfig("mcp-allowed-origins", "MCP_ALLOWED_ORIGINS")?.split(",") || [];
791
- for (const origin of mcpAllowedOrigins) {
715
+ mcpAllowedOrigins.forEach((origin, index) => {
792
716
  if (origin.trim() && !toAllowedMcpOrigin(origin)) {
793
- errors.push(`MCP_ALLOWED_ORIGINS contains an invalid origin URL: ${origin.trim()}`);
717
+ errors.push(invalidListEntryError("MCP_ALLOWED_ORIGINS", index, mcpAllowedOrigins.length, "is not a valid origin URL"));
794
718
  }
795
- }
719
+ });
796
720
  // Validate auth configuration
797
721
  const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
798
722
  const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
@@ -881,6 +805,13 @@ let STATELESS_MATERIAL = null;
881
805
  // Userinfo is dropped here, before the client pool or any request URL is built.
882
806
  // Defined before the first emitDeprecatedEnvWarnings() call, which reports it.
883
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
+ }
884
815
  let hasEmittedDeprecatedEnvWarnings = false;
885
816
  function emitDeprecatedEnvWarnings() {
886
817
  if (hasEmittedDeprecatedEnvWarnings) {
@@ -889,9 +820,7 @@ function emitDeprecatedEnvWarnings() {
889
820
  hasEmittedDeprecatedEnvWarnings = true;
890
821
  const warnings = [
891
822
  ...getDeprecatedEnvWarnings(buildDeprecatedEnvInput()),
892
- ...(CONFIGURED_GITLAB_API_URLS.hadUserinfo
893
- ? [apiUrlUserinfoWarning(cliArgs["api-url"] ? "--api-url" : "GITLAB_API_URL")]
894
- : []),
823
+ ...(CONFIGURED_GITLAB_API_URLS.hadUserinfo ? [apiUrlUserinfoWarning(API_URL_SOURCE)] : []),
895
824
  ];
896
825
  for (const warning of warnings) {
897
826
  logger.warn(warning);
@@ -13098,8 +13027,7 @@ async function main() {
13098
13027
  process.exit(0);
13099
13028
  }
13100
13029
  catch (error) {
13101
- const message = error instanceof Error ? error.message : String(error);
13102
- process.stderr.write(`${redactUrlSecretsInText(message)}\n`);
13030
+ process.stderr.write(`${redactUrlSecretsInErrorMessage(error)}\n`);
13103
13031
  logger.error({ err: error }, "auth command failed");
13104
13032
  exitAfterFlush(1);
13105
13033
  }
@@ -13110,6 +13038,11 @@ async function main() {
13110
13038
  process.stderr.write("Missing GitLab credentials. Set --token or GITLAB_PERSONAL_ACCESS_TOKEN, or run `auth` with GITLAB_USE_OAUTH=true.\n");
13111
13039
  process.exit(2);
13112
13040
  }
13041
+ const apiUrlErrors = configuredGitLabApiUrlErrors();
13042
+ if (apiUrlErrors.length > 0) {
13043
+ process.stderr.write(apiUrlErrors.map(error => `${error}\n`).join(""));
13044
+ process.exit(2);
13045
+ }
13113
13046
  try {
13114
13047
  initOAuthClient();
13115
13048
  const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
@@ -0,0 +1,75 @@
1
+ // npm ships build/ only, so the text is inlined.
2
+ /**
3
+ * Static MCP prompt catalog. These wrap the multi-step tool workflows documented in
4
+ * skills/gitlab-mcp/ so MCP clients that surface prompts (e.g. slash commands) can
5
+ * trigger them without the agent needing to read the Skill first. Kept inline (not
6
+ * loaded from skills/gitlab-mcp/*.md) since only "build" ships in the npm package.
7
+ */
8
+ const prompts = [
9
+ {
10
+ name: "review_merge_request",
11
+ description: "Review a merge request's diff in batches and leave threaded comments.",
12
+ arguments: [
13
+ { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
14
+ { name: "merge_request_iid", description: "Merge request IID", required: true },
15
+ ],
16
+ render: (args) => `Review merge request !${args.merge_request_iid ?? "<iid>"} in project ${args.project_id ?? "<project_id>"}:\n` +
17
+ `1. Call list_merge_request_changed_files to get the changed file list (use excluded_file_patterns for lockfiles/dist/generated files).\n` +
18
+ `2. Call get_merge_request_file_diff in batches of 3-5 files, prioritizing source over config/tests.\n` +
19
+ `3. Leave inline findings with create_merge_request_thread (position: new_path/new_line). For a general note, call discover_tools with category "merge_requests", then create_merge_request_note.\n` +
20
+ `4. Summarize the review verdict in a final top-level note.`,
21
+ },
22
+ {
23
+ name: "triage_pipeline_failure",
24
+ description: "Find the failing job in a pipeline and pull its log tail for diagnosis.",
25
+ arguments: [
26
+ { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
27
+ { name: "pipeline_id", description: "Pipeline ID", required: true },
28
+ ],
29
+ render: (args) => `Diagnose pipeline ${args.pipeline_id ?? "<pipeline_id>"} in project ${args.project_id ?? "<project_id>"}:\n` +
30
+ `1. Call list_pipeline_jobs to find jobs with status "failed".\n` +
31
+ `2. Call get_pipeline_job_output (or the job trace tool) on each failed job, focusing on the tail of the log.\n` +
32
+ `3. Identify the root cause (compile error, test failure, timeout, runner issue) and quote the relevant log lines.\n` +
33
+ `4. Suggest the fix or the next diagnostic step.`,
34
+ },
35
+ {
36
+ name: "triage_vulnerabilities",
37
+ description: "List a project's open vulnerabilities and propose a remediation priority order.",
38
+ arguments: [
39
+ { name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
40
+ ],
41
+ render: (args) => `Triage vulnerabilities for project ${args.project_id ?? "<project_id>"}:\n` +
42
+ `1. Call list_project_vulnerabilities once with state=detected and once with state=confirmed ` +
43
+ `(the tool only accepts one state per call). Follow pageInfo.endCursor with the "after" ` +
44
+ `param while pageInfo.hasNextPage is true, and aggregate both states' results.\n` +
45
+ `2. Group the aggregated results by severity (critical > high > medium > low) and by whether a fix is already available.\n` +
46
+ `3. Propose a remediation order, calling out any critical/high items with no available fix.`,
47
+ },
48
+ ];
49
+ export function listMcpPrompts() {
50
+ return {
51
+ prompts: prompts.map(({ name, description, arguments: args }) => ({
52
+ name,
53
+ description,
54
+ arguments: args,
55
+ })),
56
+ };
57
+ }
58
+ export function getMcpPrompt(name, args) {
59
+ const prompt = prompts.find(p => p.name === name);
60
+ if (!prompt) {
61
+ throw new Error(`Unknown prompt: ${name}`);
62
+ }
63
+ return {
64
+ description: prompt.description,
65
+ messages: [
66
+ {
67
+ role: "user",
68
+ content: {
69
+ type: "text",
70
+ text: prompt.render(args),
71
+ },
72
+ },
73
+ ],
74
+ };
75
+ }
@@ -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
- redactErrorChain(error, new Set());
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
- 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;
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
- seen.add(error);
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
- else {
70
- redactErrorChain(error.cause, seen);
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
- if (error instanceof AggregateError && Array.isArray(error.errors)) {
73
- for (const inner of error.errors) {
74
- redactErrorChain(inner, seen);
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 === value) {
85
- return;
327
+ if (redacted !== value) {
328
+ setErrorField(error, field, redacted);
86
329
  }
330
+ }
331
+ function setErrorField(error, field, value) {
87
332
  try {
88
- error[field] = redacted;
333
+ error[field] = value;
89
334
  }
90
335
  catch {
91
336
  // A frozen error keeps its text; the logger still redacts its own copy.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.3.6-next.129",
3
+ "version": "2.3.6-next.131",
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": [