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