@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.
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +6 -2
- package/README.zh-CN.md +5 -1
- 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 +95 -43
- 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/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/oauth-device-flow-tests.js +10 -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 +4 -2
- package/build/test/test-permission-mode.js +86 -0
- package/build/tools/denied-regex.js +38 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/build/cli-command.js
CHANGED
|
@@ -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([
|
|
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 (
|
|
21
|
-
i + 1
|
|
22
|
-
|
|
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
|
|
8
|
-
if (
|
|
9
|
-
|
|
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
|
|
12
|
-
|
|
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(",")
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
|
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;
|
|
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 (
|
|
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
|
-
|
|
12008
|
-
|
|
12009
|
-
|
|
12010
|
-
|
|
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
|
-
|
|
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);
|