@zereight/mcp-gitlab 2.1.68 → 2.1.69

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.
@@ -0,0 +1,158 @@
1
+ import { GITLAB_PERMISSION_MODE } from "../config.js";
2
+ import { allTools, deleteTools, destructiveTools, readOnlyTools } from "../tools/registry.js";
3
+ import { getPositionalCliCommand } from "../cli-command.js";
4
+ import { applyFlagAliases, argvHasHelp, CliUsageError, parseArgv, parseToolArgs, } from "./args.js";
5
+ import { liveCliExposure } from "./exposure.js";
6
+ import { canonicalGroup, findCuratedCommand, isKnownCliCommand, resolveScopedTool, } from "./curated.js";
7
+ import { curatedHelpText, globalHelpText, groupHelpText, toolHelpText, toolIndexHelpText, } from "./help.js";
8
+ export function isCliInvocation(argv) {
9
+ if (argvHasHelp(argv)) {
10
+ return true;
11
+ }
12
+ const positional = getPositionalCliCommand(argv);
13
+ if (positional === undefined) {
14
+ return false;
15
+ }
16
+ return isKnownCliCommand(positional);
17
+ }
18
+ export function resolveCli(argv, options = {}) {
19
+ try {
20
+ return resolveCliUnsafe(argv, options.permissionMode ?? GITLAB_PERMISSION_MODE, options.exposure ?? liveCliExposure());
21
+ }
22
+ catch (error) {
23
+ if (error instanceof CliUsageError) {
24
+ return { kind: "usage", message: error.message };
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+ function resolveCliUnsafe(argv, permissionMode, exposure) {
30
+ const parsed = parseArgv(argv);
31
+ const [head, action, extra] = parsed.positionals;
32
+ if (head === undefined) {
33
+ if (parsed.help) {
34
+ return { kind: "help", text: globalHelpText() };
35
+ }
36
+ return { kind: "server" };
37
+ }
38
+ if (head === "auth") {
39
+ return { kind: "auth" };
40
+ }
41
+ if (head === "tool") {
42
+ if (extra !== undefined) {
43
+ return { kind: "usage", message: `Unexpected extra arguments: ${parsed.positionals.slice(2).join(" ")}` };
44
+ }
45
+ if (action === undefined) {
46
+ if (parsed.help) {
47
+ return { kind: "help", text: toolIndexHelpText() };
48
+ }
49
+ return { kind: "usage", message: `Usage: zereight-mcp-gitlab tool <tool-name> [options]` };
50
+ }
51
+ if (parsed.help) {
52
+ return { kind: "help", text: toolHelpText(action) };
53
+ }
54
+ return buildRun({
55
+ toolName: action,
56
+ flags: parsed.toolFlags,
57
+ argsJson: parsed.argsJson,
58
+ extraArgs: undefined,
59
+ output: parsed.output ?? "json",
60
+ yes: parsed.yes,
61
+ permissionMode,
62
+ exposure,
63
+ tableSpec: undefined,
64
+ });
65
+ }
66
+ const group = canonicalGroup(head);
67
+ if (group === undefined) {
68
+ if (parsed.help) {
69
+ return { kind: "help", text: globalHelpText() };
70
+ }
71
+ return { kind: "server" };
72
+ }
73
+ if (extra !== undefined) {
74
+ return { kind: "usage", message: `Unexpected extra arguments: ${parsed.positionals.slice(2).join(" ")}` };
75
+ }
76
+ if (action === undefined) {
77
+ return { kind: "help", text: groupHelpText(group) };
78
+ }
79
+ if (parsed.help) {
80
+ return { kind: "help", text: curatedHelpText(group, action) };
81
+ }
82
+ const command = findCuratedCommand(group, action);
83
+ if (command === undefined) {
84
+ return { kind: "usage", message: `Unknown command: ${head} ${action}` };
85
+ }
86
+ const aliased = applyFlagAliases(parsed.toolFlags, command.flagAliases);
87
+ const scopeArgs = {
88
+ ...(parsed.argsJson ?? {}),
89
+ ...aliased,
90
+ ...(command.extraArgs ?? {}),
91
+ };
92
+ const toolName = resolveScopedTool(command, scopeArgs);
93
+ return buildRun({
94
+ toolName,
95
+ flags: aliased,
96
+ argsJson: parsed.argsJson,
97
+ extraArgs: command.extraArgs,
98
+ output: parsed.output ?? "table",
99
+ yes: parsed.yes,
100
+ permissionMode,
101
+ exposure,
102
+ tableSpec: command.table,
103
+ });
104
+ }
105
+ function buildRun(input) {
106
+ if (input.toolName === "discover_tools") {
107
+ return {
108
+ kind: "usage",
109
+ message: "discover_tools is MCP-only; use `tool <name>` for registry tools allowed by the current filters",
110
+ };
111
+ }
112
+ const tool = allTools.find(entry => entry.name === input.toolName);
113
+ if (tool === undefined) {
114
+ return { kind: "usage", message: `Unknown tool: ${input.toolName}` };
115
+ }
116
+ const args = parseToolArgs({
117
+ schema: tool.inputSchema,
118
+ flags: input.flags,
119
+ argsJson: input.argsJson,
120
+ extraArgs: input.extraArgs,
121
+ });
122
+ const exposureMessage = input.exposure.exposureRefusal(input.toolName);
123
+ if (exposureMessage !== undefined) {
124
+ return { kind: "refused", message: exposureMessage };
125
+ }
126
+ const permissionMessage = permissionRefusal(input.toolName, input.permissionMode);
127
+ if (permissionMessage !== undefined) {
128
+ return { kind: "refused", message: permissionMessage };
129
+ }
130
+ if (destructiveTools.has(input.toolName) && !input.yes) {
131
+ return {
132
+ kind: "refused",
133
+ message: `Would run ${input.toolName} with arguments [${Object.keys(args).join(", ")}]. Re-run with --yes to proceed.`,
134
+ };
135
+ }
136
+ if (input.exposure.needsConfirmation(input.toolName) && !input.yes) {
137
+ return {
138
+ kind: "refused",
139
+ message: `${input.toolName} requires confirmation (GITLAB_TOOL_POLICY_APPROVE). Re-run with --yes to proceed.`,
140
+ };
141
+ }
142
+ return {
143
+ kind: "run",
144
+ toolName: input.toolName,
145
+ args,
146
+ output: input.output,
147
+ tableSpec: input.tableSpec,
148
+ };
149
+ }
150
+ function permissionRefusal(toolName, permissionMode) {
151
+ if (permissionMode === "readonly" && !readOnlyTools.has(toolName)) {
152
+ return `${toolName} is not allowed in read-only mode`;
153
+ }
154
+ if (permissionMode === "modify" && deleteTools.has(toolName)) {
155
+ return `${toolName} is not allowed in modify mode (delete operations are disabled)`;
156
+ }
157
+ return undefined;
158
+ }
@@ -0,0 +1,117 @@
1
+ const MAX_CELL = 48;
2
+ export function renderTable(value, spec) {
3
+ if (spec === undefined || spec.kind === "text") {
4
+ return { stdout: stringifyValue(value), stderr: "" };
5
+ }
6
+ const rows = spec.kind === "list" ? asObjectRows(value) : undefined;
7
+ if (spec.kind === "list") {
8
+ if (rows === undefined) {
9
+ return {
10
+ stdout: stringifyValue(value),
11
+ stderr: "No table renderer for this result; printed JSON.\n",
12
+ };
13
+ }
14
+ if (rows.length === 0) {
15
+ return { stdout: "No items.\n", stderr: "" };
16
+ }
17
+ const columns = spec.columns ?? inferColumns(rows);
18
+ return { stdout: formatGrid(rows, columns), stderr: "" };
19
+ }
20
+ const record = asRecord(value);
21
+ if (record === undefined) {
22
+ return {
23
+ stdout: stringifyValue(value),
24
+ stderr: "No table renderer for this result; printed JSON.\n",
25
+ };
26
+ }
27
+ const keys = spec.columns ?? Object.keys(record);
28
+ return { stdout: formatKeyValue(record, keys), stderr: "" };
29
+ }
30
+ function asObjectRows(value) {
31
+ if (Array.isArray(value)) {
32
+ return value.every(isRecord) ? value : undefined;
33
+ }
34
+ const record = asRecord(value);
35
+ if (record === undefined) {
36
+ return undefined;
37
+ }
38
+ for (const nested of Object.values(record)) {
39
+ if (Array.isArray(nested) && nested.every(isRecord)) {
40
+ return nested;
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+ function inferColumns(rows) {
46
+ const preferred = ["iid", "id", "name", "key", "tag_name", "slug", "short_id", "state", "status", "title", "path_with_namespace"];
47
+ const present = new Set();
48
+ for (const row of rows) {
49
+ for (const key of Object.keys(row)) {
50
+ present.add(key);
51
+ }
52
+ }
53
+ const columns = preferred.filter(key => present.has(key));
54
+ return columns.length > 0 ? columns : Object.keys(rows[0] ?? {}).slice(0, 4);
55
+ }
56
+ function formatGrid(rows, columns) {
57
+ const header = columns.map(key => key.toUpperCase());
58
+ const cells = rows.map(row => columns.map(key => cellText(row[key])));
59
+ const widths = columns.map((_, index) => {
60
+ const longest = cells.reduce((max, row) => Math.max(max, row[index]?.length ?? 0), header[index]?.length ?? 0);
61
+ return Math.min(longest, MAX_CELL);
62
+ });
63
+ const lines = [
64
+ padRow(header, widths),
65
+ ...cells.map(row => padRow(row, widths)),
66
+ ];
67
+ return `${lines.join("\n")}\n`;
68
+ }
69
+ function formatKeyValue(record, keys) {
70
+ const lines = [];
71
+ for (const key of keys) {
72
+ if (record[key] === undefined) {
73
+ continue;
74
+ }
75
+ lines.push(`${key}\t${cellText(record[key])}`);
76
+ }
77
+ if (lines.length === 0) {
78
+ return stringifyValue(record);
79
+ }
80
+ return `${lines.join("\n")}\n`;
81
+ }
82
+ function padRow(cells, widths) {
83
+ return cells
84
+ .map((cell, index) => truncate(cell).padEnd(widths[index] ?? 0))
85
+ .join(" ")
86
+ .trimEnd();
87
+ }
88
+ function cellText(value) {
89
+ if (value === null || value === undefined) {
90
+ return "";
91
+ }
92
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
93
+ return truncate(String(value));
94
+ }
95
+ return truncate(JSON.stringify(value));
96
+ }
97
+ function truncate(value) {
98
+ if (value.length <= MAX_CELL) {
99
+ return value;
100
+ }
101
+ return `${value.slice(0, MAX_CELL - 1)}…`;
102
+ }
103
+ function stringifyValue(value) {
104
+ if (typeof value === "string") {
105
+ return value.endsWith("\n") ? value : `${value}\n`;
106
+ }
107
+ return `${JSON.stringify(value, null, 2)}\n`;
108
+ }
109
+ function asRecord(value) {
110
+ if (!isRecord(value)) {
111
+ return undefined;
112
+ }
113
+ return value;
114
+ }
115
+ function isRecord(value) {
116
+ return typeof value === "object" && value !== null && !Array.isArray(value);
117
+ }
@@ -0,0 +1,38 @@
1
+ // Boolean flags accept `--flag`, `--flag=true`, or `--flag false`.
2
+ // They must not consume the following positional command
3
+ // (`--use-oauth mr list` is `mr`, not `list`).
4
+ export const BOOLEAN_CLI_FLAG_NAMES = new Set([
5
+ "use-oauth",
6
+ "is-old",
7
+ "read-only",
8
+ "masking-enabled",
9
+ "use-wiki",
10
+ "use-milestone",
11
+ "use-pipeline",
12
+ "disable-version-check",
13
+ "sse",
14
+ "streamable-http",
15
+ "remote-auth",
16
+ "mcp-oauth",
17
+ "allow-unauthenticated-tool-discovery",
18
+ "mcp-trust-proxy",
19
+ "oauth-callback-proxy",
20
+ "enable-dynamic-api-url",
21
+ "enable-dynamic-project-scope",
22
+ "enable-strict-project-scope",
23
+ "oauth-stateless-mode",
24
+ "compact-results",
25
+ ]);
26
+ const BOOLEAN_FLAG_LITERALS = new Set(["true", "false", "1", "0"]);
27
+ export function isBooleanFlagLiteral(value) {
28
+ if (value === undefined || value.startsWith("-")) {
29
+ return false;
30
+ }
31
+ return BOOLEAN_FLAG_LITERALS.has(value.toLowerCase());
32
+ }
33
+ export function nextBooleanFlagValue(nextToken) {
34
+ if (isBooleanFlagLiteral(nextToken)) {
35
+ return { value: nextToken, consumeNext: true };
36
+ }
37
+ return { value: "true", consumeNext: false };
38
+ }
@@ -1,5 +1,11 @@
1
+ import { BOOLEAN_CLI_FLAG_NAMES, isBooleanFlagLiteral } from "./cli-boolean-flags.js";
1
2
  const SCRIPT_PATH_PATTERN = /\.(cjs|mjs|js|cts|mts|ts)$/;
2
- const FLAGS_WITHOUT_VALUE = new Set(["--help", "-h"]);
3
+ const FLAGS_WITHOUT_VALUE = new Set([
4
+ "--help",
5
+ "-h",
6
+ "--yes",
7
+ ...[...BOOLEAN_CLI_FLAG_NAMES].map(name => `--${name}`),
8
+ ]);
3
9
  function isScriptPath(arg) {
4
10
  return SCRIPT_PATH_PATTERN.test(arg);
5
11
  }
@@ -17,9 +23,13 @@ export function getPositionalCliCommand(argv) {
17
23
  continue;
18
24
  }
19
25
  if (arg.startsWith("-")) {
20
- if (!FLAGS_WITHOUT_VALUE.has(arg) &&
21
- i + 1 < args.length &&
22
- !args[i + 1].startsWith("-")) {
26
+ if (FLAGS_WITHOUT_VALUE.has(arg)) {
27
+ if (BOOLEAN_CLI_FLAG_NAMES.has(arg.replace(/^--/, "")) && isBooleanFlagLiteral(args[i + 1])) {
28
+ i += 1;
29
+ }
30
+ continue;
31
+ }
32
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
23
33
  i += 1;
24
34
  }
25
35
  continue;
package/build/config.js CHANGED
@@ -1,15 +1,30 @@
1
+ import { BOOLEAN_CLI_FLAG_NAMES, nextBooleanFlagValue } from "./cli-boolean-flags.js";
1
2
  // Parse CLI arguments before anything else
2
3
  const args = process.argv.slice(2);
3
4
  export const cliArgs = {};
4
5
  for (let i = 0; i < args.length; i++) {
5
6
  const arg = args[i];
6
7
  if (arg.startsWith("--")) {
7
- const [key, value] = arg.slice(2).split("=");
8
- if (value) {
9
- cliArgs[key] = value;
8
+ const eqIndex = arg.indexOf("=");
9
+ if (eqIndex !== -1) {
10
+ const key = arg.slice(2, eqIndex);
11
+ const value = arg.slice(eqIndex + 1);
12
+ if (value) {
13
+ cliArgs[key] = value;
14
+ }
10
15
  }
11
- else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
12
- cliArgs[key] = args[++i];
16
+ else {
17
+ const key = arg.slice(2);
18
+ if (BOOLEAN_CLI_FLAG_NAMES.has(key)) {
19
+ const parsed = nextBooleanFlagValue(args[i + 1]);
20
+ cliArgs[key] = parsed.value;
21
+ if (parsed.consumeNext) {
22
+ i += 1;
23
+ }
24
+ }
25
+ else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
26
+ cliArgs[key] = args[++i];
27
+ }
13
28
  }
14
29
  }
15
30
  }
@@ -76,7 +91,9 @@ export const MCP_SERVER_URL = getConfig("mcp-server-url", "MCP_SERVER_URL");
76
91
  export const GITLAB_OAUTH_APP_ID = getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID");
77
92
  export const GITLAB_OAUTH_SCOPES_RAW = getConfig("oauth-scopes", "GITLAB_OAUTH_SCOPES");
78
93
  export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
79
- ? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
94
+ ? GITLAB_OAUTH_SCOPES_RAW.split(",")
95
+ .map(s => s.trim())
96
+ .filter(Boolean)
80
97
  : undefined;
81
98
  export const GITLAB_OAUTH_CALLBACK_PROXY = getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true";
82
99
  /** @deprecated Use GITLAB_OAUTH_ALLOWED_GROUPS_RAW instead. Will be removed in the next major version. */
@@ -88,7 +105,10 @@ export const GITLAB_OAUTH_ALLOWED_GROUPS = (() => {
88
105
  const raw = newVar ?? oldVar;
89
106
  if (!raw)
90
107
  return undefined;
91
- const groups = raw.split(",").map((g) => g.trim()).filter(Boolean);
108
+ const groups = raw
109
+ .split(",")
110
+ .map(g => g.trim())
111
+ .filter(Boolean);
92
112
  return groups.length > 0 ? groups : undefined;
93
113
  })();
94
114
  export const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
@@ -163,6 +183,9 @@ export const HOST = getConfig("host", "HOST") || "127.0.0.1";
163
183
  /** Default HTTP port for the MCP server. */
164
184
  const _PORT_DEFAULT = 3002;
165
185
  export const PORT = _intEnv("PORT", "port", _PORT_DEFAULT);
186
+ /** When true, oversized MCP tool replies are replaced with a preview plus a CLI replay command. */
187
+ export const GITLAB_MCP_COMPACT_RESULTS = getConfig("compact-results", "GITLAB_MCP_COMPACT_RESULTS") === "true";
188
+ export const GITLAB_MCP_COMPACT_RESULT_CHARS = _intEnv("GITLAB_MCP_COMPACT_RESULT_CHARS", "compact-result-chars", 4000);
166
189
  // ---------------------------------------------------------------------------
167
190
  // Proxy configuration
168
191
  // ---------------------------------------------------------------------------
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");
@@ -74,9 +74,13 @@ import path from "node:path";
74
74
  import { CookieJar, parse as parseCookie } from "tough-cookie";
75
75
  import { URL } from "node:url";
76
76
  import { z } from "zod";
77
- import { initializeOAuthClient } from "./oauth.js";
77
+ import { createGitLabOAuthClient, ensureOAuthToken } from "./oauth.js";
78
78
  import { getPositionalCliCommand } from "./cli-command.js";
79
79
  import { runAuthCommandAsync } from "./auth-cli.js";
80
+ import { isCliInvocation, resolveCli } from "./cli/router.js";
81
+ import { formatCliError, formatToolOutput } from "./cli/output.js";
82
+ import { compactMcpToolResult, readOwnRecord } from "./cli/compact-mcp-result.js";
83
+ import { compileDeniedToolsRegex } from "./tools/denied-regex.js";
80
84
  import { createGitLabOAuthProvider } from "./oauth-proxy.js";
81
85
  import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
82
86
  import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -179,6 +183,7 @@ const MCP_PROMPTS = [
179
183
  * Each transport connection gets its own Server instance to prevent
180
184
  * cross-client data leakage (GHSA-345p-7cg4-v4c7).
181
185
  */
186
+ /** Builds an MCP server with the tool list filtered by toolsets, permission mode, and policy. */
182
187
  function createServer() {
183
188
  // Precompute filtered tool list once at server creation (Steps 1–5 are static)
184
189
  // Step 1: Toolset filter — keep tools in enabled toolsets
@@ -329,11 +334,18 @@ function createServer() {
329
334
  ? (value) => maskingEngine.maskValue(value)
330
335
  : undefined,
331
336
  });
332
- return maskingEngine
337
+ const masked = maskingEngine
333
338
  ? maskingEngine.maskToolResult(filtered, {
334
339
  textFormat: isPlainTextResult ? "plain" : "json",
335
340
  })
336
341
  : filtered;
342
+ return compactMcpToolResult({
343
+ result: masked,
344
+ enabled: GITLAB_MCP_COMPACT_RESULTS,
345
+ maxChars: GITLAB_MCP_COMPACT_RESULT_CHARS,
346
+ toolName,
347
+ args: readOwnRecord(request.params.arguments),
348
+ });
337
349
  };
338
350
  const logError = (error) => {
339
351
  const durationMs = Date.now() - start;
@@ -398,6 +410,16 @@ function createServer() {
398
410
  if (jmespathSyntaxError) {
399
411
  return logCompletion({ content: [{ type: "text", text: jmespathSyntaxError }], isError: true }, { skipJmespath: true });
400
412
  }
413
+ // Enforce list-time filtering on direct calls: clients can call names that tools/list omits.
414
+ // filteredTools is extended by discover_tools; hidden-policy tools stay callable by design,
415
+ // but GITLAB_DENIED_TOOLS_REGEX always wins over the hidden exemption.
416
+ // Permission-mode denials fall through so handleToolCall reports its mode-specific error.
417
+ const hiddenAndNotDenied = hiddenToolSet.has(toolName) && !GITLAB_DENIED_TOOLS_REGEX?.test(toolName);
418
+ if (isToolAllowedByPermissionMode(toolName) &&
419
+ !hiddenAndNotDenied &&
420
+ !filteredTools.some(t => t.name === toolName)) {
421
+ throw new Error(`Tool "${toolName}" is not available on this server`);
422
+ }
401
423
  // Handle discover_tools meta-tool directly (needs access to mcpServer and filteredTools)
402
424
  if (toolName === "discover_tools") {
403
425
  const category = request.params.arguments?.category?.trim()?.toLowerCase();
@@ -526,6 +548,7 @@ function createServer() {
526
548
  }
527
549
  catch (error) {
528
550
  logError(error);
551
+ throw error;
529
552
  }
530
553
  });
531
554
  return mcpServer;
@@ -846,15 +869,10 @@ export function hasStatelessSessionId(req) {
846
869
  * This avoids background timers that cause issues with multiple instances.
847
870
  */
848
871
  async function ensureValidOAuthToken() {
849
- if (!oauthClient)
850
- return;
851
- if (oauthClient.hasValidToken())
852
- return;
853
872
  try {
854
- logger.info("OAuth token expired or missing, refreshing...");
855
- const freshToken = await oauthClient.getAccessToken();
856
- OAUTH_ACCESS_TOKEN = freshToken;
857
- logger.info("OAuth token refreshed successfully");
873
+ await ensureOAuthToken(oauthClient, OAUTH_ACCESS_TOKEN, token => {
874
+ OAUTH_ACCESS_TOKEN = token;
875
+ });
858
876
  }
859
877
  catch (error) {
860
878
  logger.error({ err: error }, "Failed to refresh OAuth token");
@@ -873,33 +891,11 @@ function isToolAllowedByPermissionMode(toolName) {
873
891
  }
874
892
  }
875
893
  const GITLAB_DENIED_TOOLS_REGEX = (() => {
876
- const pattern = getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX");
877
- if (!pattern)
878
- return undefined;
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;
894
+ const compiled = compileDeniedToolsRegex(getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX"));
895
+ if (compiled.error) {
896
+ logger.error(compiled.error);
902
897
  }
898
+ return compiled.regex;
903
899
  })();
904
900
  // ---------------------------------------------------------------------------
905
901
  // Tool policy: approve / hidden sets
@@ -1317,7 +1313,7 @@ if (GITLAB_MCP_OAUTH) {
1317
1313
  }
1318
1314
  logger.info("MCP OAuth enabled: GitLab OAuth proxy active (Private-Token/JOB-TOKEN headers bypass OAuth)");
1319
1315
  }
1320
- if (getPositionalCliCommand(process.argv) !== "auth" &&
1316
+ if (!isCliInvocation(process.argv) &&
1321
1317
  !REMOTE_AUTHORIZATION &&
1322
1318
  !GITLAB_MCP_OAUTH &&
1323
1319
  !USE_OAUTH &&
@@ -12004,10 +12000,12 @@ async function runServer() {
12004
12000
  logger.info("Using OAuth authentication...");
12005
12001
  try {
12006
12002
  const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
12007
- const oauthResult = await initializeOAuthClient(gitlabBaseUrl);
12008
- oauthClient = oauthResult.client;
12009
- OAUTH_ACCESS_TOKEN = oauthResult.accessToken;
12010
- logger.info("OAuth authentication successful");
12003
+ // Construct the client synchronously (no network). The token is
12004
+ // acquired lazily on the first tool call via ensureValidOAuthToken,
12005
+ // by which point the network is ready. This avoids blocking startup
12006
+ // and avoids opening a browser on transient boot-time network errors.
12007
+ oauthClient = createGitLabOAuthClient(gitlabBaseUrl);
12008
+ logger.info("OAuth enabled; token acquired lazily on first use.");
12011
12009
  }
12012
12010
  catch (error) {
12013
12011
  logger.error({ err: error }, "OAuth authentication failed");
@@ -12049,7 +12047,16 @@ async function runServer() {
12049
12047
  }
12050
12048
  }
12051
12049
  async function main() {
12052
- if (getPositionalCliCommand(process.argv) === "auth") {
12050
+ const cli = resolveCli(process.argv);
12051
+ if (cli.kind === "help") {
12052
+ process.stdout.write(cli.text);
12053
+ process.exit(0);
12054
+ }
12055
+ if (cli.kind === "usage" || cli.kind === "refused") {
12056
+ process.stderr.write(`${cli.message}\n`);
12057
+ process.exit(2);
12058
+ }
12059
+ if (cli.kind === "auth" || getPositionalCliCommand(process.argv) === "auth") {
12053
12060
  try {
12054
12061
  await runAuthCommandAsync();
12055
12062
  process.exit(0);
@@ -12061,8 +12068,53 @@ async function main() {
12061
12068
  process.exit(1);
12062
12069
  }
12063
12070
  }
12071
+ if (cli.kind === "run") {
12072
+ if (!hasCliCredentials()) {
12073
+ process.stderr.write("Missing GitLab credentials. Set --token or GITLAB_PERSONAL_ACCESS_TOKEN, or run `auth` with GITLAB_USE_OAUTH=true.\n");
12074
+ process.exit(2);
12075
+ }
12076
+ try {
12077
+ const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
12078
+ const masked = maskCliToolResult(cli.toolName, result);
12079
+ const formatted = formatToolOutput({
12080
+ result: masked,
12081
+ mode: cli.output,
12082
+ tableSpec: cli.tableSpec,
12083
+ });
12084
+ if (formatted.stderr) {
12085
+ process.stderr.write(formatted.stderr);
12086
+ }
12087
+ process.stdout.write(formatted.stdout);
12088
+ process.exit(formatted.isError ? 1 : 0);
12089
+ }
12090
+ catch (error) {
12091
+ process.stderr.write(formatCliError(error));
12092
+ logger.error({ err: error }, "cli command failed");
12093
+ process.exit(1);
12094
+ }
12095
+ }
12064
12096
  await runServer();
12065
12097
  }
12098
+ function hasCliCredentials() {
12099
+ return Boolean(GITLAB_PERSONAL_ACCESS_TOKEN || GITLAB_JOB_TOKEN || GITLAB_AUTH_COOKIE_PATH || USE_OAUTH);
12100
+ }
12101
+ function isRecord(value) {
12102
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12103
+ }
12104
+ function maskCliToolResult(toolName, result) {
12105
+ const gitlabInstance = getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com";
12106
+ const engine = maskingPolicyResolver?.select({
12107
+ gitlabInstance,
12108
+ allowBuiltinForUnscoped: true,
12109
+ });
12110
+ if (!engine || !isRecord(result)) {
12111
+ return result;
12112
+ }
12113
+ const isPlainText = toolName === "get_pipeline_job_output" ||
12114
+ toolName === "get_job_artifact_file" ||
12115
+ (toolName === "download_release_asset" && !IS_REMOTE);
12116
+ return engine.maskToolResult(result, { textFormat: isPlainText ? "plain" : "json" });
12117
+ }
12066
12118
  main().catch(error => {
12067
12119
  logger.fatal({ err: error }, "Fatal error in main()");
12068
12120
  process.exit(1);