@zereight/mcp-gitlab 2.1.69 → 2.2.0

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 (155) hide show
  1. package/README.ko.md +10 -4
  2. package/README.md +238 -233
  3. package/README.pt-BR.md +4 -4
  4. package/README.zh-CN.md +6 -4
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +456 -237
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/build/utils/url.js +15 -0
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  50. package/build/test/gitlab-diff-schema.test.js +0 -36
  51. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  52. package/build/test/gitlab-tag-schema.test.js +0 -51
  53. package/build/test/group-milestone-schema.test.js +0 -45
  54. package/build/test/mcp-oauth-tests.js +0 -905
  55. package/build/test/mcp-server-name.test.js +0 -87
  56. package/build/test/merge-merge-request-schema.test.js +0 -75
  57. package/build/test/multi-server-test.js +0 -190
  58. package/build/test/no-proxy-integration-test.js +0 -183
  59. package/build/test/no-proxy-test.js +0 -149
  60. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  61. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  62. package/build/test/oauth-device-flow-tests.js +0 -450
  63. package/build/test/oauth-startup.test.js +0 -204
  64. package/build/test/oauth-tests.js +0 -428
  65. package/build/test/path-segment-encoding.test.js +0 -98
  66. package/build/test/remote-auth-simple-test.js +0 -277
  67. package/build/test/remote-auth-tests.js +0 -314
  68. package/build/test/response-masking.test.js +0 -445
  69. package/build/test/schema-tests.js +0 -1679
  70. package/build/test/server/metrics.test.js +0 -47
  71. package/build/test/server/request-helpers.test.js +0 -48
  72. package/build/test/server/version.test.js +0 -13
  73. package/build/test/sse-auth-guard.test.js +0 -96
  74. package/build/test/sse-session-limits.test.js +0 -273
  75. package/build/test/stateless/callback-proxy.test.js +0 -510
  76. package/build/test/stateless/client-id.test.js +0 -177
  77. package/build/test/stateless/codec.test.js +0 -328
  78. package/build/test/stateless/config-ttl.test.js +0 -149
  79. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  80. package/build/test/stateless/session-id-integration.test.js +0 -675
  81. package/build/test/stateless/session-id.test.js +0 -157
  82. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  83. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  84. package/build/test/streamable-http-sse-stream.test.js +0 -350
  85. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  86. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  87. package/build/test/test-all-transport-server.js +0 -255
  88. package/build/test/test-auth-retry.js +0 -195
  89. package/build/test/test-blank-filters.js +0 -156
  90. package/build/test/test-ci-catalog.js +0 -177
  91. package/build/test/test-ci-lint.js +0 -191
  92. package/build/test/test-ci-variables.js +0 -309
  93. package/build/test/test-create-repository.js +0 -120
  94. package/build/test/test-dependency-proxy.js +0 -195
  95. package/build/test/test-deployment-tools.js +0 -463
  96. package/build/test/test-download-attachment.js +0 -190
  97. package/build/test/test-dynamic-project-scope.js +0 -524
  98. package/build/test/test-empty-toolsets.js +0 -535
  99. package/build/test/test-get-file-blame.js +0 -145
  100. package/build/test/test-get-file-contents.js +0 -128
  101. package/build/test/test-geteffectiveprojectid.js +0 -812
  102. package/build/test/test-health-check.js +0 -115
  103. package/build/test/test-issue-description-patch.js +0 -256
  104. package/build/test/test-job-artifacts.js +0 -275
  105. package/build/test/test-json-schema.js +0 -166
  106. package/build/test/test-list-group-members.js +0 -150
  107. package/build/test/test-list-group-merge-requests.js +0 -165
  108. package/build/test/test-list-issues.js +0 -170
  109. package/build/test/test-list-merge-requests.js +0 -201
  110. package/build/test/test-list-milestones.js +0 -145
  111. package/build/test/test-list-project-members.js +0 -132
  112. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  113. package/build/test/test-merge-request-approvals.js +0 -187
  114. package/build/test/test-merge-request-pipelines.js +0 -106
  115. package/build/test/test-mr-diffs-filter.js +0 -132
  116. package/build/test/test-mr-file-diffs.js +0 -251
  117. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  118. package/build/test/test-orbit.js +0 -155
  119. package/build/test/test-permission-mode.js +0 -424
  120. package/build/test/test-pipeline-schedules.js +0 -482
  121. package/build/test/test-protected-branches.js +0 -174
  122. package/build/test/test-remote-downloads.js +0 -510
  123. package/build/test/test-search-code.js +0 -275
  124. package/build/test/test-snippets.js +0 -735
  125. package/build/test/test-tags.js +0 -220
  126. package/build/test/test-todos.js +0 -195
  127. package/build/test/test-token-optimizations.js +0 -691
  128. package/build/test/test-toolset-filtering.js +0 -499
  129. package/build/test/test-update-issue-slim.js +0 -141
  130. package/build/test/test-update-project.js +0 -112
  131. package/build/test/test-upload-markdown.js +0 -206
  132. package/build/test/test-vulnerabilities.js +0 -399
  133. package/build/test/test-webhooks.js +0 -218
  134. package/build/test/tool-description-quality.test.js +0 -50
  135. package/build/test/utils/bulk-publish-options.test.js +0 -32
  136. package/build/test/utils/download-token.test.js +0 -35
  137. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  138. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  139. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  140. package/build/test/utils/graphql-query.test.js +0 -168
  141. package/build/test/utils/helpers-path.test.js +0 -104
  142. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  143. package/build/test/utils/masking.test.js +0 -384
  144. package/build/test/utils/merge-request-position.test.js +0 -57
  145. package/build/test/utils/mock-gitlab-server.js +0 -614
  146. package/build/test/utils/proxy-client-ip.test.js +0 -28
  147. package/build/test/utils/redact-sensitive.test.js +0 -32
  148. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  149. package/build/test/utils/server-launcher.js +0 -231
  150. package/build/test/utils/tool-args.test.js +0 -210
  151. package/build/test/utils/url.test.js +0 -128
  152. package/build/test/utils/version-check.test.js +0 -239
  153. package/build/test/utils/wiki-title.test.js +0 -21
  154. package/build/test-note.js +0 -54
  155. package/build/test-resolve-issue-note.js +0 -127
