@zereight/mcp-gitlab 2.1.67 → 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 +8 -4
- package/README.md +14 -5
- package/README.pt-BR.md +589 -0
- package/README.zh-CN.md +8 -4
- 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 +329 -44
- package/build/oauth.js +93 -10
- package/build/schemas.js +187 -0
- package/build/scripts/generate-tool-docs.js +7 -1
- package/build/scripts/tool-coverage/coverage.js +2 -0
- 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 +162 -0
- package/build/test/test-geteffectiveprojectid.js +4 -2
- package/build/test/test-permission-mode.js +87 -0
- package/build/test/test-snippets.js +735 -0
- package/build/test/test-toolset-filtering.js +3 -0
- package/build/tools/denied-regex.js +38 -0
- package/build/tools/registry.js +42 -1
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_TOOLS_RAW, GITLAB_TOOLSETS_RAW, getConfig, } from "../config.js";
|
|
2
|
+
import { compileDeniedToolsRegex } from "../tools/denied-regex.js";
|
|
3
|
+
import { buildFeatureFlagOverrides, isToolInEnabledToolset, parseEnabledToolsets, parseIndividualTools, } from "../tools/registry.js";
|
|
4
|
+
export function parseToolNameSet(raw) {
|
|
5
|
+
if (!raw || raw.trim() === "") {
|
|
6
|
+
return new Set();
|
|
7
|
+
}
|
|
8
|
+
return new Set(raw
|
|
9
|
+
.split(",")
|
|
10
|
+
.map(name => name.trim())
|
|
11
|
+
.filter(Boolean));
|
|
12
|
+
}
|
|
13
|
+
export function isToolExposed(input) {
|
|
14
|
+
if (input.hiddenTools.has(input.toolName)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (input.deniedRegex?.test(input.toolName) === true) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (isToolInEnabledToolset(input.toolName, input.enabledToolsets)) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (input.individuallyEnabledTools.has(input.toolName)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return input.featureFlagOverrides.has(input.toolName);
|
|
27
|
+
}
|
|
28
|
+
export function exposureRefusalMessage(input) {
|
|
29
|
+
if (input.hiddenTools.has(input.toolName)) {
|
|
30
|
+
return `${input.toolName} is hidden by GITLAB_TOOL_POLICY_HIDDEN`;
|
|
31
|
+
}
|
|
32
|
+
if (input.deniedRegex?.test(input.toolName) === true) {
|
|
33
|
+
return `${input.toolName} is blocked by GITLAB_DENIED_TOOLS_REGEX`;
|
|
34
|
+
}
|
|
35
|
+
if (isToolExposed(input)) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return `${input.toolName} is not enabled by GITLAB_TOOLSETS, GITLAB_TOOLS, or legacy wiki/milestone/pipeline flags`;
|
|
39
|
+
}
|
|
40
|
+
export function liveCliExposure() {
|
|
41
|
+
const enabledToolsets = parseEnabledToolsets(GITLAB_TOOLSETS_RAW);
|
|
42
|
+
const individuallyEnabledTools = parseIndividualTools(GITLAB_TOOLS_RAW);
|
|
43
|
+
const featureFlagOverrides = buildFeatureFlagOverrides();
|
|
44
|
+
const deniedRegex = compileDeniedToolsRegex(getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX")).regex;
|
|
45
|
+
const hiddenTools = parseToolNameSet(GITLAB_TOOL_POLICY_HIDDEN_RAW);
|
|
46
|
+
const approveTools = parseToolNameSet(GITLAB_TOOL_POLICY_APPROVE_RAW);
|
|
47
|
+
return {
|
|
48
|
+
isExposed(toolName) {
|
|
49
|
+
return isToolExposed({
|
|
50
|
+
toolName,
|
|
51
|
+
enabledToolsets,
|
|
52
|
+
individuallyEnabledTools,
|
|
53
|
+
featureFlagOverrides,
|
|
54
|
+
deniedRegex,
|
|
55
|
+
hiddenTools,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
needsConfirmation(toolName) {
|
|
59
|
+
return approveTools.has(toolName);
|
|
60
|
+
},
|
|
61
|
+
exposureRefusal(toolName) {
|
|
62
|
+
return exposureRefusalMessage({
|
|
63
|
+
toolName,
|
|
64
|
+
enabledToolsets,
|
|
65
|
+
individuallyEnabledTools,
|
|
66
|
+
featureFlagOverrides,
|
|
67
|
+
deniedRegex,
|
|
68
|
+
hiddenTools,
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { allTools } from "../tools/registry.js";
|
|
2
|
+
import { readObjectSchema, snakeToKebab } from "./args.js";
|
|
3
|
+
import { CURATED_COMMANDS, listGroupActions, listGroups } from "./curated.js";
|
|
4
|
+
const BINARY = "zereight-mcp-gitlab";
|
|
5
|
+
export function globalHelpText() {
|
|
6
|
+
const groups = listGroups().map(group => ` ${group}`).join("\n");
|
|
7
|
+
return `${BINARY} — GitLab MCP server and human CLI
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
${BINARY} auth [options]
|
|
11
|
+
${BINARY} tool <tool-name> [options]
|
|
12
|
+
${BINARY} <group> <action> [options]
|
|
13
|
+
${BINARY} --help
|
|
14
|
+
|
|
15
|
+
Global flags:
|
|
16
|
+
--output table|json Output format (table default for curated, json for tool)
|
|
17
|
+
--yes Confirm destructive or approval-policy commands
|
|
18
|
+
--args-json '{...}' Nested/array arguments
|
|
19
|
+
--token TOKEN GitLab personal access token
|
|
20
|
+
--api-url URL GitLab API URL
|
|
21
|
+
--permission-mode readonly | modify | full
|
|
22
|
+
|
|
23
|
+
Groups:
|
|
24
|
+
${groups}
|
|
25
|
+
|
|
26
|
+
Advanced: ${BINARY} tool <name> covers registry tools allowed by the current toolsets and filters. Curated commands are the daily path.
|
|
27
|
+
Human CLI honors GITLAB_TOOLSETS, GITLAB_TOOLS, GITLAB_DENIED_TOOLS_REGEX, and tool-policy hidden/approve.
|
|
28
|
+
MCP compact (server): --compact-results replaces oversized tool replies with a preview plus a CLI command for the full payload.
|
|
29
|
+
`;
|
|
30
|
+
}
|
|
31
|
+
export function groupHelpText(group) {
|
|
32
|
+
const commands = listGroupActions(group);
|
|
33
|
+
if (commands.length === 0) {
|
|
34
|
+
return `Unknown group: ${group}\n`;
|
|
35
|
+
}
|
|
36
|
+
const lines = commands.map(command => ` ${command.group} ${command.action} ${command.tool}`);
|
|
37
|
+
return `${BINARY} ${group}
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
${BINARY} ${group} <action> [options]
|
|
41
|
+
${BINARY} ${group} <action> --help
|
|
42
|
+
|
|
43
|
+
Actions:
|
|
44
|
+
${lines.join("\n")}
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
export function toolHelpText(toolName) {
|
|
48
|
+
const tool = allTools.find(entry => entry.name === toolName);
|
|
49
|
+
if (tool === undefined || toolName === "discover_tools") {
|
|
50
|
+
return `Unknown tool: ${toolName}\n`;
|
|
51
|
+
}
|
|
52
|
+
const schema = readObjectSchema(tool.inputSchema);
|
|
53
|
+
const description = typeof tool.description === "string" ? tool.description : "";
|
|
54
|
+
const flags = formatSchemaFlags(schema.properties, schema.required ?? []);
|
|
55
|
+
return `${BINARY} tool ${toolName}
|
|
56
|
+
|
|
57
|
+
${description}
|
|
58
|
+
|
|
59
|
+
Flags:
|
|
60
|
+
${flags}
|
|
61
|
+
--output json|table
|
|
62
|
+
--args-json '{...}'
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
export function curatedHelpText(group, action) {
|
|
66
|
+
const command = CURATED_COMMANDS.find(entry => entry.group === group && entry.action === action);
|
|
67
|
+
if (command === undefined) {
|
|
68
|
+
return `Unknown command: ${group} ${action}\n`;
|
|
69
|
+
}
|
|
70
|
+
const tool = allTools.find(entry => entry.name === command.tool);
|
|
71
|
+
const schema = readObjectSchema(tool?.inputSchema);
|
|
72
|
+
const flags = formatSchemaFlags(schema.properties, schema.required ?? [], command.flagAliases);
|
|
73
|
+
return `${BINARY} ${group} ${action}
|
|
74
|
+
|
|
75
|
+
Maps to ${command.tool}${command.scope ? ` (or ${command.scope.groupTool} with --group-id)` : ""}
|
|
76
|
+
|
|
77
|
+
Flags:
|
|
78
|
+
${flags}
|
|
79
|
+
--output table|json
|
|
80
|
+
--yes
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
export function toolIndexHelpText() {
|
|
84
|
+
const names = allTools
|
|
85
|
+
.filter(tool => tool.name !== "discover_tools")
|
|
86
|
+
.map(tool => ` ${tool.name}`)
|
|
87
|
+
.join("\n");
|
|
88
|
+
return `${BINARY} tool <tool-name> [options]
|
|
89
|
+
|
|
90
|
+
Default output is JSON. Use --help after a tool name for generated flags.
|
|
91
|
+
|
|
92
|
+
Tools:
|
|
93
|
+
${names}
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
function formatSchemaFlags(properties, required, aliases) {
|
|
97
|
+
if (properties === undefined || Object.keys(properties).length === 0) {
|
|
98
|
+
return " (none)";
|
|
99
|
+
}
|
|
100
|
+
const requiredSet = new Set(required);
|
|
101
|
+
const aliasHints = aliases === undefined ? [] : Object.entries(aliases).map(([shortName, fullName]) => ` --${snakeToKebab(shortName)} alias for --${snakeToKebab(fullName)}`);
|
|
102
|
+
const flags = Object.entries(properties).map(([name, property]) => {
|
|
103
|
+
const suffix = requiredSet.has(name) ? " (required)" : "";
|
|
104
|
+
const description = typeof property.description === "string" ? ` ${property.description}` : "";
|
|
105
|
+
return ` --${snakeToKebab(name)}${suffix}${description}`;
|
|
106
|
+
});
|
|
107
|
+
return [...flags, ...aliasHints].join("\n");
|
|
108
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { renderTable } from "./tables.js";
|
|
2
|
+
export function formatToolOutput(input) {
|
|
3
|
+
const isError = resultIsError(input.result);
|
|
4
|
+
const text = unwrapToolText(input.result);
|
|
5
|
+
if (input.mode === "json") {
|
|
6
|
+
return { stdout: ensureTrailingNewline(jsonOrRaw(text)), stderr: "", isError };
|
|
7
|
+
}
|
|
8
|
+
const parsed = parseJsonValue(text);
|
|
9
|
+
if (parsed === undefined && input.tableSpec?.kind !== "text") {
|
|
10
|
+
return {
|
|
11
|
+
stdout: ensureTrailingNewline(text),
|
|
12
|
+
stderr: "No table renderer for this result; printed raw output.\n",
|
|
13
|
+
isError,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const rendered = renderTable(parsed ?? text, input.tableSpec);
|
|
17
|
+
return { stdout: rendered.stdout, stderr: rendered.stderr, isError };
|
|
18
|
+
}
|
|
19
|
+
export function formatCliError(error) {
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
+
return `${redactSecrets(message)}\n`;
|
|
22
|
+
}
|
|
23
|
+
export function resultIsError(result) {
|
|
24
|
+
return isRecord(result) && result.isError === true;
|
|
25
|
+
}
|
|
26
|
+
function unwrapToolText(result) {
|
|
27
|
+
if (!isRecord(result) || !Array.isArray(result.content)) {
|
|
28
|
+
return stringifyUnknown(result);
|
|
29
|
+
}
|
|
30
|
+
const textBlock = result.content.find(item => isRecord(item) && typeof item.text === "string");
|
|
31
|
+
if (!isRecord(textBlock) || typeof textBlock.text !== "string") {
|
|
32
|
+
return stringifyUnknown(result);
|
|
33
|
+
}
|
|
34
|
+
return textBlock.text;
|
|
35
|
+
}
|
|
36
|
+
function jsonOrRaw(text) {
|
|
37
|
+
const parsed = parseJsonValue(text);
|
|
38
|
+
if (parsed === undefined) {
|
|
39
|
+
return JSON.stringify(text);
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(parsed);
|
|
42
|
+
}
|
|
43
|
+
function parseJsonValue(text) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function stringifyUnknown(value) {
|
|
52
|
+
if (typeof value === "string") {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
return JSON.stringify(value);
|
|
56
|
+
}
|
|
57
|
+
function ensureTrailingNewline(text) {
|
|
58
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
59
|
+
}
|
|
60
|
+
function redactSecrets(message) {
|
|
61
|
+
return message.replace(/glpat-[A-Za-z0-9_-]+/g, "[REDACTED]");
|
|
62
|
+
}
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { snakeToKebab } from "./args.js";
|
|
2
|
+
const BINARY = "zereight-mcp-gitlab";
|
|
3
|
+
const SIMPLE_SHELL_TOKEN = /^[A-Za-z0-9._:/=+-]+$/;
|
|
4
|
+
const SECRET_ARGUMENT_NAMES = new Set([
|
|
5
|
+
"token",
|
|
6
|
+
"job_token",
|
|
7
|
+
"private_token",
|
|
8
|
+
"access_token",
|
|
9
|
+
"cookie",
|
|
10
|
+
"cookie_path",
|
|
11
|
+
"password",
|
|
12
|
+
"signing_token",
|
|
13
|
+
"authorization",
|
|
14
|
+
]);
|
|
15
|
+
export function buildToolReplayCommand(toolName, args) {
|
|
16
|
+
const parts = [BINARY, "tool", toolName];
|
|
17
|
+
const nested = {};
|
|
18
|
+
if (args !== undefined) {
|
|
19
|
+
for (const key of Object.keys(args)) {
|
|
20
|
+
if (shouldOmitReplayArgument(key)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const value = Reflect.get(args, key);
|
|
24
|
+
if (value === undefined) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (isScalarReplayValue(value)) {
|
|
28
|
+
parts.push(`--${snakeToKebab(key)}`, shellToken(String(value)));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
nested[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (Object.keys(nested).length > 0) {
|
|
35
|
+
parts.push("--args-json", shellToken(JSON.stringify(nested)));
|
|
36
|
+
}
|
|
37
|
+
return parts.join(" ");
|
|
38
|
+
}
|
|
39
|
+
function shouldOmitReplayArgument(name) {
|
|
40
|
+
if (name.startsWith("_")) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return SECRET_ARGUMENT_NAMES.has(name);
|
|
44
|
+
}
|
|
45
|
+
function isScalarReplayValue(value) {
|
|
46
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
47
|
+
}
|
|
48
|
+
function shellToken(value) {
|
|
49
|
+
if (SIMPLE_SHELL_TOKEN.test(value)) {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
return JSON.stringify(value);
|
|
53
|
+
}
|
|
@@ -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;
|