@zereight/mcp-gitlab 2.1.68 → 2.1.70
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 +11 -3
- package/README.md +237 -232
- package/README.pt-BR.md +8 -4
- package/README.zh-CN.md +7 -3
- package/build/cli/args.js +298 -0
- package/build/cli/compact-mcp-result.js +87 -0
- package/build/cli/curated.js +243 -0
- package/build/cli/exposure.js +72 -0
- package/build/cli/help.js +108 -0
- package/build/cli/output.js +65 -0
- package/build/cli/replay-command.js +53 -0
- package/build/cli/router.js +158 -0
- package/build/cli/tables.js +117 -0
- package/build/cli-boolean-flags.js +38 -0
- package/build/cli-command.js +14 -4
- package/build/config.js +30 -7
- package/build/index.js +176 -90
- package/build/oauth.js +93 -10
- package/build/scripts/generate-tool-docs.js +2 -1
- package/build/test/cli/args.test.js +175 -0
- package/build/test/cli/boolean-flags.test.js +106 -0
- package/build/test/cli/compact-mcp-result.test.js +93 -0
- package/build/test/cli/credentials.test.js +153 -0
- package/build/test/cli/exposure.test.js +104 -0
- package/build/test/cli/load-config-snapshot.js +5 -0
- package/build/test/cli/output.test.js +74 -0
- package/build/test/cli/replay-command.test.js +37 -0
- package/build/test/cli/router.test.js +183 -0
- package/build/test/gitlab-api-tool-errors.test.js +112 -0
- package/build/test/oauth-device-flow-tests.js +10 -0
- package/build/test/oauth-download-url.test.js +157 -0
- package/build/test/oauth-startup.test.js +204 -0
- package/build/test/test-dynamic-project-scope.js +2 -0
- package/build/test/test-geteffectiveprojectid.js +20 -26
- package/build/test/test-permission-mode.js +86 -0
- package/build/test/test-pipeline-schedules.js +3 -1
- package/build/test/utils/url.test.js +14 -1
- package/build/tools/denied-regex.js +38 -0
- package/build/utils/url.js +15 -0
- package/package.json +1 -1
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_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, 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_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } 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_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, 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_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, } from "./config.js";
|
|
3
3
|
/** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
|
|
4
4
|
const IS_REMOTE = SSE || STREAMABLE_HTTP;
|
|
5
5
|
const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STREAMABLE_HTTP_AUTH_TOKEN");
|
|
@@ -38,7 +38,8 @@ function buildDownloadUrl(type, params) {
|
|
|
38
38
|
header = "JOB-TOKEN";
|
|
39
39
|
headerValue = String(staticToken);
|
|
40
40
|
}
|
|
41
|
-
else if (IS_OLD) {
|
|
41
|
+
else if (IS_OLD && !OAUTH_ACCESS_TOKEN) {
|
|
42
|
+
// OAuth tokens always use Bearer (see buildAuthHeaders)
|
|
42
43
|
header = "Private-Token";
|
|
43
44
|
headerValue = String(staticToken);
|
|
44
45
|
}
|
|
@@ -74,9 +75,13 @@ import path from "node:path";
|
|
|
74
75
|
import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
75
76
|
import { URL } from "node:url";
|
|
76
77
|
import { z } from "zod";
|
|
77
|
-
import {
|
|
78
|
+
import { createGitLabOAuthClient, ensureOAuthToken } from "./oauth.js";
|
|
78
79
|
import { getPositionalCliCommand } from "./cli-command.js";
|
|
79
80
|
import { runAuthCommandAsync } from "./auth-cli.js";
|
|
81
|
+
import { isCliInvocation, resolveCli } from "./cli/router.js";
|
|
82
|
+
import { formatCliError, formatToolOutput } from "./cli/output.js";
|
|
83
|
+
import { compactMcpToolResult, readOwnRecord } from "./cli/compact-mcp-result.js";
|
|
84
|
+
import { compileDeniedToolsRegex } from "./tools/denied-regex.js";
|
|
80
85
|
import { createGitLabOAuthProvider } from "./oauth-proxy.js";
|
|
81
86
|
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
82
87
|
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
|
@@ -89,7 +94,7 @@ import { formatPrometheusMetrics } from "./server/metrics.js";
|
|
|
89
94
|
import { SERVER_VERSION } from "./server/version.js";
|
|
90
95
|
import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
|
|
91
96
|
export { readMcpSessionIdHeader } from "./server/request-helpers.js";
|
|
92
|
-
import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "./utils/url.js";
|
|
97
|
+
import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
|
|
93
98
|
import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
|
|
94
99
|
import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
|
|
95
100
|
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
@@ -179,6 +184,7 @@ const MCP_PROMPTS = [
|
|
|
179
184
|
* Each transport connection gets its own Server instance to prevent
|
|
180
185
|
* cross-client data leakage (GHSA-345p-7cg4-v4c7).
|
|
181
186
|
*/
|
|
187
|
+
/** Builds an MCP server with the tool list filtered by toolsets, permission mode, and policy. */
|
|
182
188
|
function createServer() {
|
|
183
189
|
// Precompute filtered tool list once at server creation (Steps 1–5 are static)
|
|
184
190
|
// Step 1: Toolset filter — keep tools in enabled toolsets
|
|
@@ -329,11 +335,18 @@ function createServer() {
|
|
|
329
335
|
? (value) => maskingEngine.maskValue(value)
|
|
330
336
|
: undefined,
|
|
331
337
|
});
|
|
332
|
-
|
|
338
|
+
const masked = maskingEngine
|
|
333
339
|
? maskingEngine.maskToolResult(filtered, {
|
|
334
340
|
textFormat: isPlainTextResult ? "plain" : "json",
|
|
335
341
|
})
|
|
336
342
|
: filtered;
|
|
343
|
+
return compactMcpToolResult({
|
|
344
|
+
result: masked,
|
|
345
|
+
enabled: GITLAB_MCP_COMPACT_RESULTS,
|
|
346
|
+
maxChars: GITLAB_MCP_COMPACT_RESULT_CHARS,
|
|
347
|
+
toolName,
|
|
348
|
+
args: readOwnRecord(request.params.arguments),
|
|
349
|
+
});
|
|
337
350
|
};
|
|
338
351
|
const logError = (error) => {
|
|
339
352
|
const durationMs = Date.now() - start;
|
|
@@ -344,7 +357,7 @@ function createServer() {
|
|
|
344
357
|
durationMs,
|
|
345
358
|
error: safeError instanceof Error ? safeError.message : String(safeError),
|
|
346
359
|
}, `tool_call_error: ${toolName} (${durationMs}ms)`);
|
|
347
|
-
|
|
360
|
+
return safeError;
|
|
348
361
|
};
|
|
349
362
|
try {
|
|
350
363
|
const authData = (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId
|
|
@@ -398,6 +411,16 @@ function createServer() {
|
|
|
398
411
|
if (jmespathSyntaxError) {
|
|
399
412
|
return logCompletion({ content: [{ type: "text", text: jmespathSyntaxError }], isError: true }, { skipJmespath: true });
|
|
400
413
|
}
|
|
414
|
+
// Enforce list-time filtering on direct calls: clients can call names that tools/list omits.
|
|
415
|
+
// filteredTools is extended by discover_tools; hidden-policy tools stay callable by design,
|
|
416
|
+
// but GITLAB_DENIED_TOOLS_REGEX always wins over the hidden exemption.
|
|
417
|
+
// Permission-mode denials fall through so handleToolCall reports its mode-specific error.
|
|
418
|
+
const hiddenAndNotDenied = hiddenToolSet.has(toolName) && !GITLAB_DENIED_TOOLS_REGEX?.test(toolName);
|
|
419
|
+
if (isToolAllowedByPermissionMode(toolName) &&
|
|
420
|
+
!hiddenAndNotDenied &&
|
|
421
|
+
!filteredTools.some(t => t.name === toolName)) {
|
|
422
|
+
throw new Error(`Tool "${toolName}" is not available on this server`);
|
|
423
|
+
}
|
|
401
424
|
// Handle discover_tools meta-tool directly (needs access to mcpServer and filteredTools)
|
|
402
425
|
if (toolName === "discover_tools") {
|
|
403
426
|
const category = request.params.arguments?.category?.trim()?.toLowerCase();
|
|
@@ -525,7 +548,20 @@ function createServer() {
|
|
|
525
548
|
return logCompletion(result);
|
|
526
549
|
}
|
|
527
550
|
catch (error) {
|
|
528
|
-
|
|
551
|
+
const isGitLabApiError = error instanceof GitLabApiError;
|
|
552
|
+
const safeError = logError(error);
|
|
553
|
+
if (isGitLabApiError) {
|
|
554
|
+
return {
|
|
555
|
+
content: [
|
|
556
|
+
{
|
|
557
|
+
type: "text",
|
|
558
|
+
text: safeError instanceof Error ? safeError.message : String(safeError),
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
isError: true,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
throw safeError;
|
|
529
565
|
}
|
|
530
566
|
});
|
|
531
567
|
return mcpServer;
|
|
@@ -846,15 +882,10 @@ export function hasStatelessSessionId(req) {
|
|
|
846
882
|
* This avoids background timers that cause issues with multiple instances.
|
|
847
883
|
*/
|
|
848
884
|
async function ensureValidOAuthToken() {
|
|
849
|
-
if (!oauthClient)
|
|
850
|
-
return;
|
|
851
|
-
if (oauthClient.hasValidToken())
|
|
852
|
-
return;
|
|
853
885
|
try {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
logger.info("OAuth token refreshed successfully");
|
|
886
|
+
await ensureOAuthToken(oauthClient, OAUTH_ACCESS_TOKEN, token => {
|
|
887
|
+
OAUTH_ACCESS_TOKEN = token;
|
|
888
|
+
});
|
|
858
889
|
}
|
|
859
890
|
catch (error) {
|
|
860
891
|
logger.error({ err: error }, "Failed to refresh OAuth token");
|
|
@@ -873,33 +904,11 @@ function isToolAllowedByPermissionMode(toolName) {
|
|
|
873
904
|
}
|
|
874
905
|
}
|
|
875
906
|
const GITLAB_DENIED_TOOLS_REGEX = (() => {
|
|
876
|
-
const
|
|
877
|
-
if (
|
|
878
|
-
|
|
879
|
-
// Reject patterns that are too long (potential ReDoS vector)
|
|
880
|
-
const MAX_PATTERN_LENGTH = 200;
|
|
881
|
-
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
882
|
-
logger.error(`GITLAB_DENIED_TOOLS_REGEX pattern exceeds ${MAX_PATTERN_LENGTH} chars. Ignoring.`);
|
|
883
|
-
return undefined;
|
|
884
|
-
}
|
|
885
|
-
// Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
|
|
886
|
-
// e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
|
|
887
|
-
// Note: lookahead (?!), (?=), lookbehind (?<), and named groups (?<name>) are safe and allowed
|
|
888
|
-
const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]/;
|
|
889
|
-
if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
|
|
890
|
-
logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
|
|
891
|
-
return undefined;
|
|
892
|
-
}
|
|
893
|
-
try {
|
|
894
|
-
const regex = new RegExp(pattern);
|
|
895
|
-
// Dry-run against a sample string to catch immediate issues
|
|
896
|
-
regex.test("sample_tool_name");
|
|
897
|
-
return regex;
|
|
898
|
-
}
|
|
899
|
-
catch {
|
|
900
|
-
logger.error(`Invalid GITLAB_DENIED_TOOLS_REGEX pattern: "${pattern}". Ignoring.`);
|
|
901
|
-
return undefined;
|
|
907
|
+
const compiled = compileDeniedToolsRegex(getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX"));
|
|
908
|
+
if (compiled.error) {
|
|
909
|
+
logger.error(compiled.error);
|
|
902
910
|
}
|
|
911
|
+
return compiled.regex;
|
|
903
912
|
})();
|
|
904
913
|
// ---------------------------------------------------------------------------
|
|
905
914
|
// Tool policy: approve / hidden sets
|
|
@@ -1101,12 +1110,15 @@ function buildAuthHeaders() {
|
|
|
1101
1110
|
// OAuth token takes priority over PAT when both are set.
|
|
1102
1111
|
// NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
|
|
1103
1112
|
// If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins.
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1113
|
+
// OAuth tokens always travel as `Authorization: Bearer`, even with GITLAB_IS_OLD: the HTTP
|
|
1114
|
+
// client strips `Authorization` on cross-origin redirects but not `Private-Token`.
|
|
1115
|
+
if (OAUTH_ACCESS_TOKEN) {
|
|
1116
|
+
return { Authorization: `Bearer ${OAUTH_ACCESS_TOKEN}` };
|
|
1107
1117
|
}
|
|
1108
|
-
if (
|
|
1109
|
-
return
|
|
1118
|
+
if (GITLAB_PERSONAL_ACCESS_TOKEN) {
|
|
1119
|
+
return IS_OLD
|
|
1120
|
+
? { "Private-Token": String(GITLAB_PERSONAL_ACCESS_TOKEN) }
|
|
1121
|
+
: { Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}` };
|
|
1110
1122
|
}
|
|
1111
1123
|
// Fall back to CI job token
|
|
1112
1124
|
if (GITLAB_JOB_TOKEN) {
|
|
@@ -1317,7 +1329,7 @@ if (GITLAB_MCP_OAUTH) {
|
|
|
1317
1329
|
}
|
|
1318
1330
|
logger.info("MCP OAuth enabled: GitLab OAuth proxy active (Private-Token/JOB-TOKEN headers bypass OAuth)");
|
|
1319
1331
|
}
|
|
1320
|
-
if (
|
|
1332
|
+
if (!isCliInvocation(process.argv) &&
|
|
1321
1333
|
!REMOTE_AUTHORIZATION &&
|
|
1322
1334
|
!GITLAB_MCP_OAUTH &&
|
|
1323
1335
|
!USE_OAUTH &&
|
|
@@ -1329,6 +1341,12 @@ if (getPositionalCliCommand(process.argv) !== "auth" &&
|
|
|
1329
1341
|
logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
|
|
1330
1342
|
process.exit(1);
|
|
1331
1343
|
}
|
|
1344
|
+
class GitLabApiError extends Error {
|
|
1345
|
+
constructor(message) {
|
|
1346
|
+
super(message);
|
|
1347
|
+
this.name = "GitLabApiError";
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1332
1350
|
/**
|
|
1333
1351
|
* Utility function for handling GitLab API errors
|
|
1334
1352
|
* API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
|
|
@@ -1345,12 +1363,12 @@ async function handleGitLabError(response) {
|
|
|
1345
1363
|
// before the common tool-call boundary returns/logs it.
|
|
1346
1364
|
logger.error("GitLab API Rate Limit Exceeded");
|
|
1347
1365
|
logger.error("User API Key Rate limit exceeded. Please try again later.");
|
|
1348
|
-
const error = new
|
|
1366
|
+
const error = new GitLabApiError(`GitLab API Rate Limit Exceeded: ${errorBody}`);
|
|
1349
1367
|
throw error;
|
|
1350
1368
|
}
|
|
1351
1369
|
else {
|
|
1352
1370
|
// Handle other API errors
|
|
1353
|
-
const error = new
|
|
1371
|
+
const error = new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
1354
1372
|
throw error;
|
|
1355
1373
|
}
|
|
1356
1374
|
}
|
|
@@ -1415,7 +1433,7 @@ async function forkProject(projectId, namespace) {
|
|
|
1415
1433
|
});
|
|
1416
1434
|
// Handle case where project already exists
|
|
1417
1435
|
if (response.status === 409) {
|
|
1418
|
-
throw new
|
|
1436
|
+
throw new GitLabApiError("Project already exists in the target namespace");
|
|
1419
1437
|
}
|
|
1420
1438
|
await handleGitLabError(response);
|
|
1421
1439
|
const data = await response.json();
|
|
@@ -1486,7 +1504,7 @@ async function getFileContents(projectId, filePath, ref) {
|
|
|
1486
1504
|
});
|
|
1487
1505
|
// Handle file not found
|
|
1488
1506
|
if (response.status === 404) {
|
|
1489
|
-
throw new
|
|
1507
|
+
throw new GitLabApiError(`File not found: ${filePath}`);
|
|
1490
1508
|
}
|
|
1491
1509
|
await handleGitLabError(response);
|
|
1492
1510
|
const data = await response.json();
|
|
@@ -1519,7 +1537,7 @@ async function createIssue(projectId, options) {
|
|
|
1519
1537
|
// Handle bad request
|
|
1520
1538
|
if (response.status === 400) {
|
|
1521
1539
|
const errorBody = await response.text();
|
|
1522
|
-
throw new
|
|
1540
|
+
throw new GitLabApiError(`Invalid request: ${errorBody}`);
|
|
1523
1541
|
}
|
|
1524
1542
|
await handleGitLabError(response);
|
|
1525
1543
|
const data = await response.json();
|
|
@@ -1755,7 +1773,7 @@ async function executeGraphQL(query, variables = {}) {
|
|
|
1755
1773
|
});
|
|
1756
1774
|
if (!response.ok) {
|
|
1757
1775
|
const errorBody = await response.text();
|
|
1758
|
-
throw new
|
|
1776
|
+
throw new GitLabApiError(`GraphQL request failed (${response.status}): ${errorBody}`);
|
|
1759
1777
|
}
|
|
1760
1778
|
const json = await response.json();
|
|
1761
1779
|
if (json.errors && json.errors.length > 0) {
|
|
@@ -3204,11 +3222,11 @@ async function createMergeRequest(projectId, options) {
|
|
|
3204
3222
|
});
|
|
3205
3223
|
if (response.status === 400) {
|
|
3206
3224
|
const errorBody = await response.text();
|
|
3207
|
-
throw new
|
|
3225
|
+
throw new GitLabApiError(`Invalid request: ${errorBody}`);
|
|
3208
3226
|
}
|
|
3209
3227
|
if (!response.ok) {
|
|
3210
3228
|
const errorBody = await response.text();
|
|
3211
|
-
throw new
|
|
3229
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3212
3230
|
}
|
|
3213
3231
|
const data = await response.json();
|
|
3214
3232
|
return GitLabMergeRequestSchema.parse(data);
|
|
@@ -3296,7 +3314,7 @@ async function deleteMergeRequestDiscussionNote(projectId, mergeRequestIid, disc
|
|
|
3296
3314
|
});
|
|
3297
3315
|
if (!response.ok) {
|
|
3298
3316
|
const errorText = await response.text();
|
|
3299
|
-
throw new
|
|
3317
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
3300
3318
|
}
|
|
3301
3319
|
}
|
|
3302
3320
|
/**
|
|
@@ -3456,7 +3474,7 @@ async function deleteMergeRequestNote(projectId, mergeRequestIid, noteId) {
|
|
|
3456
3474
|
});
|
|
3457
3475
|
if (!response.ok) {
|
|
3458
3476
|
const errorText = await response.text();
|
|
3459
|
-
throw new
|
|
3477
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
3460
3478
|
}
|
|
3461
3479
|
}
|
|
3462
3480
|
// --- Emoji Reactions (REST) ---
|
|
@@ -3627,7 +3645,7 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
|
|
|
3627
3645
|
});
|
|
3628
3646
|
if (!response.ok) {
|
|
3629
3647
|
const errorBody = await response.text();
|
|
3630
|
-
throw new
|
|
3648
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3631
3649
|
}
|
|
3632
3650
|
const data = await response.json();
|
|
3633
3651
|
return GitLabCreateUpdateFileResponseSchema.parse(data);
|
|
@@ -3656,11 +3674,11 @@ async function createCommit(projectId, message, branch, actions) {
|
|
|
3656
3674
|
});
|
|
3657
3675
|
if (response.status === 400) {
|
|
3658
3676
|
const errorBody = await response.text();
|
|
3659
|
-
throw new
|
|
3677
|
+
throw new GitLabApiError(`Invalid request: ${errorBody}`);
|
|
3660
3678
|
}
|
|
3661
3679
|
if (!response.ok) {
|
|
3662
3680
|
const errorBody = await response.text();
|
|
3663
|
-
throw new
|
|
3681
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3664
3682
|
}
|
|
3665
3683
|
const data = await response.json();
|
|
3666
3684
|
return GitLabCommitSchema.parse(data);
|
|
@@ -3686,7 +3704,7 @@ async function searchProjects(query, page = 1, perPage = 20) {
|
|
|
3686
3704
|
});
|
|
3687
3705
|
if (!response.ok) {
|
|
3688
3706
|
const errorBody = await response.text();
|
|
3689
|
-
throw new
|
|
3707
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3690
3708
|
}
|
|
3691
3709
|
const unfiltered = (await response.json());
|
|
3692
3710
|
const projects = filterProjectsByAllowlist(unfiltered);
|
|
@@ -3773,7 +3791,7 @@ async function createRepository(options) {
|
|
|
3773
3791
|
});
|
|
3774
3792
|
if (!response.ok) {
|
|
3775
3793
|
const errorBody = await response.text();
|
|
3776
|
-
throw new
|
|
3794
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3777
3795
|
}
|
|
3778
3796
|
const data = await response.json();
|
|
3779
3797
|
return GitLabRepositorySchema.parse(data);
|
|
@@ -4199,7 +4217,7 @@ async function getBranchDiffs(projectId, from, to, straight) {
|
|
|
4199
4217
|
});
|
|
4200
4218
|
if (!response.ok) {
|
|
4201
4219
|
const errorBody = await response.text();
|
|
4202
|
-
throw new
|
|
4220
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
4203
4221
|
}
|
|
4204
4222
|
const data = await response.json();
|
|
4205
4223
|
return GitLabCompareResultSchema.parse(data);
|
|
@@ -4432,7 +4450,7 @@ noteableIid, body) {
|
|
|
4432
4450
|
});
|
|
4433
4451
|
if (!response.ok) {
|
|
4434
4452
|
const errorText = await response.text();
|
|
4435
|
-
throw new
|
|
4453
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4436
4454
|
}
|
|
4437
4455
|
return await response.json();
|
|
4438
4456
|
}
|
|
@@ -4447,7 +4465,7 @@ async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
|
|
|
4447
4465
|
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/merge_requests/${encodeGitLabPathSegment(merge_request_iid)}/draft_notes/${encodeGitLabPathSegment(draft_note_id)}`, { ...getFetchConfig() });
|
|
4448
4466
|
if (!response.ok) {
|
|
4449
4467
|
const errorText = await response.text();
|
|
4450
|
-
throw new
|
|
4468
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4451
4469
|
}
|
|
4452
4470
|
const data = await response.json();
|
|
4453
4471
|
return GitLabDraftNoteSchema.parse(data);
|
|
@@ -4461,7 +4479,7 @@ async function listDraftNotes(projectId, mergeRequestIid) {
|
|
|
4461
4479
|
});
|
|
4462
4480
|
if (!response.ok) {
|
|
4463
4481
|
const errorText = await response.text();
|
|
4464
|
-
throw new
|
|
4482
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4465
4483
|
}
|
|
4466
4484
|
const data = await response.json();
|
|
4467
4485
|
return z.array(GitLabDraftNoteSchema).parse(data);
|
|
@@ -4496,7 +4514,7 @@ async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscus
|
|
|
4496
4514
|
});
|
|
4497
4515
|
if (!response.ok) {
|
|
4498
4516
|
const errorText = await response.text();
|
|
4499
|
-
throw new
|
|
4517
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4500
4518
|
}
|
|
4501
4519
|
const data = await response.json();
|
|
4502
4520
|
return GitLabDraftNoteSchema.parse(data);
|
|
@@ -4531,7 +4549,7 @@ async function updateDraftNote(projectId, mergeRequestIid, draftNoteId, body, po
|
|
|
4531
4549
|
});
|
|
4532
4550
|
if (!response.ok) {
|
|
4533
4551
|
const errorText = await response.text();
|
|
4534
|
-
throw new
|
|
4552
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4535
4553
|
}
|
|
4536
4554
|
const data = await response.json();
|
|
4537
4555
|
return GitLabDraftNoteSchema.parse(data);
|
|
@@ -4552,7 +4570,7 @@ async function deleteDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
4552
4570
|
});
|
|
4553
4571
|
if (!response.ok) {
|
|
4554
4572
|
const errorText = await response.text();
|
|
4555
|
-
throw new
|
|
4573
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4556
4574
|
}
|
|
4557
4575
|
}
|
|
4558
4576
|
/**
|
|
@@ -4571,7 +4589,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
4571
4589
|
});
|
|
4572
4590
|
if (!response.ok) {
|
|
4573
4591
|
const errorText = await response.text();
|
|
4574
|
-
throw new
|
|
4592
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4575
4593
|
}
|
|
4576
4594
|
// Handle empty response (204 No Content) or successful response
|
|
4577
4595
|
const responseText = await response.text();
|
|
@@ -4652,7 +4670,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid, options = {}) {
|
|
|
4652
4670
|
});
|
|
4653
4671
|
if (!response.ok) {
|
|
4654
4672
|
const errorText = await response.text();
|
|
4655
|
-
throw new
|
|
4673
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4656
4674
|
}
|
|
4657
4675
|
// Handle empty response (204 No Content) or successful response
|
|
4658
4676
|
const responseText = await response.text();
|
|
@@ -4683,7 +4701,7 @@ async function resolveMergeRequestThread(projectId, mergeRequestIid, discussionI
|
|
|
4683
4701
|
});
|
|
4684
4702
|
if (!response.ok) {
|
|
4685
4703
|
const errorText = await response.text();
|
|
4686
|
-
throw new
|
|
4704
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4687
4705
|
}
|
|
4688
4706
|
}
|
|
4689
4707
|
/**
|
|
@@ -5275,7 +5293,7 @@ async function getPipeline(projectId, pipelineId, signal) {
|
|
|
5275
5293
|
signal,
|
|
5276
5294
|
});
|
|
5277
5295
|
if (response.status === 404) {
|
|
5278
|
-
throw new
|
|
5296
|
+
throw new GitLabApiError(`Pipeline not found`);
|
|
5279
5297
|
}
|
|
5280
5298
|
await handleGitLabError(response);
|
|
5281
5299
|
const data = await response.json();
|
|
@@ -5348,7 +5366,7 @@ async function getDeployment(projectId, deploymentId) {
|
|
|
5348
5366
|
...getFetchConfig(),
|
|
5349
5367
|
});
|
|
5350
5368
|
if (response.status === 404) {
|
|
5351
|
-
throw new
|
|
5369
|
+
throw new GitLabApiError(`Deployment not found`);
|
|
5352
5370
|
}
|
|
5353
5371
|
await handleGitLabError(response);
|
|
5354
5372
|
const data = await response.json();
|
|
@@ -5400,7 +5418,7 @@ async function getEnvironment(projectId, environmentId) {
|
|
|
5400
5418
|
...getFetchConfig(),
|
|
5401
5419
|
});
|
|
5402
5420
|
if (response.status === 404) {
|
|
5403
|
-
throw new
|
|
5421
|
+
throw new GitLabApiError(`Environment not found`);
|
|
5404
5422
|
}
|
|
5405
5423
|
await handleGitLabError(response);
|
|
5406
5424
|
const data = await response.json();
|
|
@@ -5453,7 +5471,7 @@ async function listPipelineJobs(projectId, pipelineId, options = {}) {
|
|
|
5453
5471
|
...getFetchConfig(),
|
|
5454
5472
|
});
|
|
5455
5473
|
if (response.status === 404) {
|
|
5456
|
-
throw new
|
|
5474
|
+
throw new GitLabApiError(`Pipeline not found`);
|
|
5457
5475
|
}
|
|
5458
5476
|
await handleGitLabError(response);
|
|
5459
5477
|
const data = await response.json();
|
|
@@ -5476,7 +5494,7 @@ async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
|
|
|
5476
5494
|
...getFetchConfig(),
|
|
5477
5495
|
});
|
|
5478
5496
|
if (response.status === 404) {
|
|
5479
|
-
throw new
|
|
5497
|
+
throw new GitLabApiError(`Pipeline not found`);
|
|
5480
5498
|
}
|
|
5481
5499
|
await handleGitLabError(response);
|
|
5482
5500
|
const data = await response.json();
|
|
@@ -5490,7 +5508,7 @@ async function getPipelineJob(projectId, jobId, signal) {
|
|
|
5490
5508
|
signal,
|
|
5491
5509
|
});
|
|
5492
5510
|
if (response.status === 404) {
|
|
5493
|
-
throw new
|
|
5511
|
+
throw new GitLabApiError(`Job not found`);
|
|
5494
5512
|
}
|
|
5495
5513
|
await handleGitLabError(response);
|
|
5496
5514
|
const data = await response.json();
|
|
@@ -5518,7 +5536,7 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
|
|
|
5518
5536
|
},
|
|
5519
5537
|
});
|
|
5520
5538
|
if (response.status === 404) {
|
|
5521
|
-
throw new
|
|
5539
|
+
throw new GitLabApiError(`Job trace not found or job is not finished yet`);
|
|
5522
5540
|
}
|
|
5523
5541
|
await handleGitLabError(response);
|
|
5524
5542
|
const fullTrace = await response.text();
|
|
@@ -5580,7 +5598,7 @@ async function listJobArtifacts(projectId, jobId, options = {}) {
|
|
|
5580
5598
|
...getFetchConfig(),
|
|
5581
5599
|
});
|
|
5582
5600
|
if (response.status === 404) {
|
|
5583
|
-
throw new
|
|
5601
|
+
throw new GitLabApiError(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
|
|
5584
5602
|
}
|
|
5585
5603
|
await handleGitLabError(response);
|
|
5586
5604
|
const data = await response.json();
|
|
@@ -5600,7 +5618,7 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
|
|
|
5600
5618
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts`);
|
|
5601
5619
|
const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
|
|
5602
5620
|
if (response.status === 404) {
|
|
5603
|
-
throw new
|
|
5621
|
+
throw new GitLabApiError(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
|
|
5604
5622
|
}
|
|
5605
5623
|
await handleGitLabError(response);
|
|
5606
5624
|
const filename = `artifacts_job_${encodeGitLabPathSegment(jobId)}.zip`;
|
|
@@ -5626,7 +5644,7 @@ async function getJobArtifactFile(projectId, jobId, artifactPath) {
|
|
|
5626
5644
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`);
|
|
5627
5645
|
const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
|
|
5628
5646
|
if (response.status === 404) {
|
|
5629
|
-
throw new
|
|
5647
|
+
throw new GitLabApiError(`Artifact file not found: ${artifactPath}`);
|
|
5630
5648
|
}
|
|
5631
5649
|
await handleGitLabError(response);
|
|
5632
5650
|
return await response.text();
|
|
@@ -6042,10 +6060,10 @@ async function getRepositoryTree(options) {
|
|
|
6042
6060
|
queryParams.append("pagination", options.pagination);
|
|
6043
6061
|
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
|
|
6044
6062
|
if (response.status === 404) {
|
|
6045
|
-
throw new
|
|
6063
|
+
throw new GitLabApiError("Repository or path not found");
|
|
6046
6064
|
}
|
|
6047
6065
|
if (!response.ok) {
|
|
6048
|
-
throw new
|
|
6066
|
+
throw new GitLabApiError(`Failed to get repository tree: ${response.statusText}`);
|
|
6049
6067
|
}
|
|
6050
6068
|
const data = await response.json();
|
|
6051
6069
|
const items = z.array(GitLabTreeItemSchema).parse(data);
|
|
@@ -11991,6 +12009,22 @@ async function initializeServerByTransportMode(mode) {
|
|
|
11991
12009
|
}
|
|
11992
12010
|
}
|
|
11993
12011
|
}
|
|
12012
|
+
/**
|
|
12013
|
+
* Construct the OAuth client when OAuth is enabled. No network: the token is
|
|
12014
|
+
* acquired lazily on the first tool call via ensureValidOAuthToken, which
|
|
12015
|
+
* avoids blocking startup and opening a browser on transient boot-time errors.
|
|
12016
|
+
* Shared by the MCP server and the human CLI.
|
|
12017
|
+
*/
|
|
12018
|
+
function initOAuthClient() {
|
|
12019
|
+
if (!USE_OAUTH) {
|
|
12020
|
+
return;
|
|
12021
|
+
}
|
|
12022
|
+
if (isCleartextRemoteUrl(GITLAB_API_URL)) {
|
|
12023
|
+
throw new Error(`Refusing to send OAuth tokens over cleartext HTTP to ${GITLAB_API_URL}. ` +
|
|
12024
|
+
"Use an https:// GITLAB_API_URL (http:// is only allowed for localhost).");
|
|
12025
|
+
}
|
|
12026
|
+
oauthClient = createGitLabOAuthClient(GITLAB_API_URL.replace(/\/api\/v4$/, ""));
|
|
12027
|
+
}
|
|
11994
12028
|
/**
|
|
11995
12029
|
* Initialize and run the server
|
|
11996
12030
|
* Main entry point for server startup
|
|
@@ -12003,11 +12037,8 @@ async function runServer() {
|
|
|
12003
12037
|
if (USE_OAUTH) {
|
|
12004
12038
|
logger.info("Using OAuth authentication...");
|
|
12005
12039
|
try {
|
|
12006
|
-
|
|
12007
|
-
|
|
12008
|
-
oauthClient = oauthResult.client;
|
|
12009
|
-
OAUTH_ACCESS_TOKEN = oauthResult.accessToken;
|
|
12010
|
-
logger.info("OAuth authentication successful");
|
|
12040
|
+
initOAuthClient();
|
|
12041
|
+
logger.info("OAuth enabled; token acquired lazily on first use.");
|
|
12011
12042
|
}
|
|
12012
12043
|
catch (error) {
|
|
12013
12044
|
logger.error({ err: error }, "OAuth authentication failed");
|
|
@@ -12049,7 +12080,16 @@ async function runServer() {
|
|
|
12049
12080
|
}
|
|
12050
12081
|
}
|
|
12051
12082
|
async function main() {
|
|
12052
|
-
|
|
12083
|
+
const cli = resolveCli(process.argv);
|
|
12084
|
+
if (cli.kind === "help") {
|
|
12085
|
+
process.stdout.write(cli.text);
|
|
12086
|
+
process.exit(0);
|
|
12087
|
+
}
|
|
12088
|
+
if (cli.kind === "usage" || cli.kind === "refused") {
|
|
12089
|
+
process.stderr.write(`${cli.message}\n`);
|
|
12090
|
+
process.exit(2);
|
|
12091
|
+
}
|
|
12092
|
+
if (cli.kind === "auth" || getPositionalCliCommand(process.argv) === "auth") {
|
|
12053
12093
|
try {
|
|
12054
12094
|
await runAuthCommandAsync();
|
|
12055
12095
|
process.exit(0);
|
|
@@ -12061,8 +12101,54 @@ async function main() {
|
|
|
12061
12101
|
process.exit(1);
|
|
12062
12102
|
}
|
|
12063
12103
|
}
|
|
12104
|
+
if (cli.kind === "run") {
|
|
12105
|
+
if (!hasCliCredentials()) {
|
|
12106
|
+
process.stderr.write("Missing GitLab credentials. Set --token or GITLAB_PERSONAL_ACCESS_TOKEN, or run `auth` with GITLAB_USE_OAUTH=true.\n");
|
|
12107
|
+
process.exit(2);
|
|
12108
|
+
}
|
|
12109
|
+
try {
|
|
12110
|
+
initOAuthClient();
|
|
12111
|
+
const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
|
|
12112
|
+
const masked = maskCliToolResult(cli.toolName, result);
|
|
12113
|
+
const formatted = formatToolOutput({
|
|
12114
|
+
result: masked,
|
|
12115
|
+
mode: cli.output,
|
|
12116
|
+
tableSpec: cli.tableSpec,
|
|
12117
|
+
});
|
|
12118
|
+
if (formatted.stderr) {
|
|
12119
|
+
process.stderr.write(formatted.stderr);
|
|
12120
|
+
}
|
|
12121
|
+
process.stdout.write(formatted.stdout);
|
|
12122
|
+
process.exit(formatted.isError ? 1 : 0);
|
|
12123
|
+
}
|
|
12124
|
+
catch (error) {
|
|
12125
|
+
process.stderr.write(formatCliError(error));
|
|
12126
|
+
logger.error({ err: error }, "cli command failed");
|
|
12127
|
+
process.exit(1);
|
|
12128
|
+
}
|
|
12129
|
+
}
|
|
12064
12130
|
await runServer();
|
|
12065
12131
|
}
|
|
12132
|
+
function hasCliCredentials() {
|
|
12133
|
+
return Boolean(GITLAB_PERSONAL_ACCESS_TOKEN || GITLAB_JOB_TOKEN || GITLAB_AUTH_COOKIE_PATH || USE_OAUTH);
|
|
12134
|
+
}
|
|
12135
|
+
function isRecord(value) {
|
|
12136
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12137
|
+
}
|
|
12138
|
+
function maskCliToolResult(toolName, result) {
|
|
12139
|
+
const gitlabInstance = getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com";
|
|
12140
|
+
const engine = maskingPolicyResolver?.select({
|
|
12141
|
+
gitlabInstance,
|
|
12142
|
+
allowBuiltinForUnscoped: true,
|
|
12143
|
+
});
|
|
12144
|
+
if (!engine || !isRecord(result)) {
|
|
12145
|
+
return result;
|
|
12146
|
+
}
|
|
12147
|
+
const isPlainText = toolName === "get_pipeline_job_output" ||
|
|
12148
|
+
toolName === "get_job_artifact_file" ||
|
|
12149
|
+
(toolName === "download_release_asset" && !IS_REMOTE);
|
|
12150
|
+
return engine.maskToolResult(result, { textFormat: isPlainText ? "plain" : "json" });
|
|
12151
|
+
}
|
|
12066
12152
|
main().catch(error => {
|
|
12067
12153
|
logger.fatal({ err: error }, "Fatal error in main()");
|
|
12068
12154
|
process.exit(1);
|