@@ -1,93 +0,0 @@
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
- }
@@ -1,104 +0,0 @@
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
- });
@@ -1,5 +0,0 @@
1
- import { GITLAB_READ_ONLY_MODE, USE_OAUTH } from "../../config.js";
2
- process.stdout.write(JSON.stringify({
3
- GITLAB_READ_ONLY_MODE,
4
- USE_OAUTH,
5
- }));
@@ -1,74 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { formatCliError, formatToolOutput } from "../../cli/output.js";
4
- describe("When formatToolOutput prints a tool result", () => {
5
- describe("with json mode", () => {
6
- it("should write the unwrapped payload as JSON", () => {
7
- const formatted = formatToolOutput({
8
- result: { content: [{ type: "text", text: JSON.stringify({ id: 1, username: "tao" }) }] },
9
- mode: "json",
10
- tableSpec: undefined,
11
- });
12
- assert.equal(formatted.stdout, "{\"id\":1,\"username\":\"tao\"}\n");
13
- assert.equal(formatted.stderr, "");
14
- assert.equal(formatted.isError, false);
15
- });
16
- });
17
- describe("with table mode for a list", () => {
18
- it("should print iid state title columns", () => {
19
- const formatted = formatToolOutput({
20
- result: {
21
- content: [
22
- {
23
- type: "text",
24
- text: JSON.stringify([
25
- { iid: 45, state: "opened", title: "fix: pipeline retry race" },
26
- { iid: 44, state: "opened", title: "chore: bump deps" },
27
- ]),
28
- },
29
- ],
30
- },
31
- mode: "table",
32
- tableSpec: { kind: "list", columns: ["iid", "state", "title"] },
33
- });
34
- assert.match(formatted.stdout, /IID\s+STATE\s+TITLE/);
35
- assert.match(formatted.stdout, /45\s+opened\s+fix: pipeline retry race/);
36
- assert.equal(formatted.stderr, "");
37
- });
38
- });
39
- describe("with a non-text content block first", () => {
40
- it("should unwrap the first text payload", () => {
41
- const formatted = formatToolOutput({
42
- result: {
43
- content: [
44
- { type: "image", data: "xxx" },
45
- { type: "text", text: JSON.stringify({ id: 2 }) },
46
- ],
47
- },
48
- mode: "json",
49
- tableSpec: undefined,
50
- });
51
- assert.equal(formatted.stdout, "{\"id\":2}\n");
52
- });
53
- });
54
- describe("with an API-style error object", () => {
55
- it("should mark the result as an error", () => {
56
- const formatted = formatToolOutput({
57
- result: { content: [{ type: "text", text: "GitLab API error: 404 Not Found" }], isError: true },
58
- mode: "json",
59
- tableSpec: undefined,
60
- });
61
- assert.equal(formatted.isError, true);
62
- });
63
- });
64
- });
65
- describe("When formatCliError prints a thrown error", () => {
66
- describe("with a token in the message", () => {
67
- it("should redact the token", () => {
68
- const leaked = `glpat-${"notarealtokenvalue"}`;
69
- const text = formatCliError(new Error(`auth failed ${leaked}`));
70
- assert.match(text, /\[REDACTED\]/);
71
- assert.equal(text.includes(leaked), false);
72
- });
73
- });
74
- });
@@ -1,37 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { buildToolReplayCommand } from "../../cli/replay-command.js";
4
- describe("When buildToolReplayCommand prints a CLI invocation", () => {
5
- describe("with scalar tool arguments", () => {
6
- it("should emit kebab-case flags without the token", () => {
7
- const command = buildToolReplayCommand("list_issues", {
8
- project_id: "123",
9
- state: "opened",
10
- token: "glpat-secret",
11
- });
12
- assert.equal(command, "zereight-mcp-gitlab tool list_issues --project-id 123 --state opened");
13
- });
14
- });
15
- describe("with a nested object", () => {
16
- it("should pass it through --args-json", () => {
17
- const command = buildToolReplayCommand("execute_graphql", {
18
- query: "{ currentUser { id } }",
19
- variables: { n: 1 },
20
- });
21
- assert.match(command, /tool execute_graphql/);
22
- assert.match(command, /--query/);
23
- assert.match(command, /--args-json/);
24
- assert.equal(command.includes("glpat"), false);
25
- });
26
- });
27
- describe("with confirmation metadata", () => {
28
- it("should omit _confirmed", () => {
29
- const command = buildToolReplayCommand("push_files", {
30
- project_id: "1",
31
- _confirmed: true,
32
- });
33
- assert.equal(command.includes("_confirmed"), false);
34
- assert.match(command, /--project-id 1/);
35
- });
36
- });
37
- });
@@ -1,183 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { isCliInvocation, resolveCli } from "../../cli/router.js";
4
- function argv(...args) {
5
- return ["node", "index.js", ...args];
6
- }
7
- const OPEN_EXPOSURE = {
8
- isExposed() {
9
- return true;
10
- },
11
- needsConfirmation() {
12
- return false;
13
- },
14
- exposureRefusal() {
15
- return undefined;
16
- },
17
- };
18
- function resolve(args, extra = {}) {
19
- return resolveCli(args, { exposure: extra.exposure ?? OPEN_EXPOSURE, permissionMode: extra.permissionMode });
20
- }
21
- describe("When resolveCli classifies argv", () => {
22
- describe("with no positional command", () => {
23
- it("should stay on the MCP server path", () => {
24
- const resolved = resolve(argv("--token", "glpat-xxxxxxxxxxxxxxxxxxxx"));
25
- assert.equal(resolved.kind, "server");
26
- });
27
- });
28
- describe("with --help", () => {
29
- it("should print global help without starting the server", () => {
30
- const resolved = resolve(argv("--help"));
31
- assert.equal(resolved.kind, "help");
32
- if (resolved.kind === "help") {
33
- assert.match(resolved.text, /tool <tool-name>/);
34
- assert.match(resolved.text, /auth/);
35
- }
36
- });
37
- });
38
- describe("with --help after an unknown positional", () => {
39
- it("should print global help instead of starting the server", () => {
40
- const resolved = resolve(argv("not-a-command", "--help"));
41
- assert.equal(resolved.kind, "help");
42
- });
43
- });
44
- describe("with tool whoami", () => {
45
- it("should run whoami with json output by default", () => {
46
- const resolved = resolve(argv("tool", "whoami"));
47
- assert.equal(resolved.kind, "run");
48
- if (resolved.kind === "run") {
49
- assert.equal(resolved.toolName, "whoami");
50
- assert.equal(resolved.output, "json");
51
- assert.deepEqual(resolved.args, {});
52
- }
53
- });
54
- });
55
- });
56
- describe("When a curated command is resolved", () => {
57
- describe("with mr list --group-id", () => {
58
- it("should call list_group_merge_requests", () => {
59
- const resolved = resolve(argv("mr", "list", "--group-id", "42", "--state", "opened"));
60
- assert.equal(resolved.kind, "run");
61
- if (resolved.kind === "run") {
62
- assert.equal(resolved.toolName, "list_group_merge_requests");
63
- assert.equal(resolved.args.group_id, "42");
64
- assert.equal(resolved.output, "table");
65
- }
66
- });
67
- });
68
- describe("with issue close", () => {
69
- it("should call update_issue with state_event close", () => {
70
- const resolved = resolve(argv("issue", "close", "--project-id", "123", "--issue-iid", "9"));
71
- assert.equal(resolved.kind, "run");
72
- if (resolved.kind === "run") {
73
- assert.equal(resolved.toolName, "update_issue");
74
- assert.equal(resolved.args.issue_iid, "9");
75
- assert.equal(resolved.args.state_event, "close");
76
- }
77
- });
78
- });
79
- describe("with mr merge and --yes=false", () => {
80
- it("should reject the inline value instead of merging", () => {
81
- const resolved = resolve(argv("mr", "merge", "--project-id", "123", "--mr-iid", "45", "--yes=false"));
82
- assert.equal(resolved.kind, "usage");
83
- if (resolved.kind === "usage") {
84
- assert.match(resolved.message, /--yes does not accept a value/);
85
- }
86
- });
87
- });
88
- describe("with mr merge and no --yes", () => {
89
- it("should refuse until --yes is passed without dumping argument values", () => {
90
- const resolved = resolve(argv("mr", "merge", "--project-id", "123", "--mr-iid", "45"));
91
- assert.equal(resolved.kind, "refused");
92
- if (resolved.kind === "refused") {
93
- assert.match(resolved.message, /--yes/);
94
- assert.match(resolved.message, /merge_merge_request/);
95
- assert.match(resolved.message, /project_id/);
96
- assert.equal(resolved.message.includes("123"), false);
97
- }
98
- });
99
- });
100
- describe("with variable list scoped via --args-json", () => {
101
- it("should call list_group_variables when group_id is only in JSON", () => {
102
- const resolved = resolve(argv("variable", "list", "--args-json", '{"group_id":"42"}'));
103
- assert.equal(resolved.kind, "run");
104
- if (resolved.kind === "run") {
105
- assert.equal(resolved.toolName, "list_group_variables");
106
- assert.equal(resolved.args.group_id, "42");
107
- }
108
- });
109
- });
110
- });
111
- describe("When permission mode blocks a write", () => {
112
- describe("with readonly mode and issue create", () => {
113
- it("should refuse before dispatch", () => {
114
- const resolved = resolve(argv("issue", "create", "--project-id", "123", "--title", "bug"), { permissionMode: "readonly" });
115
- assert.equal(resolved.kind, "refused");
116
- if (resolved.kind === "refused") {
117
- assert.match(resolved.message, /read-only/);
118
- }
119
- });
120
- });
121
- });
122
- describe("When isCliInvocation inspects argv", () => {
123
- describe("with a curated group", () => {
124
- it("should treat it as a human CLI invocation", () => {
125
- assert.equal(isCliInvocation(argv("mr", "list")), true);
126
- });
127
- });
128
- describe("with --help", () => {
129
- it("should treat it as a human CLI invocation", () => {
130
- assert.equal(isCliInvocation(argv("--help")), true);
131
- });
132
- });
133
- describe("with only a server token flag", () => {
134
- it("should stay on the MCP server path", () => {
135
- assert.equal(isCliInvocation(argv("--token", "x")), false);
136
- });
137
- });
138
- });
139
- describe("When MCP exposure filters block a CLI tool", () => {
140
- describe("with a denied-tools regex", () => {
141
- it("should refuse before dispatch", () => {
142
- const resolved = resolve(argv("tool", "whoami"), {
143
- exposure: {
144
- isExposed() {
145
- return false;
146
- },
147
- needsConfirmation() {
148
- return false;
149
- },
150
- exposureRefusal(toolName) {
151
- return `${toolName} is blocked by GITLAB_DENIED_TOOLS_REGEX`;
152
- },
153
- },
154
- });
155
- assert.equal(resolved.kind, "refused");
156
- if (resolved.kind === "refused") {
157
- assert.match(resolved.message, /GITLAB_DENIED_TOOLS_REGEX/);
158
- }
159
- });
160
- });
161
- describe("with GITLAB_TOOL_POLICY_APPROVE", () => {
162
- it("should require --yes", () => {
163
- const resolved = resolve(argv("tool", "whoami"), {
164
- exposure: {
165
- isExposed() {
166
- return true;
167
- },
168
- needsConfirmation() {
169
- return true;
170
- },
171
- exposureRefusal() {
172
- return undefined;
173
- },
174
- },
175
- });
176
- assert.equal(resolved.kind, "refused");
177
- if (resolved.kind === "refused") {
178
- assert.match(resolved.message, /GITLAB_TOOL_POLICY_APPROVE/);
179
- assert.match(resolved.message, /--yes/);
180
- }
181
- });
182
- });
183
- });
@@ -1,109 +0,0 @@
1
- /**
2
- * Client Pool Test Suite
3
- * Tests connection pooling limits and functionality
4
- */
5
- import { describe, test, after, before } from 'node:test';
6
- import assert from 'node:assert';
7
- import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST } from './utils/server-launcher.js';
8
- import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
9
- import { CustomHeaderClient } from './clients/custom-header-client.js';
10
- // Test constants
11
- const MOCK_TOKEN = 'glpat-mock-token-12345';
12
- const POOL_MAX_SIZE = 2;
13
- // Port ranges
14
- const MOCK_GITLAB_PORT_BASE = 9500;
15
- const MCP_SERVER_PORT_BASE = 3500;
16
- console.log('🏊 Client Pool Test Suite');
17
- console.log('');
18
- describe('Client Pool Limits', () => {
19
- let mcpUrl;
20
- let mockGitLab;
21
- let servers = [];
22
- let mockGitLabUrl;
23
- before(async () => {
24
- // Start mock GitLab server
25
- const mockPort = await findMockServerPort();
26
- mockGitLab = new MockGitLabServer({
27
- port: mockPort,
28
- validTokens: [MOCK_TOKEN]
29
- });
30
- await mockGitLab.start();
31
- mockGitLabUrl = mockGitLab.getUrl();
32
- // Start MCP server with pool limit
33
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
34
- const server = await launchServer({
35
- mode: TransportMode.STREAMABLE_HTTP,
36
- port: mcpPort,
37
- timeout: 5000,
38
- env: {
39
- STREAMABLE_HTTP: 'true',
40
- REMOTE_AUTHORIZATION: 'true',
41
- ENABLE_DYNAMIC_API_URL: 'true', // Enable dynamic URLs to test pooling
42
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
43
- GITLAB_POOL_MAX_SIZE: String(POOL_MAX_SIZE),
44
- }
45
- });
46
- servers.push(server);
47
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
48
- console.log(`Mock GitLab: ${mockGitLabUrl}`);
49
- console.log(`MCP Server: ${mcpUrl}`);
50
- console.log(`Pool Max Size: ${POOL_MAX_SIZE}`);
51
- });
52
- after(async () => {
53
- cleanupServers(servers);
54
- if (mockGitLab) {
55
- await mockGitLab.stop();
56
- }
57
- });
58
- test('should enforce pool size limit', async () => {
59
- // The pool size is configured to 2.
60
- // We will connect with 3 distinct URLs.
61
- // The first 2 should succeed (or fail with network error), adding to the pool.
62
- // The 3rd should fail with "Pool is full".
63
- // URL 1: 127.0.0.1 (Real mock server)
64
- const url1 = `${mockGitLabUrl}/api/v4`;
65
- const client1 = new CustomHeaderClient({
66
- 'authorization': `Bearer ${MOCK_TOKEN}`,
67
- 'x-gitlab-api-url': url1
68
- });
69
- await client1.connect(mcpUrl);
70
- await client1.callTool('list_projects', { per_page: 1 });
71
- console.log(' ✓ Request 1 (127.0.0.1) succeeded');
72
- await client1.disconnect();
73
- // URL 2: localhost (Real mock server, distinct string)
74
- const url2 = url1.replace('127.0.0.1', 'localhost');
75
- const client2 = new CustomHeaderClient({
76
- 'authorization': `Bearer ${MOCK_TOKEN}`,
77
- 'x-gitlab-api-url': url2
78
- });
79
- await client2.connect(mcpUrl);
80
- try {
81
- await client2.callTool('list_projects', { per_page: 1 });
82
- console.log(' ✓ Request 2 (localhost) succeeded');
83
- }
84
- catch (e) {
85
- // It might fail if localhost isn't listening or something, but it shouldn't be pool full
86
- console.log(' ! Request 2 failed:', e);
87
- if (String(e).includes('capacity reached')) {
88
- assert.fail('Request 2 failed with pool limit error prematurely');
89
- }
90
- }
91
- await client2.disconnect();
92
- // URL 3: gitlab-3.example.com (Fake, distinct string)
93
- // This should trigger the pool limit check
94
- const client3 = new CustomHeaderClient({
95
- 'authorization': `Bearer ${MOCK_TOKEN}`,
96
- 'x-gitlab-api-url': 'https://gitlab-3.example.com/api/v4'
97
- });
98
- await client3.connect(mcpUrl);
99
- try {
100
- await client3.callTool('list_projects', { per_page: 1 });
101
- assert.fail('Request 3 should have failed with pool limit error');
102
- }
103
- catch (error) {
104
- console.log(' â„šī¸ Error received:', error.message);
105
- assert.ok(error.message.includes('capacity reached') || error.message.includes('pool is full'), 'Error should be about server capacity');
106
- }
107
- await client3.disconnect();
108
- });
109
- });
@@ -1,34 +0,0 @@
1
- /**
2
- * MCP Client Interface and error classes for testing
3
- */
4
- /**
5
- * Base error class for MCP client errors
6
- */
7
- export class MCPClientError extends Error {
8
- cause;
9
- constructor(message, cause) {
10
- super(message);
11
- this.cause = cause;
12
- this.name = 'MCPClientError';
13
- }
14
- }
15
- /**
16
- * Connection error for MCP clients
17
- */
18
- export class MCPConnectionError extends MCPClientError {
19
- constructor(message, cause) {
20
- super(message, cause);
21
- this.name = 'MCPConnectionError';
22
- }
23
- }
24
- /**
25
- * Tool call error for MCP clients
26
- */
27
- export class MCPToolCallError extends MCPClientError {
28
- toolName;
29
- constructor(message, toolName, cause) {
30
- super(message, cause);
31
- this.toolName = toolName;
32
- this.name = 'MCPToolCallError';
33
- }
34
- }