@zereight/mcp-gitlab 2.3.3 → 2.3.5-next.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +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 +92 -59
- 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`를 사용하세요. `main`에 머지된 수정을 다음 안정 버전 전에 확인하려면 프리릴리스 빌드인 `npx -y @zereight/mcp-gitlab@next`를 사용하세요(운영 환경용이 아닙니다). 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
142
142
|
|
|
143
143
|
#### Claude Desktop 원클릭 설치(MCP Bundle)
|
|
144
144
|
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# GitLab MCP Server
|
|
2
2
|
|
|
3
|
-
[](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. To try a fix merged to `main` before the next stable release, use `npx -y @zereight/mcp-gitlab@next`, a pre-release build that is not meant for production. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
147
147
|
|
|
148
148
|
#### Claude Desktop one-click install (MCP Bundle)
|
|
149
149
|
|
package/README.pt-BR.md
CHANGED
|
@@ -138,7 +138,7 @@ O caminho no store é fixado pelo seu lock file; atualize-o com `nix flake updat
|
|
|
138
138
|
|
|
139
139
|
Os exemplos usam `zereight-mcp-gitlab`, um alias com menor chance de conflito que o binário legado `mcp-gitlab`. Se o seu cliente MCP não o encontrar, use o caminho absoluto retornado por `which zereight-mcp-gitlab`.
|
|
140
140
|
|
|
141
|
-
Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.3.
|
|
141
|
+
Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.3.3`. Se quiser sempre a versão mais recente, use `npx -y @zereight/mcp-gitlab@latest`. Para testar uma correção já mesclada na `main` antes da próxima versão estável, use `npx -y @zereight/mcp-gitlab@next`, um build de pré-lançamento que não é indicado para produção. O servidor exibe um aviso no stderr durante a inicialização quando existe uma versão mais nova disponível (desative com `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
142
142
|
|
|
143
143
|
#### Instalação com um clique no Claude Desktop (MCP Bundle)
|
|
144
144
|
|
package/README.zh-CN.md
CHANGED
|
@@ -138,7 +138,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
|
|
|
138
138
|
|
|
139
139
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
140
140
|
|
|
141
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.3.
|
|
141
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.3.3`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。如需在下一个稳定版本发布前试用已合并到 `main` 的修复,请使用 `npx -y @zereight/mcp-gitlab@next`(预发布版本,不适用于生产环境)。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
142
142
|
|
|
143
143
|
#### Claude Desktop 一键安装(MCP Bundle)
|
|
144
144
|
|
package/build/config.js
CHANGED
|
@@ -79,6 +79,7 @@ export const SSE = getConfig("sse", "SSE") === "true";
|
|
|
79
79
|
export const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
|
|
80
80
|
export const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
81
81
|
export const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
|
|
82
|
+
export const GITLAB_MCP_REQUEST_BODY_LIMIT = process.env.GITLAB_MCP_REQUEST_BODY_LIMIT?.trim() || "10mb";
|
|
82
83
|
export const GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY = getConfig("allow-unauthenticated-tool-discovery", "GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY") === "true";
|
|
83
84
|
export const MCP_TRUST_PROXY = getConfig("mcp-trust-proxy", "MCP_TRUST_PROXY") === "true";
|
|
84
85
|
// ---------------------------------------------------------------------------
|
package/build/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_OAUTH_ALLOWED_REDIRECT_URIS_RAW, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, GITLAB_DOWNLOAD_DIR, buildDeprecatedEnvInput, } from "./config.js";
|
|
2
|
+
import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_MCP_REQUEST_BODY_LIMIT, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_OAUTH_ALLOWED_REDIRECT_URIS_RAW, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, GITLAB_DOWNLOAD_DIR, buildDeprecatedEnvInput, } from "./config.js";
|
|
3
3
|
import { getDeprecatedEnvWarnings } from "./deprecated-env.js";
|
|
4
4
|
/** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
|
|
5
5
|
const IS_REMOTE = SSE || STREAMABLE_HTTP;
|
|
@@ -62,7 +62,7 @@ function isConstantTimeSecretMatch(provided, expected) {
|
|
|
62
62
|
import { loadKeyMaterialFromEnv, looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./stateless/index.js";
|
|
63
63
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
64
64
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
65
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
65
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
66
66
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
67
67
|
import fetchCookie from "fetch-cookie";
|
|
68
68
|
import fs from "node:fs";
|
|
@@ -90,6 +90,7 @@ import { loadRemoteHttpStack } from "./server/remote-http-stack.js";
|
|
|
90
90
|
import { determineTransportMode, TransportMode } from "./server/transport-mode.js";
|
|
91
91
|
import { formatPrometheusMetrics } from "./server/metrics.js";
|
|
92
92
|
import { SERVER_VERSION } from "./server/version.js";
|
|
93
|
+
import { listMcpResources, listMcpResourceTemplates, readMcpResource, } from "./server/mcp-resources.js";
|
|
93
94
|
import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
|
|
94
95
|
export { readMcpSessionIdHeader } from "./server/request-helpers.js";
|
|
95
96
|
import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
|
|
@@ -98,12 +99,13 @@ import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation
|
|
|
98
99
|
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
99
100
|
import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
|
|
100
101
|
import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
|
|
101
|
-
import { classifyHealthCheckTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
|
|
102
|
-
import { redactUrlSecretsInText } from "./utils/redact-url.js";
|
|
102
|
+
import { classifyHealthCheckTransportError, describeFetchTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
|
|
103
|
+
import { redactUrlSecretsInText, splitCommaSeparatedUrls } from "./utils/redact-url.js";
|
|
103
104
|
import { createMaskingPolicyResolver, getManagedMaskingProjectIds, } from "./masking/index.js";
|
|
104
105
|
import { checkForNewVersion } from "./utils/version-check.js";
|
|
105
106
|
import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
|
|
106
107
|
import { fetchWithValidatedRedirects } from "./utils/safe-redirect-fetch.js";
|
|
108
|
+
import { fetchStatusBeforeDeadline } from "./utils/poll-deadline.js";
|
|
107
109
|
import { parseGitLabVersionApiResponse, } from "./utils/gitlab-instance-version.js";
|
|
108
110
|
import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./utils/bulk-publish-options.js";
|
|
109
111
|
import { appendFilterParam, appendFilterParams, cleanMutuallyExclusiveIdUsernameOptions, dropBlankArrayEntries, isBlankFilterValue, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
|
|
@@ -142,6 +144,9 @@ const maskingPolicyResolver = createMaskingPolicyResolver({
|
|
|
142
144
|
workspaceDir: GITLAB_MASKING_WORKSPACE_DIR,
|
|
143
145
|
});
|
|
144
146
|
const SERVER_NAME = process.env.MCP_SERVER_NAME?.trim() || "zereight-gitlab-mcp-server";
|
|
147
|
+
// No `title`: clients would show it instead of `name`, hiding MCP_SERVER_NAME.
|
|
148
|
+
const SERVER_DESCRIPTION = "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more.";
|
|
149
|
+
const SERVER_WEBSITE_URL = "https://zereight.github.io/gitlab-mcp/";
|
|
145
150
|
/**
|
|
146
151
|
* Static MCP prompt catalog. These wrap the multi-step tool workflows documented in
|
|
147
152
|
* skills/gitlab-mcp/ so MCP clients that surface prompts (e.g. slash commands) can
|
|
@@ -239,12 +244,15 @@ function createServer() {
|
|
|
239
244
|
const mcpServer = new McpServer({
|
|
240
245
|
name: SERVER_NAME,
|
|
241
246
|
version: SERVER_VERSION,
|
|
247
|
+
description: SERVER_DESCRIPTION,
|
|
248
|
+
websiteUrl: SERVER_WEBSITE_URL,
|
|
242
249
|
}, {
|
|
243
250
|
capabilities: {
|
|
244
251
|
tools: {
|
|
245
252
|
listChanged: true,
|
|
246
253
|
},
|
|
247
254
|
prompts: {},
|
|
255
|
+
resources: {},
|
|
248
256
|
},
|
|
249
257
|
});
|
|
250
258
|
mcpServer.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
@@ -273,6 +281,9 @@ function createServer() {
|
|
|
273
281
|
],
|
|
274
282
|
};
|
|
275
283
|
});
|
|
284
|
+
mcpServer.server.setRequestHandler(ListResourcesRequestSchema, async () => listMcpResources());
|
|
285
|
+
mcpServer.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => listMcpResourceTemplates());
|
|
286
|
+
mcpServer.server.setRequestHandler(ReadResourceRequestSchema, async (request) => readMcpResource(request.params.uri));
|
|
276
287
|
mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
277
288
|
return {
|
|
278
289
|
tools: getListedTools(filteredTools, listedToolsCache.revision),
|
|
@@ -730,7 +741,8 @@ function validateConfiguration() {
|
|
|
730
741
|
}
|
|
731
742
|
}
|
|
732
743
|
// Validate GITLAB_API_URL format
|
|
733
|
-
const
|
|
744
|
+
const configuredApiUrls = getConfig("api-url", "GITLAB_API_URL");
|
|
745
|
+
const apiUrls = configuredApiUrls === undefined ? [] : splitCommaSeparatedUrls(configuredApiUrls);
|
|
734
746
|
if (apiUrls.length > 0) {
|
|
735
747
|
for (const url of apiUrls) {
|
|
736
748
|
try {
|
|
@@ -954,9 +966,7 @@ const hiddenToolSet = new Set((GITLAB_TOOL_POLICY_HIDDEN_RAW || "")
|
|
|
954
966
|
}
|
|
955
967
|
}
|
|
956
968
|
const clientPool = new GitLabClientPool({
|
|
957
|
-
apiUrls: (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
|
|
958
|
-
.split(",")
|
|
959
|
-
.map(normalizeGitLabApiUrl),
|
|
969
|
+
apiUrls: splitCommaSeparatedUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com").map(normalizeGitLabApiUrl),
|
|
960
970
|
httpProxy: HTTP_PROXY,
|
|
961
971
|
httpsProxy: HTTPS_PROXY,
|
|
962
972
|
noProxy: NO_PROXY,
|
|
@@ -1330,9 +1340,7 @@ function resolveTrustedGitLabApiUrl(value) {
|
|
|
1330
1340
|
return allowedApiUrl;
|
|
1331
1341
|
}
|
|
1332
1342
|
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
1333
|
-
const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
|
|
1334
|
-
.split(",")
|
|
1335
|
-
.map(normalizeGitLabApiUrl);
|
|
1343
|
+
const GITLAB_API_URLS = splitCommaSeparatedUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com").map(normalizeGitLabApiUrl);
|
|
1336
1344
|
const GITLAB_API_URL = GITLAB_API_URLS[0];
|
|
1337
1345
|
const GITLAB_ALLOWED_API_URLS_BY_HOST = new Map();
|
|
1338
1346
|
for (const { host, apiUrl } of [
|
|
@@ -6385,11 +6393,7 @@ function delayForRebasePoll(delayMs) {
|
|
|
6385
6393
|
});
|
|
6386
6394
|
}
|
|
6387
6395
|
async function getMergeRequestRebaseStatusBeforeDeadline(projectId, mergeRequestIid, deadline) {
|
|
6388
|
-
|
|
6389
|
-
if (remaining <= 0) {
|
|
6390
|
-
throw new Error("Timed out waiting for terminal status");
|
|
6391
|
-
}
|
|
6392
|
-
return getMergeRequestRebaseStatus(projectId, mergeRequestIid, AbortSignal.timeout(remaining));
|
|
6396
|
+
return fetchStatusBeforeDeadline(signal => getMergeRequestRebaseStatus(projectId, mergeRequestIid, signal), deadline);
|
|
6393
6397
|
}
|
|
6394
6398
|
/**
|
|
6395
6399
|
* Poll until a rebase finishes, without trusting a finished snapshot that still
|
|
@@ -6433,12 +6437,7 @@ async function waitForMergeRequestRebase(projectId, mergeRequestIid, previous, t
|
|
|
6433
6437
|
}
|
|
6434
6438
|
async function waitForStatus(fetchStatus, terminalStatuses, timeoutSeconds = 300, pollIntervalSeconds = 5) {
|
|
6435
6439
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
6436
|
-
const fetchBeforeDeadline =
|
|
6437
|
-
const remaining = deadline - Date.now();
|
|
6438
|
-
if (remaining <= 0)
|
|
6439
|
-
throw new Error("Timed out waiting for terminal status");
|
|
6440
|
-
return fetchStatus(AbortSignal.timeout(remaining));
|
|
6441
|
-
};
|
|
6440
|
+
const fetchBeforeDeadline = () => fetchStatusBeforeDeadline(fetchStatus, deadline);
|
|
6442
6441
|
let value = await fetchBeforeDeadline();
|
|
6443
6442
|
while (!terminalStatuses.has(value.status)) {
|
|
6444
6443
|
const remaining = deadline - Date.now();
|
|
@@ -8301,7 +8300,7 @@ async function handleToolCall(params) {
|
|
|
8301
8300
|
};
|
|
8302
8301
|
}
|
|
8303
8302
|
catch (err) {
|
|
8304
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
8303
|
+
const message = describeFetchTransportError(err) ?? (err instanceof Error ? err.message : String(err));
|
|
8305
8304
|
return {
|
|
8306
8305
|
content: [
|
|
8307
8306
|
{
|
|
@@ -8351,7 +8350,8 @@ async function handleToolCall(params) {
|
|
|
8351
8350
|
logger.error({ err: forkError }, "Error forking repository");
|
|
8352
8351
|
let forkErrorMessage = "Failed to fork repository";
|
|
8353
8352
|
if (forkError instanceof Error) {
|
|
8354
|
-
|
|
8353
|
+
const reason = describeFetchTransportError(forkError) ?? forkError.message;
|
|
8354
|
+
forkErrorMessage = `${forkErrorMessage}: ${reason}`;
|
|
8355
8355
|
}
|
|
8356
8356
|
return {
|
|
8357
8357
|
content: [
|
|
@@ -8719,7 +8719,7 @@ async function handleToolCall(params) {
|
|
|
8719
8719
|
text: JSON.stringify({
|
|
8720
8720
|
status: "success",
|
|
8721
8721
|
message: "All pending to-do items marked as done",
|
|
8722
|
-
}
|
|
8722
|
+
}),
|
|
8723
8723
|
},
|
|
8724
8724
|
],
|
|
8725
8725
|
};
|
|
@@ -8963,9 +8963,7 @@ async function handleToolCall(params) {
|
|
|
8963
8963
|
await handleGitLabError(response);
|
|
8964
8964
|
const data = await response.json();
|
|
8965
8965
|
return {
|
|
8966
|
-
content: [
|
|
8967
|
-
{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(data), null, 2) },
|
|
8968
|
-
],
|
|
8966
|
+
content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(data)) }],
|
|
8969
8967
|
};
|
|
8970
8968
|
}
|
|
8971
8969
|
case "list_project_members": {
|
|
@@ -9186,7 +9184,7 @@ async function handleToolCall(params) {
|
|
|
9186
9184
|
changes: result.changes,
|
|
9187
9185
|
summary: result.summary,
|
|
9188
9186
|
preview: result.preview,
|
|
9189
|
-
}
|
|
9187
|
+
}),
|
|
9190
9188
|
},
|
|
9191
9189
|
],
|
|
9192
9190
|
};
|
|
@@ -9225,7 +9223,7 @@ async function handleToolCall(params) {
|
|
|
9225
9223
|
web_url: updatedIssue.web_url,
|
|
9226
9224
|
updated_at: updatedIssue.updated_at,
|
|
9227
9225
|
},
|
|
9228
|
-
}
|
|
9226
|
+
}),
|
|
9229
9227
|
},
|
|
9230
9228
|
],
|
|
9231
9229
|
};
|
|
@@ -9237,7 +9235,7 @@ async function handleToolCall(params) {
|
|
|
9237
9235
|
content: [
|
|
9238
9236
|
{
|
|
9239
9237
|
type: "text",
|
|
9240
|
-
text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }
|
|
9238
|
+
text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }),
|
|
9241
9239
|
},
|
|
9242
9240
|
],
|
|
9243
9241
|
};
|
|
@@ -9281,7 +9279,7 @@ async function handleToolCall(params) {
|
|
|
9281
9279
|
text: JSON.stringify({
|
|
9282
9280
|
status: "success",
|
|
9283
9281
|
message: "Issue link deleted successfully",
|
|
9284
|
-
}
|
|
9282
|
+
}),
|
|
9285
9283
|
},
|
|
9286
9284
|
],
|
|
9287
9285
|
};
|
|
@@ -9459,7 +9457,7 @@ async function handleToolCall(params) {
|
|
|
9459
9457
|
content: [
|
|
9460
9458
|
{
|
|
9461
9459
|
type: "text",
|
|
9462
|
-
text: JSON.stringify({ status: "success", message: "Label deleted successfully" }
|
|
9460
|
+
text: JSON.stringify({ status: "success", message: "Label deleted successfully" }),
|
|
9463
9461
|
},
|
|
9464
9462
|
],
|
|
9465
9463
|
};
|
|
@@ -9514,7 +9512,7 @@ async function handleToolCall(params) {
|
|
|
9514
9512
|
text: JSON.stringify({
|
|
9515
9513
|
status: "success",
|
|
9516
9514
|
message: "Wiki page deleted successfully",
|
|
9517
|
-
}
|
|
9515
|
+
}),
|
|
9518
9516
|
},
|
|
9519
9517
|
],
|
|
9520
9518
|
};
|
|
@@ -9567,7 +9565,7 @@ async function handleToolCall(params) {
|
|
|
9567
9565
|
text: JSON.stringify({
|
|
9568
9566
|
status: "success",
|
|
9569
9567
|
message: "Group wiki page deleted successfully",
|
|
9570
|
-
}
|
|
9568
|
+
}),
|
|
9571
9569
|
},
|
|
9572
9570
|
],
|
|
9573
9571
|
};
|
|
@@ -9992,7 +9990,7 @@ async function handleToolCall(params) {
|
|
|
9992
9990
|
topics: args.topics,
|
|
9993
9991
|
verificationLevel: args.verification_level,
|
|
9994
9992
|
});
|
|
9995
|
-
return { content: [{ type: "text", text: JSON.stringify(result
|
|
9993
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9996
9994
|
}
|
|
9997
9995
|
case "get_ci_catalog_resource": {
|
|
9998
9996
|
const args = GetCiCatalogResourceSchema.parse(params.arguments);
|
|
@@ -10064,7 +10062,7 @@ async function handleToolCall(params) {
|
|
|
10064
10062
|
}
|
|
10065
10063
|
}
|
|
10066
10064
|
}
|
|
10067
|
-
return { content: [{ type: "text", text: JSON.stringify(result
|
|
10065
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
10068
10066
|
}
|
|
10069
10067
|
case "create_pipeline": {
|
|
10070
10068
|
const { project_id, ref, variables, inputs } = CreatePipelineSchema.parse(params.arguments);
|
|
@@ -10107,14 +10105,14 @@ async function handleToolCall(params) {
|
|
|
10107
10105
|
const { project_id, ...options } = args;
|
|
10108
10106
|
const schedules = await listPipelineSchedules(project_id, options);
|
|
10109
10107
|
return {
|
|
10110
|
-
content: [{ type: "text", text: JSON.stringify(schedules
|
|
10108
|
+
content: [{ type: "text", text: JSON.stringify(schedules) }],
|
|
10111
10109
|
};
|
|
10112
10110
|
}
|
|
10113
10111
|
case "get_pipeline_schedule": {
|
|
10114
10112
|
const { project_id, pipeline_schedule_id } = GetPipelineScheduleSchema.parse(params.arguments);
|
|
10115
10113
|
const schedule = await getPipelineSchedule(project_id, pipeline_schedule_id);
|
|
10116
10114
|
return {
|
|
10117
|
-
content: [{ type: "text", text: JSON.stringify(schedule
|
|
10115
|
+
content: [{ type: "text", text: JSON.stringify(schedule) }],
|
|
10118
10116
|
};
|
|
10119
10117
|
}
|
|
10120
10118
|
case "list_pipeline_schedule_pipelines": {
|
|
@@ -10122,7 +10120,7 @@ async function handleToolCall(params) {
|
|
|
10122
10120
|
const { project_id, pipeline_schedule_id, ...options } = args;
|
|
10123
10121
|
const pipelines = await listPipelineSchedulePipelines(project_id, pipeline_schedule_id, options);
|
|
10124
10122
|
return {
|
|
10125
|
-
content: [{ type: "text", text: JSON.stringify(pipelines
|
|
10123
|
+
content: [{ type: "text", text: JSON.stringify(pipelines) }],
|
|
10126
10124
|
};
|
|
10127
10125
|
}
|
|
10128
10126
|
case "create_pipeline_schedule": {
|
|
@@ -10191,7 +10189,7 @@ async function handleToolCall(params) {
|
|
|
10191
10189
|
const { project_id, pipeline_schedule_id, key } = GetPipelineScheduleVariableSchema.parse(params.arguments);
|
|
10192
10190
|
const variable = await getPipelineScheduleVariable(project_id, pipeline_schedule_id, key);
|
|
10193
10191
|
return {
|
|
10194
|
-
content: [{ type: "text", text: JSON.stringify(variable
|
|
10192
|
+
content: [{ type: "text", text: JSON.stringify(variable) }],
|
|
10195
10193
|
};
|
|
10196
10194
|
}
|
|
10197
10195
|
case "create_pipeline_schedule_variable": {
|
|
@@ -10422,7 +10420,7 @@ async function handleToolCall(params) {
|
|
|
10422
10420
|
text: JSON.stringify({
|
|
10423
10421
|
status: "success",
|
|
10424
10422
|
message: "Milestone deleted successfully",
|
|
10425
|
-
}
|
|
10423
|
+
}),
|
|
10426
10424
|
},
|
|
10427
10425
|
],
|
|
10428
10426
|
};
|
|
@@ -10538,7 +10536,7 @@ async function handleToolCall(params) {
|
|
|
10538
10536
|
text: JSON.stringify({
|
|
10539
10537
|
status: "success",
|
|
10540
10538
|
message: "Group milestone deleted successfully",
|
|
10541
|
-
}
|
|
10539
|
+
}),
|
|
10542
10540
|
},
|
|
10543
10541
|
],
|
|
10544
10542
|
};
|
|
@@ -10666,7 +10664,10 @@ async function handleToolCall(params) {
|
|
|
10666
10664
|
content: [
|
|
10667
10665
|
{
|
|
10668
10666
|
type: "text",
|
|
10669
|
-
text: JSON.stringify({
|
|
10667
|
+
text: JSON.stringify({
|
|
10668
|
+
status: "success",
|
|
10669
|
+
message: `Variable '${args.key}' deleted from project`,
|
|
10670
|
+
}),
|
|
10670
10671
|
},
|
|
10671
10672
|
],
|
|
10672
10673
|
};
|
|
@@ -10706,7 +10707,10 @@ async function handleToolCall(params) {
|
|
|
10706
10707
|
content: [
|
|
10707
10708
|
{
|
|
10708
10709
|
type: "text",
|
|
10709
|
-
text: JSON.stringify({
|
|
10710
|
+
text: JSON.stringify({
|
|
10711
|
+
status: "success",
|
|
10712
|
+
message: `Variable '${args.key}' deleted from group`,
|
|
10713
|
+
}),
|
|
10710
10714
|
},
|
|
10711
10715
|
],
|
|
10712
10716
|
};
|
|
@@ -10745,7 +10749,10 @@ async function handleToolCall(params) {
|
|
|
10745
10749
|
content: [
|
|
10746
10750
|
{
|
|
10747
10751
|
type: "text",
|
|
10748
|
-
text: JSON.stringify({
|
|
10752
|
+
text: JSON.stringify({
|
|
10753
|
+
status: "success",
|
|
10754
|
+
message: "Dependency proxy cache purge scheduled",
|
|
10755
|
+
}),
|
|
10749
10756
|
},
|
|
10750
10757
|
],
|
|
10751
10758
|
};
|
|
@@ -10914,7 +10921,11 @@ async function handleToolCall(params) {
|
|
|
10914
10921
|
content: [
|
|
10915
10922
|
{
|
|
10916
10923
|
type: "text",
|
|
10917
|
-
text: JSON.stringify({
|
|
10924
|
+
text: JSON.stringify({
|
|
10925
|
+
status: "success",
|
|
10926
|
+
message: "Release deleted successfully",
|
|
10927
|
+
release,
|
|
10928
|
+
}),
|
|
10918
10929
|
},
|
|
10919
10930
|
],
|
|
10920
10931
|
};
|
|
@@ -10926,7 +10937,10 @@ async function handleToolCall(params) {
|
|
|
10926
10937
|
content: [
|
|
10927
10938
|
{
|
|
10928
10939
|
type: "text",
|
|
10929
|
-
text: JSON.stringify({
|
|
10940
|
+
text: JSON.stringify({
|
|
10941
|
+
status: "success",
|
|
10942
|
+
message: "Release evidence created successfully",
|
|
10943
|
+
}),
|
|
10930
10944
|
},
|
|
10931
10945
|
],
|
|
10932
10946
|
};
|
|
@@ -10986,7 +11000,10 @@ async function handleToolCall(params) {
|
|
|
10986
11000
|
content: [
|
|
10987
11001
|
{
|
|
10988
11002
|
type: "text",
|
|
10989
|
-
text: JSON.stringify({
|
|
11003
|
+
text: JSON.stringify({
|
|
11004
|
+
status: "success",
|
|
11005
|
+
message: `Tag '${args.tag_name}' deleted successfully`,
|
|
11006
|
+
}),
|
|
10990
11007
|
},
|
|
10991
11008
|
],
|
|
10992
11009
|
};
|
|
@@ -11074,7 +11091,7 @@ async function handleToolCall(params) {
|
|
|
11074
11091
|
const { project_id, ...options } = args;
|
|
11075
11092
|
const snippets = await listSnippets(project_id, options);
|
|
11076
11093
|
return {
|
|
11077
|
-
content: [{ type: "text", text: JSON.stringify(snippets
|
|
11094
|
+
content: [{ type: "text", text: JSON.stringify(snippets) }],
|
|
11078
11095
|
};
|
|
11079
11096
|
}
|
|
11080
11097
|
case "get_snippet": {
|
|
@@ -11111,7 +11128,7 @@ async function handleToolCall(params) {
|
|
|
11111
11128
|
}
|
|
11112
11129
|
}
|
|
11113
11130
|
return {
|
|
11114
|
-
content: [{ type: "text", text: JSON.stringify(result
|
|
11131
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
11115
11132
|
};
|
|
11116
11133
|
}
|
|
11117
11134
|
case "create_snippet": {
|
|
@@ -11119,7 +11136,7 @@ async function handleToolCall(params) {
|
|
|
11119
11136
|
const { project_id, ...options } = args;
|
|
11120
11137
|
const snippet = await createSnippet(project_id, options);
|
|
11121
11138
|
return {
|
|
11122
|
-
content: [{ type: "text", text: JSON.stringify(snippet
|
|
11139
|
+
content: [{ type: "text", text: JSON.stringify(snippet) }],
|
|
11123
11140
|
};
|
|
11124
11141
|
}
|
|
11125
11142
|
case "update_snippet": {
|
|
@@ -11127,7 +11144,7 @@ async function handleToolCall(params) {
|
|
|
11127
11144
|
const { project_id, ...options } = args;
|
|
11128
11145
|
const snippet = await updateSnippet(project_id, options);
|
|
11129
11146
|
return {
|
|
11130
|
-
content: [{ type: "text", text: JSON.stringify(snippet
|
|
11147
|
+
content: [{ type: "text", text: JSON.stringify(snippet) }],
|
|
11131
11148
|
};
|
|
11132
11149
|
}
|
|
11133
11150
|
case "delete_snippet": {
|
|
@@ -11140,7 +11157,7 @@ async function handleToolCall(params) {
|
|
|
11140
11157
|
text: JSON.stringify({
|
|
11141
11158
|
status: "success",
|
|
11142
11159
|
message: `Snippet ${args.snippet_id} deleted successfully`,
|
|
11143
|
-
}
|
|
11160
|
+
}),
|
|
11144
11161
|
},
|
|
11145
11162
|
],
|
|
11146
11163
|
};
|
|
@@ -11189,7 +11206,9 @@ async function handleToolCall(params) {
|
|
|
11189
11206
|
content: [
|
|
11190
11207
|
{
|
|
11191
11208
|
type: "text",
|
|
11192
|
-
text: JSON.stringify({
|
|
11209
|
+
text: JSON.stringify({
|
|
11210
|
+
error: `Webhook event ${args.event_id} not found ${searchScope}`,
|
|
11211
|
+
}),
|
|
11193
11212
|
},
|
|
11194
11213
|
],
|
|
11195
11214
|
};
|
|
@@ -11416,6 +11435,11 @@ async function handleToolCall(params) {
|
|
|
11416
11435
|
// ToolInputError is returned as an isError tool result, so the model can fix and retry.
|
|
11417
11436
|
throw new ToolInputError(`Invalid arguments for ${params.name}: ${detail}`);
|
|
11418
11437
|
}
|
|
11438
|
+
// undici says only "fetch failed"; name the reason (TLS, DNS, refused, ...).
|
|
11439
|
+
const transportMessage = describeFetchTransportError(error);
|
|
11440
|
+
if (transportMessage !== null) {
|
|
11441
|
+
throw new Error(transportMessage, { cause: error });
|
|
11442
|
+
}
|
|
11419
11443
|
throw error;
|
|
11420
11444
|
}
|
|
11421
11445
|
}
|
|
@@ -11457,6 +11481,7 @@ function buildDownloadProxyDeps() {
|
|
|
11457
11481
|
async function startSSEServer() {
|
|
11458
11482
|
const { express, rateLimit, ipKeyGenerator, SSEServerTransport } = await loadRemoteHttpStack();
|
|
11459
11483
|
const app = express();
|
|
11484
|
+
const sseJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT });
|
|
11460
11485
|
const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
|
|
11461
11486
|
if (MCP_TRUST_PROXY) {
|
|
11462
11487
|
app.set("trust proxy", 1);
|
|
@@ -11609,12 +11634,20 @@ async function startSSEServer() {
|
|
|
11609
11634
|
}
|
|
11610
11635
|
}
|
|
11611
11636
|
});
|
|
11612
|
-
|
|
11637
|
+
const requireSseSession = (req, res, next) => {
|
|
11638
|
+
const sessionId = req.query.sessionId;
|
|
11639
|
+
if (typeof sessionId !== "string" || !transports[sessionId]) {
|
|
11640
|
+
res.status(400).send("No transport found for sessionId");
|
|
11641
|
+
return;
|
|
11642
|
+
}
|
|
11643
|
+
next();
|
|
11644
|
+
};
|
|
11645
|
+
app.post("/messages", rejectDnsRebinding, requireSseAuth, requireSseSession, sseJsonBodyParser, async (req, res) => {
|
|
11613
11646
|
const sessionId = req.query.sessionId;
|
|
11614
11647
|
const transport = transports[sessionId];
|
|
11615
11648
|
if (transport) {
|
|
11616
11649
|
sessionLastActivity.set(sessionId, Date.now());
|
|
11617
|
-
await transport.handlePostMessage(req, res);
|
|
11650
|
+
await transport.handlePostMessage(req, res, req.body);
|
|
11618
11651
|
}
|
|
11619
11652
|
else {
|
|
11620
11653
|
res.status(400).send("No transport found for sessionId");
|
|
@@ -12187,7 +12220,7 @@ async function startStreamableHTTPServer() {
|
|
|
12187
12220
|
app.set("trust proxy", 1);
|
|
12188
12221
|
}
|
|
12189
12222
|
app.use("/mcp", requireMcpHostAndOrigin);
|
|
12190
|
-
|
|
12223
|
+
const mcpJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT });
|
|
12191
12224
|
registerDownloadProxy(app, buildDownloadProxyDeps());
|
|
12192
12225
|
const mcpRateLimitKeyGenerator = (req) => ipKeyGenerator(normalizeProxyClientIpForRateLimit(req.ip ?? ""));
|
|
12193
12226
|
const mcpRequestRateLimit = rateLimit({
|
|
@@ -12431,7 +12464,7 @@ async function startStreamableHTTPServer() {
|
|
|
12431
12464
|
next();
|
|
12432
12465
|
};
|
|
12433
12466
|
// Streamable HTTP endpoint - handles both session creation and message handling
|
|
12434
|
-
app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, async (req, res) => {
|
|
12467
|
+
app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, mcpJsonBodyParser, async (req, res) => {
|
|
12435
12468
|
const sessionId = readMcpSessionIdHeader(req);
|
|
12436
12469
|
const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
|
|
12437
12470
|
// Track request
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// npm ships build/ only, so the text is inlined.
|
|
2
|
+
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
const TEXT_MARKDOWN = "text/markdown";
|
|
4
|
+
const resources = [
|
|
5
|
+
{
|
|
6
|
+
uri: "gitlab-mcp://docs/auth-setup",
|
|
7
|
+
name: "auth-setup",
|
|
8
|
+
description: "Local token auth and browser OAuth.",
|
|
9
|
+
text: "A personal access token is the local default path. Set GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_API_URL. OAuth is the browser path. Enable it with GITLAB_USE_OAUTH and set GITLAB_OAUTH_CLIENT_ID. GITLAB_OAUTH_TOKEN_SCRIPT is a separate option: it runs a command that prints a token instead of opening a browser.",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
uri: "gitlab-mcp://docs/toolsets",
|
|
13
|
+
name: "toolsets",
|
|
14
|
+
description: "How the enabled toolset list is chosen.",
|
|
15
|
+
text: "When GITLAB_TOOLSETS is unset, the server uses the lean core toolset. GITLAB_TOOLSETS or --toolsets sets an explicit list. An explicit list replaces core. It does not merge with core.",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
uri: "gitlab-mcp://docs/permission-modes",
|
|
19
|
+
name: "permission-modes",
|
|
20
|
+
description: "How permission mode limits tool calls.",
|
|
21
|
+
text: "Set GITLAB_PERMISSION_MODE or pass --permission-mode. readonly blocks writes. modify blocks delete and teardown. full is the default.",
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
export function listMcpResources() {
|
|
25
|
+
return {
|
|
26
|
+
resources: resources.map(resource => ({
|
|
27
|
+
uri: resource.uri,
|
|
28
|
+
name: resource.name,
|
|
29
|
+
description: resource.description,
|
|
30
|
+
mimeType: TEXT_MARKDOWN,
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function listMcpResourceTemplates() {
|
|
35
|
+
return { resourceTemplates: [] };
|
|
36
|
+
}
|
|
37
|
+
export function readMcpResource(uri) {
|
|
38
|
+
const resource = resources.find(entry => entry.uri === uri);
|
|
39
|
+
if (resource === undefined) {
|
|
40
|
+
throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
contents: [
|
|
44
|
+
{
|
|
45
|
+
uri: resource.uri,
|
|
46
|
+
mimeType: TEXT_MARKDOWN,
|
|
47
|
+
text: resource.text,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* health_check must report these as a normal tool result: the caller is
|
|
4
4
|
* already asking whether the connection works, so a JSON-RPC -32603 hides the
|
|
5
5
|
* only signal they came for.
|
|
6
|
+
* Other tools keep the JSON-RPC error, but describeFetchTransportError puts the
|
|
7
|
+
* reason in its message.
|
|
6
8
|
*
|
|
7
9
|
* node-fetch puts the OS code on `code` / `errno`. undici leaves `code` empty
|
|
8
10
|
* on the TypeError and stores it on `cause.code` (errno there is numeric).
|
|
@@ -25,6 +27,14 @@ const TIMEOUT_ERROR_NAMES = new Set(["AbortError", "TimeoutError"]);
|
|
|
25
27
|
const REQUEST_TIMEOUT_TYPE = "request-timeout";
|
|
26
28
|
const TLS_CA_HINT = "Make sure NODE_EXTRA_CA_CERTS or GITLAB_CA_CERT_PATH contains the full certificate chain, including intermediate CAs.";
|
|
27
29
|
const FALLBACK_MESSAGE = "GitLab request failed before an HTTP response";
|
|
30
|
+
// undici rejects with exactly this TypeError message and keeps the reason on `cause`.
|
|
31
|
+
const UNDICI_FETCH_FAILED_MESSAGE = "fetch failed";
|
|
32
|
+
const TRANSPORT_HINTS = {
|
|
33
|
+
tls: TLS_CA_HINT,
|
|
34
|
+
dns: "Check the host in the GitLab API URL and the DNS or proxy settings.",
|
|
35
|
+
timeout: "Check that GitLab is reachable from this host and the proxy settings.",
|
|
36
|
+
network: "Check the GitLab API URL, that GitLab is reachable from this host, and the proxy settings.",
|
|
37
|
+
};
|
|
28
38
|
export function classifyHealthCheckTransportError(error) {
|
|
29
39
|
const code = readTransportCode(error);
|
|
30
40
|
const kind = classifyKind(error, code);
|
|
@@ -38,6 +48,20 @@ export function classifyHealthCheckTransportError(error) {
|
|
|
38
48
|
}
|
|
39
49
|
return failure;
|
|
40
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Message for a tool call whose fetch never got an HTTP response, e.g.
|
|
53
|
+
* "GitLab request failed before an HTTP response (ENOTFOUND): fetch failed:
|
|
54
|
+
* getaddrinfo ENOTFOUND gitlab.example.com. Check the host ...".
|
|
55
|
+
* Returns null for anything that is not undici's "fetch failed" TypeError.
|
|
56
|
+
*/
|
|
57
|
+
export function describeFetchTransportError(error) {
|
|
58
|
+
if (!(error instanceof TypeError) || error.message !== UNDICI_FETCH_FAILED_MESSAGE) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const failure = classifyHealthCheckTransportError(error);
|
|
62
|
+
const reason = failure.code ?? failure.kind;
|
|
63
|
+
return `${FALLBACK_MESSAGE} (${reason}): ${failure.message}. ${TRANSPORT_HINTS[failure.kind]}`;
|
|
64
|
+
}
|
|
41
65
|
function classifyKind(error, code) {
|
|
42
66
|
if (code !== null) {
|
|
43
67
|
return kindFromCode(code);
|
|
@@ -33,6 +33,9 @@ export function applyJmespathFilter(data, expression) {
|
|
|
33
33
|
function isContentBlock(value) {
|
|
34
34
|
return Boolean(value && typeof value === "object");
|
|
35
35
|
}
|
|
36
|
+
function isJsonObject(value) {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
36
39
|
function prepareDataForJmespath(data, maskValue) {
|
|
37
40
|
return maskValue ? maskValue(data) : data;
|
|
38
41
|
}
|
|
@@ -68,9 +71,15 @@ export function applyJmespathToToolResult(result, expression, options) {
|
|
|
68
71
|
return parsed.ok ? { ...block, text: JSON.stringify(filter(parsed.value)) } : block;
|
|
69
72
|
})
|
|
70
73
|
: result.content;
|
|
71
|
-
|
|
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.5-next.124",
|
|
4
4
|
"mcpName": "io.github.zereight/gitlab-mcp",
|
|
5
5
|
"description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"files": [
|
|
33
33
|
"build"
|
|
34
34
|
],
|
|
35
|
+
"homepage": "https://zereight.github.io/gitlab-mcp/",
|
|
35
36
|
"repository": {
|
|
36
37
|
"type": "git",
|
|
37
38
|
"url": "git+https://github.com/zereight/gitlab-mcp.git"
|