@zereight/mcp-gitlab 2.1.68 → 2.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.ko.md +11 -3
  2. package/README.md +237 -232
  3. package/README.pt-BR.md +8 -4
  4. package/README.zh-CN.md +7 -3
  5. package/build/cli/args.js +298 -0
  6. package/build/cli/compact-mcp-result.js +87 -0
  7. package/build/cli/curated.js +243 -0
  8. package/build/cli/exposure.js +72 -0
  9. package/build/cli/help.js +108 -0
  10. package/build/cli/output.js +65 -0
  11. package/build/cli/replay-command.js +53 -0
  12. package/build/cli/router.js +158 -0
  13. package/build/cli/tables.js +117 -0
  14. package/build/cli-boolean-flags.js +38 -0
  15. package/build/cli-command.js +14 -4
  16. package/build/config.js +30 -7
  17. package/build/index.js +176 -90
  18. package/build/oauth.js +93 -10
  19. package/build/scripts/generate-tool-docs.js +2 -1
  20. package/build/test/cli/args.test.js +175 -0
  21. package/build/test/cli/boolean-flags.test.js +106 -0
  22. package/build/test/cli/compact-mcp-result.test.js +93 -0
  23. package/build/test/cli/credentials.test.js +153 -0
  24. package/build/test/cli/exposure.test.js +104 -0
  25. package/build/test/cli/load-config-snapshot.js +5 -0
  26. package/build/test/cli/output.test.js +74 -0
  27. package/build/test/cli/replay-command.test.js +37 -0
  28. package/build/test/cli/router.test.js +183 -0
  29. package/build/test/gitlab-api-tool-errors.test.js +112 -0
  30. package/build/test/oauth-device-flow-tests.js +10 -0
  31. package/build/test/oauth-download-url.test.js +157 -0
  32. package/build/test/oauth-startup.test.js +204 -0
  33. package/build/test/test-dynamic-project-scope.js +2 -0
  34. package/build/test/test-geteffectiveprojectid.js +20 -26
  35. package/build/test/test-permission-mode.js +86 -0
  36. package/build/test/test-pipeline-schedules.js +3 -1
  37. package/build/test/utils/url.test.js +14 -1
  38. package/build/tools/denied-regex.js +38 -0
  39. package/build/utils/url.js +15 -0
  40. 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 errorText = await response.text();
176
- throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
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
- const callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888");
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
- logger.error({ err: error }, "Token refresh failed. Starting new OAuth flow...");
533
- tokenData = await this.startOAuthFlow();
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
- * Create and initialize a GitLabOAuth client.
592
- * Performs initial authentication (triggers browser flow if needed).
593
- * Returns the client instance and the initial access token.
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 async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
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
- const oauth = new GitLabOAuth({
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
+ }