@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
package/build/oauth.js
CHANGED
|
@@ -19,6 +19,28 @@ function escapeHtml(str) {
|
|
|
19
19
|
}
|
|
20
20
|
// Track pending auth requests across multiple MCP instances
|
|
21
21
|
const pendingAuthRequests = new Map();
|
|
22
|
+
const OAUTH_REFRESH_TIMEOUT_MS = 10000;
|
|
23
|
+
/**
|
|
24
|
+
* Decide whether a failed token-refresh response means the refresh token is
|
|
25
|
+
* genuinely dead (interactive re-login would fix it) versus a transient or
|
|
26
|
+
* configuration problem (where opening a browser is wrong).
|
|
27
|
+
*
|
|
28
|
+
* Returns true ONLY for a 400/401 whose JSON body has error "invalid_grant"
|
|
29
|
+
* or "invalid_token". Everything else — config errors (invalid_client,
|
|
30
|
+
* invalid_request), 5xx, 429, and non-JSON bodies — returns false.
|
|
31
|
+
*/
|
|
32
|
+
export function isAuthInvalidTokenResponse(status, bodyText) {
|
|
33
|
+
if (status !== 400 && status !== 401) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const body = JSON.parse(bodyText);
|
|
38
|
+
return body.error === "invalid_grant" || body.error === "invalid_token";
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
22
44
|
/**
|
|
23
45
|
* Check if a port is already in use
|
|
24
46
|
*/
|
|
@@ -170,10 +192,14 @@ export class GitLabOAuth {
|
|
|
170
192
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
171
193
|
},
|
|
172
194
|
body: params.toString(),
|
|
195
|
+
signal: AbortSignal.timeout(OAUTH_REFRESH_TIMEOUT_MS),
|
|
173
196
|
});
|
|
174
197
|
if (!response.ok) {
|
|
175
|
-
const
|
|
176
|
-
|
|
198
|
+
const bodyText = await response.text();
|
|
199
|
+
const authInvalid = isAuthInvalidTokenResponse(response.status, bodyText);
|
|
200
|
+
throw Object.assign(new Error(`Token refresh failed: ${response.status} ${bodyText}`), {
|
|
201
|
+
authInvalid,
|
|
202
|
+
});
|
|
177
203
|
}
|
|
178
204
|
const data = (await response.json());
|
|
179
205
|
return {
|
|
@@ -230,7 +256,13 @@ export class GitLabOAuth {
|
|
|
230
256
|
* Uses a shared server if port is already in use
|
|
231
257
|
*/
|
|
232
258
|
async startOAuthFlow() {
|
|
233
|
-
|
|
259
|
+
let callbackPort = 8888;
|
|
260
|
+
try {
|
|
261
|
+
callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888", 10);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Fallback to default port on malformed redirectUri
|
|
265
|
+
}
|
|
234
266
|
const requestId = crypto.randomUUID();
|
|
235
267
|
// Check if port is already in use
|
|
236
268
|
const portInUse = await isPortInUse(callbackPort);
|
|
@@ -529,8 +561,14 @@ export class GitLabOAuth {
|
|
|
529
561
|
this.saveToken(tokenData);
|
|
530
562
|
}
|
|
531
563
|
catch (error) {
|
|
532
|
-
|
|
533
|
-
|
|
564
|
+
if (error.authInvalid === true) {
|
|
565
|
+
logger.error({ err: error }, "Refresh token invalid, starting new OAuth flow...");
|
|
566
|
+
tokenData = await this.startOAuthFlow();
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
logger.error({ err: error }, "Token refresh failed (transient/network), not opening browser.");
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
534
572
|
}
|
|
535
573
|
}
|
|
536
574
|
else {
|
|
@@ -588,11 +626,11 @@ export class GitLabOAuth {
|
|
|
588
626
|
}
|
|
589
627
|
}
|
|
590
628
|
/**
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
629
|
+
* Construct a GitLabOAuth client from environment configuration.
|
|
630
|
+
* Does NOT perform any network access — no token is acquired here.
|
|
631
|
+
* Throws if neither GITLAB_OAUTH_CLIENT_ID nor GITLAB_OAUTH_TOKEN_SCRIPT is set.
|
|
594
632
|
*/
|
|
595
|
-
export
|
|
633
|
+
export function createGitLabOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
596
634
|
const clientId = process.env.GITLAB_OAUTH_CLIENT_ID;
|
|
597
635
|
const clientSecret = process.env.GITLAB_OAUTH_CLIENT_SECRET;
|
|
598
636
|
const redirectUri = process.env.GITLAB_OAUTH_REDIRECT_URI || "http://127.0.0.1:8888/callback";
|
|
@@ -601,7 +639,7 @@ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
|
601
639
|
if (!clientId && !tokenScript) {
|
|
602
640
|
throw new Error("GITLAB_OAUTH_CLIENT_ID or GITLAB_OAUTH_TOKEN_SCRIPT environment variable is required for OAuth authentication");
|
|
603
641
|
}
|
|
604
|
-
|
|
642
|
+
return new GitLabOAuth({
|
|
605
643
|
clientId: clientId || "external-token-script",
|
|
606
644
|
clientSecret,
|
|
607
645
|
redirectUri,
|
|
@@ -610,6 +648,51 @@ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
|
610
648
|
tokenStoragePath,
|
|
611
649
|
tokenScript,
|
|
612
650
|
});
|
|
651
|
+
}
|
|
652
|
+
const inFlightTokenPromises = new WeakMap();
|
|
653
|
+
/**
|
|
654
|
+
* Ensure the caller holds a usable OAuth access token, delivering it via onToken.
|
|
655
|
+
* Returns cached token without network when still valid.
|
|
656
|
+
*
|
|
657
|
+
* NOTE: the fast path requires BOTH a held token and a valid stored token.
|
|
658
|
+
* Returning early on hasValidToken() alone would skip onToken after lazy
|
|
659
|
+
* startup (caller holds nothing yet) and leave requests unauthenticated.
|
|
660
|
+
* Concurrent callers coalesce on an in-flight promise to avoid racing
|
|
661
|
+
* duplicate refresh requests (which would fail on token rotation).
|
|
662
|
+
*/
|
|
663
|
+
export async function ensureOAuthToken(client, currentToken, onToken) {
|
|
664
|
+
if (!client)
|
|
665
|
+
return;
|
|
666
|
+
// Fast path: token already resolved for this process and still valid.
|
|
667
|
+
if (currentToken && client.hasValidToken())
|
|
668
|
+
return;
|
|
669
|
+
let promise = inFlightTokenPromises.get(client);
|
|
670
|
+
if (!promise) {
|
|
671
|
+
const needsRefresh = !client.hasValidToken();
|
|
672
|
+
if (needsRefresh) {
|
|
673
|
+
logger.info("OAuth token expired or missing, refreshing...");
|
|
674
|
+
}
|
|
675
|
+
promise = (async () => {
|
|
676
|
+
const token = await client.getAccessToken();
|
|
677
|
+
if (needsRefresh) {
|
|
678
|
+
logger.info("OAuth token refreshed successfully");
|
|
679
|
+
}
|
|
680
|
+
return token;
|
|
681
|
+
})().finally(() => {
|
|
682
|
+
inFlightTokenPromises.delete(client);
|
|
683
|
+
});
|
|
684
|
+
inFlightTokenPromises.set(client, promise);
|
|
685
|
+
}
|
|
686
|
+
const token = await promise;
|
|
687
|
+
onToken(token);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Create and initialize a GitLabOAuth client.
|
|
691
|
+
* Performs initial authentication (triggers browser flow if needed).
|
|
692
|
+
* Returns the client instance and the initial access token.
|
|
693
|
+
*/
|
|
694
|
+
export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
695
|
+
const oauth = createGitLabOAuthClient(gitlabUrl);
|
|
613
696
|
// Single call: triggers browser flow if needed, or reads cached token
|
|
614
697
|
const accessToken = await oauth.getAccessToken();
|
|
615
698
|
return { client: oauth, accessToken };
|
|
@@ -24,13 +24,14 @@ const LEGACY_TOGGLE_ENV = {
|
|
|
24
24
|
function isDefaultToolset(id) {
|
|
25
25
|
return TOOLSET_DEFINITIONS.find(d => d.id === id)?.isDefault ?? false;
|
|
26
26
|
}
|
|
27
|
+
/** Returns the availability note for a toolset group, or undefined when it is on by default. */
|
|
27
28
|
function computeToggleNote(id) {
|
|
28
29
|
if (isDefaultToolset(id))
|
|
29
30
|
return undefined;
|
|
30
31
|
// Synthetic group for tools not in any TOOLSET_DEFINITIONS entry.
|
|
31
32
|
// discover_tools is always exposed; execute_graphql is opt-in via GITLAB_TOOLS.
|
|
32
33
|
if (id === "meta") {
|
|
33
|
-
return "Mixed availability. `discover_tools` is always exposed (the server re-adds it after every toolset filter). `execute_graphql` is not part of any toolset — enable it explicitly with `GITLAB_TOOLS=execute_graphql`.";
|
|
34
|
+
return "Mixed availability. `discover_tools` is always exposed (the server re-adds it after every toolset filter). `execute_graphql` is not part of any toolset — enable it explicitly with `GITLAB_TOOLS=execute_graphql`. Tools excluded from the list (toolset / `GITLAB_TOOLS` / `GITLAB_DENIED_TOOLS_REGEX`) are also rejected on direct calls; `GITLAB_TOOL_POLICY_HIDDEN` tools stay callable unless they match `GITLAB_DENIED_TOOLS_REGEX`.";
|
|
34
35
|
}
|
|
35
36
|
const legacy = LEGACY_TOGGLE_ENV[id];
|
|
36
37
|
if (legacy) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { allTools } from "../../tools/registry.js";
|
|
4
|
+
import { CreateWebhookSchema, GetPipelineJobOutputSchema, ProtectBranchSchema, } from "../../schemas.js";
|
|
5
|
+
import { applyFlagAliases, CliUsageError, parseArgv, parseToolArgs } from "../../cli/args.js";
|
|
6
|
+
function argv(...args) {
|
|
7
|
+
return ["node", "index.js", ...args];
|
|
8
|
+
}
|
|
9
|
+
function schemaFor(toolName) {
|
|
10
|
+
const tool = allTools.find(entry => entry.name === toolName);
|
|
11
|
+
assert.ok(tool, `missing tool ${toolName}`);
|
|
12
|
+
return tool.inputSchema;
|
|
13
|
+
}
|
|
14
|
+
describe("When parseArgv reads a command line", () => {
|
|
15
|
+
describe("with a generic tool invocation", () => {
|
|
16
|
+
it("should keep positionals and kebab flags as snake_case tool args", () => {
|
|
17
|
+
const parsed = parseArgv(argv("tool", "list_issues", "--project-id", "123", "--state", "opened"));
|
|
18
|
+
assert.deepEqual(parsed.positionals, ["tool", "list_issues"]);
|
|
19
|
+
assert.equal(parsed.toolFlags.project_id, "123");
|
|
20
|
+
assert.equal(parsed.toolFlags.state, "opened");
|
|
21
|
+
assert.equal(parsed.output, undefined);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("with global flags mixed in", () => {
|
|
25
|
+
it("should not treat --token --output --yes as tool flags", () => {
|
|
26
|
+
const parsed = parseArgv(argv("tool", "whoami", "--token", "glpat-xxxxxxxxxxxxxxxxxxxx", "--output=json", "--yes"));
|
|
27
|
+
assert.equal(parsed.output, "json");
|
|
28
|
+
assert.equal(parsed.yes, true);
|
|
29
|
+
assert.deepEqual(parsed.toolFlags, {});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("with --args-json", () => {
|
|
33
|
+
it("should parse the object and ignore prototype keys", () => {
|
|
34
|
+
const parsed = parseArgv(argv("tool", "create_issue", "--args-json", '{"title":"bug","__proto__":{"x":1}}'));
|
|
35
|
+
assert.equal(parsed.argsJson?.title, "bug");
|
|
36
|
+
assert.equal(Object.prototype.hasOwnProperty.call(parsed.argsJson, "__proto__"), false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("with --use-oauth before a curated command", () => {
|
|
40
|
+
it("should keep mr list as positionals instead of consuming mr as the flag value", () => {
|
|
41
|
+
const parsed = parseArgv(argv("--use-oauth", "mr", "list"));
|
|
42
|
+
assert.deepEqual(parsed.positionals, ["mr", "list"]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("with --read-only false before a curated command", () => {
|
|
46
|
+
it("should keep false as the flag value and mr list as positionals", () => {
|
|
47
|
+
const parsed = parseArgv(argv("--read-only", "false", "mr", "list"));
|
|
48
|
+
assert.deepEqual(parsed.positionals, ["mr", "list"]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe("When parseToolArgs validates a real schema", () => {
|
|
53
|
+
describe("with whoami", () => {
|
|
54
|
+
it("should accept an empty flag set", () => {
|
|
55
|
+
const args = parseToolArgs({
|
|
56
|
+
schema: schemaFor("whoami"),
|
|
57
|
+
flags: {},
|
|
58
|
+
argsJson: undefined,
|
|
59
|
+
extraArgs: undefined,
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(args, {});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("with create_issue missing title", () => {
|
|
65
|
+
it("should throw a usage error for the required flag", () => {
|
|
66
|
+
assert.throws(() => parseToolArgs({
|
|
67
|
+
schema: schemaFor("create_issue"),
|
|
68
|
+
flags: { project_id: "123" },
|
|
69
|
+
argsJson: undefined,
|
|
70
|
+
extraArgs: undefined,
|
|
71
|
+
}), (error) => error instanceof CliUsageError && error.message.includes("--title"));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe("with flags overlaying --args-json", () => {
|
|
75
|
+
it("should let flags win", () => {
|
|
76
|
+
const args = parseToolArgs({
|
|
77
|
+
schema: schemaFor("create_issue"),
|
|
78
|
+
flags: { title: "from-flag" },
|
|
79
|
+
argsJson: { project_id: "123", title: "from-json" },
|
|
80
|
+
extraArgs: undefined,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(args.project_id, "123");
|
|
83
|
+
assert.equal(args.title, "from-flag");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe("with an unknown flag", () => {
|
|
87
|
+
it("should throw a usage error", () => {
|
|
88
|
+
assert.throws(() => parseToolArgs({
|
|
89
|
+
schema: schemaFor("whoami"),
|
|
90
|
+
flags: { nope: "1" },
|
|
91
|
+
argsJson: undefined,
|
|
92
|
+
extraArgs: undefined,
|
|
93
|
+
}), (error) => error instanceof CliUsageError && error.message === "Unknown flag: --nope");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("When parseToolArgs coerces scalar flags from the schema", () => {
|
|
98
|
+
describe("with a number flag", () => {
|
|
99
|
+
it("should pass a number that the tool schema accepts", () => {
|
|
100
|
+
const args = parseToolArgs({
|
|
101
|
+
schema: schemaFor("get_pipeline_job_output"),
|
|
102
|
+
flags: { project_id: "1", job_id: "9", limit: "10" },
|
|
103
|
+
argsJson: undefined,
|
|
104
|
+
extraArgs: undefined,
|
|
105
|
+
});
|
|
106
|
+
assert.equal(GetPipelineJobOutputSchema.safeParse(args).success, true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("with a boolean flag", () => {
|
|
110
|
+
it("should pass a boolean that the tool schema accepts", () => {
|
|
111
|
+
const args = parseToolArgs({
|
|
112
|
+
schema: schemaFor("create_webhook"),
|
|
113
|
+
flags: { project_id: "1", url: "https://example.com/hook", push_events: "false" },
|
|
114
|
+
argsJson: undefined,
|
|
115
|
+
extraArgs: undefined,
|
|
116
|
+
});
|
|
117
|
+
assert.equal(CreateWebhookSchema.safeParse(args).success, true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe("with an integer flag", () => {
|
|
121
|
+
it("should pass an integer that the tool schema accepts", () => {
|
|
122
|
+
const args = parseToolArgs({
|
|
123
|
+
schema: schemaFor("protect_branch"),
|
|
124
|
+
flags: { project_id: "1", branch_name: "main", push_access_level: "40" },
|
|
125
|
+
argsJson: undefined,
|
|
126
|
+
extraArgs: undefined,
|
|
127
|
+
});
|
|
128
|
+
assert.strictEqual(args.push_access_level, 40);
|
|
129
|
+
assert.equal(ProtectBranchSchema.safeParse(args).success, true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe("with a field that also accepts strings", () => {
|
|
133
|
+
it("should keep the raw string", () => {
|
|
134
|
+
const args = parseToolArgs({
|
|
135
|
+
schema: schemaFor("list_webhook_events"),
|
|
136
|
+
flags: { project_id: "1", hook_id: "2", status: "500" },
|
|
137
|
+
argsJson: undefined,
|
|
138
|
+
extraArgs: undefined,
|
|
139
|
+
});
|
|
140
|
+
assert.equal(args.status, "500");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe("with a non-boolean value for a boolean flag", () => {
|
|
144
|
+
it("should throw a usage error", () => {
|
|
145
|
+
assert.throws(() => parseToolArgs({
|
|
146
|
+
schema: schemaFor("create_webhook"),
|
|
147
|
+
flags: { project_id: "1", url: "https://example.com/hook", push_events: "yes" },
|
|
148
|
+
argsJson: undefined,
|
|
149
|
+
extraArgs: undefined,
|
|
150
|
+
}), (error) => error instanceof CliUsageError && error.message === "--push-events must be true or false");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe("with a fraction for an integer flag", () => {
|
|
154
|
+
it("should throw a usage error", () => {
|
|
155
|
+
assert.throws(() => parseToolArgs({
|
|
156
|
+
schema: schemaFor("protect_branch"),
|
|
157
|
+
flags: { project_id: "1", branch_name: "main", push_access_level: "40.5" },
|
|
158
|
+
argsJson: undefined,
|
|
159
|
+
extraArgs: undefined,
|
|
160
|
+
}), (error) => error instanceof CliUsageError &&
|
|
161
|
+
error.message === "--push-access-level must be an integer");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe("When applyFlagAliases rewrites curated shorts", () => {
|
|
166
|
+
describe("with --mr-iid", () => {
|
|
167
|
+
it("should map onto merge_request_iid", () => {
|
|
168
|
+
const mapped = applyFlagAliases({ mr_iid: "45", project_id: "123" }, {
|
|
169
|
+
mr_iid: "merge_request_iid",
|
|
170
|
+
});
|
|
171
|
+
assert.equal(mapped.merge_request_iid, "45");
|
|
172
|
+
assert.equal(mapped.project_id, "123");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as url from "node:url";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { isBooleanFlagLiteral, nextBooleanFlagValue } from "../../cli-boolean-flags.js";
|
|
7
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
8
|
+
const SNAPSHOT_PATH = path.resolve(__dirname, "load-config-snapshot.ts");
|
|
9
|
+
function loadConfig(argv) {
|
|
10
|
+
const childEnv = {};
|
|
11
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
12
|
+
if (value !== undefined) {
|
|
13
|
+
childEnv[key] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
delete childEnv.GITLAB_READ_ONLY_MODE;
|
|
17
|
+
delete childEnv.GITLAB_USE_OAUTH;
|
|
18
|
+
const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", SNAPSHOT_PATH, ...argv], {
|
|
19
|
+
env: childEnv,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
});
|
|
23
|
+
return readBooleanConfigSnapshot(JSON.parse(stdout));
|
|
24
|
+
}
|
|
25
|
+
function readBooleanConfigSnapshot(value) {
|
|
26
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
27
|
+
throw new Error("config snapshot was not an object");
|
|
28
|
+
}
|
|
29
|
+
if (!("GITLAB_READ_ONLY_MODE" in value) || typeof value.GITLAB_READ_ONLY_MODE !== "boolean") {
|
|
30
|
+
throw new Error("GITLAB_READ_ONLY_MODE missing");
|
|
31
|
+
}
|
|
32
|
+
if (!("USE_OAUTH" in value) || typeof value.USE_OAUTH !== "boolean") {
|
|
33
|
+
throw new Error("USE_OAUTH missing");
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
GITLAB_READ_ONLY_MODE: value.GITLAB_READ_ONLY_MODE,
|
|
37
|
+
USE_OAUTH: value.USE_OAUTH,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
describe("When nextBooleanFlagValue reads the following token", () => {
|
|
41
|
+
describe("with a positional command", () => {
|
|
42
|
+
it("should treat the flag as true and leave the command", () => {
|
|
43
|
+
assert.deepEqual(nextBooleanFlagValue("mr"), { value: "true", consumeNext: false });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe("with an explicit false", () => {
|
|
47
|
+
it("should consume the literal", () => {
|
|
48
|
+
assert.deepEqual(nextBooleanFlagValue("false"), { value: "false", consumeNext: true });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("with a missing token", () => {
|
|
52
|
+
it("should default to true", () => {
|
|
53
|
+
assert.deepEqual(nextBooleanFlagValue(undefined), { value: "true", consumeNext: false });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("When isBooleanFlagLiteral inspects a token", () => {
|
|
58
|
+
describe("with true", () => {
|
|
59
|
+
it("should accept it", () => {
|
|
60
|
+
assert.equal(isBooleanFlagLiteral("true"), true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe("with a command name", () => {
|
|
64
|
+
it("should reject it", () => {
|
|
65
|
+
assert.equal(isBooleanFlagLiteral("mr"), false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("When config.ts parses space-separated boolean flags", () => {
|
|
70
|
+
describe("with --read-only as the last argument", () => {
|
|
71
|
+
it("should turn read-only mode on", () => {
|
|
72
|
+
const cfg = loadConfig(["--read-only"]);
|
|
73
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("with --read-only followed by another flag", () => {
|
|
77
|
+
it("should turn read-only mode on", () => {
|
|
78
|
+
const cfg = loadConfig(["--read-only", "--use-pipeline"]);
|
|
79
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("with an empty --read-only= assignment", () => {
|
|
83
|
+
it("should leave read-only mode off", () => {
|
|
84
|
+
const cfg = loadConfig(["--read-only="]);
|
|
85
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("with --read-only false", () => {
|
|
89
|
+
it("should keep read-only off", () => {
|
|
90
|
+
const cfg = loadConfig(["--read-only", "false"]);
|
|
91
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("with --read-only before a command", () => {
|
|
95
|
+
it("should turn read-only on without consuming mr", () => {
|
|
96
|
+
const cfg = loadConfig(["--read-only", "mr", "list"]);
|
|
97
|
+
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("with --use-oauth false", () => {
|
|
101
|
+
it("should keep oauth off", () => {
|
|
102
|
+
const cfg = loadConfig(["--use-oauth", "false"]);
|
|
103
|
+
assert.equal(cfg.USE_OAUTH, false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { compactMcpToolResult } from "../../cli/compact-mcp-result.js";
|
|
4
|
+
function textResult(text) {
|
|
5
|
+
return { content: [{ type: "text", text }] };
|
|
6
|
+
}
|
|
7
|
+
describe("When compactMcpToolResult wraps an MCP tool reply", () => {
|
|
8
|
+
describe("with compact mode disabled", () => {
|
|
9
|
+
it("should return the original result", () => {
|
|
10
|
+
const result = textResult("[]");
|
|
11
|
+
const compacted = compactMcpToolResult({
|
|
12
|
+
result,
|
|
13
|
+
enabled: false,
|
|
14
|
+
maxChars: 1,
|
|
15
|
+
toolName: "list_issues",
|
|
16
|
+
args: { project_id: "1" },
|
|
17
|
+
});
|
|
18
|
+
assert.equal(compacted, result);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe("with a small payload", () => {
|
|
22
|
+
it("should leave the result alone", () => {
|
|
23
|
+
const result = textResult('{"iid":1}');
|
|
24
|
+
const compacted = compactMcpToolResult({
|
|
25
|
+
result,
|
|
26
|
+
enabled: true,
|
|
27
|
+
maxChars: 4000,
|
|
28
|
+
toolName: "get_issue",
|
|
29
|
+
args: { project_id: "1", issue_iid: 1 },
|
|
30
|
+
});
|
|
31
|
+
assert.equal(compacted, result);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe("with a long issue list", () => {
|
|
35
|
+
it("should keep a preview and a CLI replay command", () => {
|
|
36
|
+
const issues = Array.from({ length: 20 }, (_, index) => ({ iid: index + 1, title: "x".repeat(80) }));
|
|
37
|
+
const compacted = compactMcpToolResult({
|
|
38
|
+
result: textResult(JSON.stringify(issues)),
|
|
39
|
+
enabled: true,
|
|
40
|
+
maxChars: 200,
|
|
41
|
+
toolName: "list_issues",
|
|
42
|
+
args: { project_id: "123", state: "opened" },
|
|
43
|
+
});
|
|
44
|
+
assert.ok(isRecord(compacted));
|
|
45
|
+
assert.equal(Object.prototype.hasOwnProperty.call(compacted, "structuredContent"), false);
|
|
46
|
+
const content = compacted.content;
|
|
47
|
+
assert.ok(Array.isArray(content));
|
|
48
|
+
const block = content[0];
|
|
49
|
+
assert.ok(isRecord(block) && typeof block.text === "string");
|
|
50
|
+
const payload = JSON.parse(block.text);
|
|
51
|
+
assert.ok(isRecord(payload));
|
|
52
|
+
assert.equal(payload.compact, true);
|
|
53
|
+
assert.equal(payload.tool, "list_issues");
|
|
54
|
+
assert.equal(payload.items_total, 20);
|
|
55
|
+
assert.ok(Array.isArray(payload.preview));
|
|
56
|
+
assert.equal(payload.preview.length, 5);
|
|
57
|
+
assert.equal(payload.cli, "zereight-mcp-gitlab tool list_issues --project-id 123 --state opened");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe("with structuredContent on a large payload", () => {
|
|
61
|
+
it("should drop structuredContent so the full object cannot leak", () => {
|
|
62
|
+
const issues = Array.from({ length: 20 }, (_, index) => ({ iid: index + 1, title: "x".repeat(80) }));
|
|
63
|
+
const compacted = compactMcpToolResult({
|
|
64
|
+
result: {
|
|
65
|
+
content: [{ type: "text", text: JSON.stringify(issues) }],
|
|
66
|
+
structuredContent: issues,
|
|
67
|
+
},
|
|
68
|
+
enabled: true,
|
|
69
|
+
maxChars: 200,
|
|
70
|
+
toolName: "list_issues",
|
|
71
|
+
args: { project_id: "1" },
|
|
72
|
+
});
|
|
73
|
+
assert.ok(isRecord(compacted));
|
|
74
|
+
assert.equal(Object.prototype.hasOwnProperty.call(compacted, "structuredContent"), false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe("with an error result", () => {
|
|
78
|
+
it("should not compact", () => {
|
|
79
|
+
const result = { content: [{ type: "text", text: "boom".repeat(500) }], isError: true };
|
|
80
|
+
const compacted = compactMcpToolResult({
|
|
81
|
+
result,
|
|
82
|
+
enabled: true,
|
|
83
|
+
maxChars: 10,
|
|
84
|
+
toolName: "list_issues",
|
|
85
|
+
args: {},
|
|
86
|
+
});
|
|
87
|
+
assert.equal(compacted, result);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
function isRecord(value) {
|
|
92
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { exposureRefusalMessage, isToolExposed, parseToolNameSet } from "../../cli/exposure.js";
|
|
4
|
+
import { compileDeniedToolsRegex } from "../../tools/denied-regex.js";
|
|
5
|
+
import { parseEnabledToolsets } from "../../tools/registry.js";
|
|
6
|
+
const ISSUES = parseEnabledToolsets("issues");
|
|
7
|
+
const EMPTY = new Set();
|
|
8
|
+
function exposure(overrides) {
|
|
9
|
+
return {
|
|
10
|
+
toolName: overrides.toolName,
|
|
11
|
+
enabledToolsets: overrides.enabledToolsets ?? ISSUES,
|
|
12
|
+
individuallyEnabledTools: overrides.individuallyEnabledTools ?? EMPTY,
|
|
13
|
+
featureFlagOverrides: overrides.featureFlagOverrides ?? EMPTY,
|
|
14
|
+
deniedRegex: overrides.deniedRegex,
|
|
15
|
+
hiddenTools: overrides.hiddenTools ?? EMPTY,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe("When isToolExposed applies MCP filters", () => {
|
|
19
|
+
describe("with a tool in the enabled toolset", () => {
|
|
20
|
+
it("should expose list_issues", () => {
|
|
21
|
+
assert.equal(isToolExposed(exposure({ toolName: "list_issues" })), true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("with a tool outside the enabled toolsets", () => {
|
|
25
|
+
it("should hide list_pipelines", () => {
|
|
26
|
+
assert.equal(isToolExposed(exposure({ toolName: "list_pipelines" })), false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("with GITLAB_TOOLS", () => {
|
|
30
|
+
it("should expose an individually enabled tool", () => {
|
|
31
|
+
assert.equal(isToolExposed(exposure({
|
|
32
|
+
toolName: "list_pipelines",
|
|
33
|
+
individuallyEnabledTools: new Set(["list_pipelines"]),
|
|
34
|
+
})), true);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe("with a legacy feature-flag override", () => {
|
|
38
|
+
it("should expose the overridden wiki tool", () => {
|
|
39
|
+
assert.equal(isToolExposed(exposure({
|
|
40
|
+
toolName: "list_wiki_pages",
|
|
41
|
+
featureFlagOverrides: new Set(["list_wiki_pages"]),
|
|
42
|
+
})), true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("with GITLAB_DENIED_TOOLS_REGEX", () => {
|
|
46
|
+
it("should hide a matching tool even when the toolset is enabled", () => {
|
|
47
|
+
assert.equal(isToolExposed(exposure({
|
|
48
|
+
toolName: "list_issues",
|
|
49
|
+
deniedRegex: /^list_/,
|
|
50
|
+
})), false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("with GITLAB_TOOL_POLICY_HIDDEN", () => {
|
|
54
|
+
it("should hide the named tool", () => {
|
|
55
|
+
assert.equal(isToolExposed(exposure({
|
|
56
|
+
toolName: "list_issues",
|
|
57
|
+
hiddenTools: new Set(["list_issues"]),
|
|
58
|
+
})), false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("When exposureRefusalMessage explains a block", () => {
|
|
63
|
+
describe("with a hidden tool", () => {
|
|
64
|
+
it("should mention GITLAB_TOOL_POLICY_HIDDEN", () => {
|
|
65
|
+
const message = exposureRefusalMessage(exposure({ toolName: "list_issues", hiddenTools: new Set(["list_issues"]) }));
|
|
66
|
+
assert.match(String(message), /GITLAB_TOOL_POLICY_HIDDEN/);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("with a denied regex", () => {
|
|
70
|
+
it("should mention GITLAB_DENIED_TOOLS_REGEX", () => {
|
|
71
|
+
const message = exposureRefusalMessage(exposure({ toolName: "list_issues", deniedRegex: /^list_/ }));
|
|
72
|
+
assert.match(String(message), /GITLAB_DENIED_TOOLS_REGEX/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe("with a toolset miss", () => {
|
|
76
|
+
it("should mention GITLAB_TOOLSETS", () => {
|
|
77
|
+
const message = exposureRefusalMessage(exposure({ toolName: "list_pipelines" }));
|
|
78
|
+
assert.match(String(message), /GITLAB_TOOLSETS/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("When parseToolNameSet reads a policy list", () => {
|
|
83
|
+
describe("with comma-separated names", () => {
|
|
84
|
+
it("should trim and drop empties", () => {
|
|
85
|
+
assert.deepEqual([...parseToolNameSet(" push_files, delete_issue , ")], ["push_files", "delete_issue"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe("When compileDeniedToolsRegex validates a pattern", () => {
|
|
90
|
+
describe("with nested quantifiers", () => {
|
|
91
|
+
it("should ignore the pattern", () => {
|
|
92
|
+
const compiled = compileDeniedToolsRegex("(a+)+");
|
|
93
|
+
assert.equal(compiled.regex, undefined);
|
|
94
|
+
assert.match(String(compiled.error), /nested quantifiers/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("with invalid characters", () => {
|
|
98
|
+
it("should reject unsafe characters", () => {
|
|
99
|
+
const compiled = compileDeniedToolsRegex("create_; rm -rf /");
|
|
100
|
+
assert.equal(compiled.regex, undefined);
|
|
101
|
+
assert.match(String(compiled.error), /invalid characters/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|