gitlab-mcp 1.1.0 → 1.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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/dist/config/env.d.ts +56 -0
  3. package/dist/config/env.js +163 -0
  4. package/dist/config/env.js.map +1 -0
  5. package/dist/http-app.d.ts +45 -0
  6. package/dist/http-app.js +550 -0
  7. package/dist/http-app.js.map +1 -0
  8. package/dist/http.d.ts +2 -0
  9. package/dist/http.js +65 -0
  10. package/dist/http.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +65 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/lib/auth-context.d.ts +9 -0
  15. package/dist/lib/auth-context.js +9 -0
  16. package/dist/lib/auth-context.js.map +1 -0
  17. package/dist/lib/gitlab-client.d.ts +331 -0
  18. package/dist/lib/gitlab-client.js +1025 -0
  19. package/dist/lib/gitlab-client.js.map +1 -0
  20. package/dist/lib/logger.d.ts +2 -0
  21. package/dist/lib/logger.js +13 -0
  22. package/dist/lib/logger.js.map +1 -0
  23. package/dist/lib/network.d.ts +3 -0
  24. package/dist/lib/network.js +38 -0
  25. package/dist/lib/network.js.map +1 -0
  26. package/dist/lib/oauth.d.ts +29 -0
  27. package/dist/lib/oauth.js +220 -0
  28. package/dist/lib/oauth.js.map +1 -0
  29. package/dist/lib/output.d.ts +14 -0
  30. package/dist/lib/output.js +38 -0
  31. package/dist/lib/output.js.map +1 -0
  32. package/dist/lib/policy.d.ts +25 -0
  33. package/dist/lib/policy.js +48 -0
  34. package/dist/lib/policy.js.map +1 -0
  35. package/dist/lib/request-runtime.d.ts +26 -0
  36. package/dist/lib/request-runtime.js +323 -0
  37. package/dist/lib/request-runtime.js.map +1 -0
  38. package/dist/lib/sanitize.d.ts +1 -0
  39. package/dist/lib/sanitize.js +21 -0
  40. package/dist/lib/sanitize.js.map +1 -0
  41. package/dist/lib/session-capacity.d.ts +8 -0
  42. package/dist/lib/session-capacity.js +7 -0
  43. package/dist/lib/session-capacity.js.map +1 -0
  44. package/dist/server/build-server.d.ts +3 -0
  45. package/dist/server/build-server.js +13 -0
  46. package/dist/server/build-server.js.map +1 -0
  47. package/dist/tools/gitlab.d.ts +9 -0
  48. package/dist/tools/gitlab.js +2576 -0
  49. package/dist/tools/gitlab.js.map +1 -0
  50. package/dist/tools/health.d.ts +2 -0
  51. package/dist/tools/health.js +21 -0
  52. package/dist/tools/health.js.map +1 -0
  53. package/dist/tools/mr-code-context.d.ts +38 -0
  54. package/dist/tools/mr-code-context.js +330 -0
  55. package/dist/tools/mr-code-context.js.map +1 -0
  56. package/{src/types/context.ts → dist/types/context.d.ts} +5 -6
  57. package/dist/types/context.js +2 -0
  58. package/dist/types/context.js.map +1 -0
  59. package/docs/configuration.md +6 -6
  60. package/docs/mcp-integration-testing-best-practices.md +981 -0
  61. package/package.json +13 -1
  62. package/.dockerignore +0 -7
  63. package/.editorconfig +0 -9
  64. package/.env.example +0 -75
  65. package/.github/workflows/nodejs.yml +0 -31
  66. package/.github/workflows/npm-publish.yml +0 -31
  67. package/.husky/pre-commit +0 -1
  68. package/.nvmrc +0 -1
  69. package/.prettierrc.json +0 -6
  70. package/Dockerfile +0 -20
  71. package/docker-compose.yml +0 -10
  72. package/eslint.config.js +0 -23
  73. package/scripts/get-oauth-token.example.sh +0 -15
  74. package/src/config/env.ts +0 -171
  75. package/src/http.ts +0 -620
  76. package/src/index.ts +0 -77
  77. package/src/lib/auth-context.ts +0 -19
  78. package/src/lib/gitlab-client.ts +0 -1810
  79. package/src/lib/logger.ts +0 -17
  80. package/src/lib/network.ts +0 -45
  81. package/src/lib/oauth.ts +0 -287
  82. package/src/lib/output.ts +0 -51
  83. package/src/lib/policy.ts +0 -78
  84. package/src/lib/request-runtime.ts +0 -376
  85. package/src/lib/sanitize.ts +0 -25
  86. package/src/lib/session-capacity.ts +0 -14
  87. package/src/server/build-server.ts +0 -17
  88. package/src/tools/gitlab.ts +0 -3135
  89. package/src/tools/health.ts +0 -27
  90. package/src/tools/mr-code-context.ts +0 -473
  91. package/tests/auth-context.test.ts +0 -102
  92. package/tests/gitlab-client.test.ts +0 -672
  93. package/tests/graphql-guard.test.ts +0 -121
  94. package/tests/integration/agent-loop.integration.test.ts +0 -558
  95. package/tests/integration/server.integration.test.ts +0 -543
  96. package/tests/mr-code-context.test.ts +0 -600
  97. package/tests/oauth.test.ts +0 -43
  98. package/tests/output.test.ts +0 -186
  99. package/tests/policy.test.ts +0 -324
  100. package/tests/request-runtime.test.ts +0 -252
  101. package/tests/sanitize.test.ts +0 -123
  102. package/tests/session-capacity.test.ts +0 -49
  103. package/tests/upload-reference.test.ts +0 -88
  104. package/tsconfig.build.json +0 -11
  105. package/tsconfig.json +0 -21
  106. package/vitest.config.ts +0 -12
@@ -1,252 +0,0 @@
1
- /**
2
- * Tests for helper functions exported or used internally by request-runtime.ts.
3
- * Since some functions are private, we test them indirectly or test the module-level
4
- * exported utilities that are accessible.
5
- *
6
- * For deeper testing we extract testable logic patterns.
7
- */
8
- import { describe, expect, it } from "vitest";
9
- import * as os from "node:os";
10
- import * as path from "node:path";
11
-
12
- /**
13
- * Replicate the parseTokenOutput logic for testing.
14
- * This matches the private function in request-runtime.ts.
15
- */
16
- function parseTokenOutput(rawOutput: string): string | undefined {
17
- const output = rawOutput.trim();
18
- if (!output) {
19
- return undefined;
20
- }
21
-
22
- try {
23
- const parsed = JSON.parse(output) as Record<string, unknown>;
24
- const token =
25
- getStringField(parsed, "token") ||
26
- getStringField(parsed, "access_token") ||
27
- getStringField(parsed, "private_token");
28
- if (token) {
29
- return token;
30
- }
31
- } catch {
32
- // Plain string output is valid.
33
- }
34
-
35
- return output.split(/\r?\n/, 1)[0]?.trim() || undefined;
36
- }
37
-
38
- function getStringField(record: Record<string, unknown>, key: string): string | undefined {
39
- const value = record[key];
40
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
41
- }
42
-
43
- /**
44
- * Replicate resolveHomePath for testing.
45
- */
46
- function resolveHomePath(input?: string): string | undefined {
47
- if (!input) {
48
- return undefined;
49
- }
50
-
51
- if (input.startsWith("~/")) {
52
- return path.join(os.homedir(), input.slice(2));
53
- }
54
-
55
- return input;
56
- }
57
-
58
- /**
59
- * Replicate normalizeWarmupPath for testing.
60
- */
61
- function normalizeWarmupPath(value: string): string {
62
- const trimmed = value.trim();
63
- if (!trimmed) {
64
- return "/user";
65
- }
66
-
67
- return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
68
- }
69
-
70
- /**
71
- * Replicate resolveApiRoot for testing.
72
- */
73
- function resolveApiRoot(url: URL): string | undefined {
74
- const match = url.pathname.match(/^(.*\/api\/v4)(?:\/|$)/);
75
- return match?.[1];
76
- }
77
-
78
- /**
79
- * Replicate parseOauthScopes for testing.
80
- */
81
- function parseOauthScopes(rawScopes: string): string[] {
82
- return rawScopes
83
- .split(/[,\s]+/)
84
- .map((scope) => scope.trim())
85
- .filter((scope) => scope.length > 0);
86
- }
87
-
88
- describe("parseTokenOutput", () => {
89
- it("returns undefined for empty string", () => {
90
- expect(parseTokenOutput("")).toBeUndefined();
91
- });
92
-
93
- it("returns undefined for whitespace-only string", () => {
94
- expect(parseTokenOutput(" \n ")).toBeUndefined();
95
- });
96
-
97
- it("parses plain text token", () => {
98
- expect(parseTokenOutput("glpat-abc123")).toBe("glpat-abc123");
99
- });
100
-
101
- it("parses plain text with trailing newline", () => {
102
- expect(parseTokenOutput("glpat-abc123\n")).toBe("glpat-abc123");
103
- });
104
-
105
- it("takes first line of multi-line output", () => {
106
- expect(parseTokenOutput("glpat-abc123\nsome debug info\nmore stuff")).toBe("glpat-abc123");
107
- });
108
-
109
- it("parses JSON with token field", () => {
110
- expect(parseTokenOutput('{"token": "glpat-from-json"}')).toBe("glpat-from-json");
111
- });
112
-
113
- it("parses JSON with access_token field", () => {
114
- expect(parseTokenOutput('{"access_token": "oauth-token-123"}')).toBe("oauth-token-123");
115
- });
116
-
117
- it("parses JSON with private_token field", () => {
118
- expect(parseTokenOutput('{"private_token": "private-123"}')).toBe("private-123");
119
- });
120
-
121
- it("prefers token field over access_token", () => {
122
- expect(parseTokenOutput('{"token": "primary", "access_token": "secondary"}')).toBe("primary");
123
- });
124
-
125
- it("ignores JSON with empty token fields", () => {
126
- expect(parseTokenOutput('{"token": "", "other": "value"}')).toBe(
127
- '{"token": "", "other": "value"}'
128
- );
129
- });
130
-
131
- it("ignores JSON with whitespace-only token fields", () => {
132
- expect(parseTokenOutput('{"token": " "}')).toBe('{"token": " "}');
133
- });
134
-
135
- it("trims whitespace from parsed token values", () => {
136
- expect(parseTokenOutput('{"token": " trimmed "}')).toBe("trimmed");
137
- });
138
-
139
- it("handles JSON with no recognized token fields", () => {
140
- const input = '{"unknown_field": "some-value"}';
141
- // Falls back to first line
142
- expect(parseTokenOutput(input)).toBe(input);
143
- });
144
- });
145
-
146
- describe("resolveHomePath", () => {
147
- it("returns undefined for empty input", () => {
148
- expect(resolveHomePath("")).toBeUndefined();
149
- expect(resolveHomePath(undefined)).toBeUndefined();
150
- });
151
-
152
- it("expands ~ to home directory", () => {
153
- const result = resolveHomePath("~/some/path");
154
- expect(result).toBeDefined();
155
- expect(result).not.toContain("~/");
156
- expect(result).toContain("some/path");
157
- });
158
-
159
- it("returns absolute paths unchanged", () => {
160
- expect(resolveHomePath("/absolute/path")).toBe("/absolute/path");
161
- });
162
-
163
- it("returns relative paths unchanged", () => {
164
- expect(resolveHomePath("relative/path")).toBe("relative/path");
165
- });
166
- });
167
-
168
- describe("normalizeWarmupPath", () => {
169
- it("returns /user for empty string", () => {
170
- expect(normalizeWarmupPath("")).toBe("/user");
171
- });
172
-
173
- it("returns /user for whitespace-only string", () => {
174
- expect(normalizeWarmupPath(" ")).toBe("/user");
175
- });
176
-
177
- it("preserves leading slash", () => {
178
- expect(normalizeWarmupPath("/custom")).toBe("/custom");
179
- });
180
-
181
- it("adds leading slash when missing", () => {
182
- expect(normalizeWarmupPath("custom")).toBe("/custom");
183
- });
184
-
185
- it("trims whitespace", () => {
186
- expect(normalizeWarmupPath(" /user ")).toBe("/user");
187
- });
188
- });
189
-
190
- describe("resolveApiRoot", () => {
191
- it("extracts /api/v4 from standard URL", () => {
192
- const url = new URL("https://gitlab.example.com/api/v4/projects/1");
193
- expect(resolveApiRoot(url)).toBe("/api/v4");
194
- });
195
-
196
- it("extracts subpath /api/v4", () => {
197
- const url = new URL("https://example.com/gitlab/api/v4/projects");
198
- expect(resolveApiRoot(url)).toBe("/gitlab/api/v4");
199
- });
200
-
201
- it("returns undefined for non-API URLs", () => {
202
- const url = new URL("https://gitlab.example.com/group/project");
203
- expect(resolveApiRoot(url)).toBeUndefined();
204
- });
205
-
206
- it("matches when path ends with /api/v4", () => {
207
- const url = new URL("https://gitlab.example.com/api/v4/");
208
- expect(resolveApiRoot(url)).toBe("/api/v4");
209
- });
210
- });
211
-
212
- describe("parseOauthScopes", () => {
213
- it("parses space-separated scopes", () => {
214
- expect(parseOauthScopes("api read_user")).toEqual(["api", "read_user"]);
215
- });
216
-
217
- it("parses comma-separated scopes", () => {
218
- expect(parseOauthScopes("api,read_user,write_repository")).toEqual([
219
- "api",
220
- "read_user",
221
- "write_repository"
222
- ]);
223
- });
224
-
225
- it("handles mixed separators", () => {
226
- expect(parseOauthScopes("api, read_user write_repository")).toEqual([
227
- "api",
228
- "read_user",
229
- "write_repository"
230
- ]);
231
- });
232
-
233
- it("filters empty entries", () => {
234
- expect(parseOauthScopes("api,,read_user, ,write_repository")).toEqual([
235
- "api",
236
- "read_user",
237
- "write_repository"
238
- ]);
239
- });
240
-
241
- it("handles single scope", () => {
242
- expect(parseOauthScopes("api")).toEqual(["api"]);
243
- });
244
-
245
- it("handles empty string", () => {
246
- expect(parseOauthScopes("")).toEqual([]);
247
- });
248
-
249
- it("trims whitespace from scopes", () => {
250
- expect(parseOauthScopes(" api , read_user ")).toEqual(["api", "read_user"]);
251
- });
252
- });
@@ -1,123 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { stripNullsDeep } from "../src/lib/sanitize.js";
4
-
5
- describe("stripNullsDeep", () => {
6
- it("removes null values recursively from objects", () => {
7
- const input = {
8
- title: "demo",
9
- description: null,
10
- nested: {
11
- keep: 1,
12
- drop: null,
13
- arr: [1, null, 2, null, 3]
14
- }
15
- };
16
-
17
- expect(stripNullsDeep(input)).toEqual({
18
- title: "demo",
19
- nested: {
20
- keep: 1,
21
- arr: [1, 2, 3]
22
- }
23
- });
24
- });
25
-
26
- it("returns undefined for top-level null", () => {
27
- expect(stripNullsDeep(null)).toBeUndefined();
28
- });
29
-
30
- it("preserves primitive string values", () => {
31
- expect(stripNullsDeep("hello")).toBe("hello");
32
- });
33
-
34
- it("preserves primitive number values", () => {
35
- expect(stripNullsDeep(42)).toBe(42);
36
- expect(stripNullsDeep(0)).toBe(0);
37
- expect(stripNullsDeep(-1)).toBe(-1);
38
- });
39
-
40
- it("preserves boolean values", () => {
41
- expect(stripNullsDeep(true)).toBe(true);
42
- expect(stripNullsDeep(false)).toBe(false);
43
- });
44
-
45
- it("preserves undefined as-is", () => {
46
- expect(stripNullsDeep(undefined)).toBeUndefined();
47
- });
48
-
49
- it("handles empty object", () => {
50
- expect(stripNullsDeep({})).toEqual({});
51
- });
52
-
53
- it("handles empty array", () => {
54
- expect(stripNullsDeep([])).toEqual([]);
55
- });
56
-
57
- it("handles object with all null values", () => {
58
- expect(stripNullsDeep({ a: null, b: null, c: null })).toEqual({});
59
- });
60
-
61
- it("handles array with all null values", () => {
62
- expect(stripNullsDeep([null, null, null])).toEqual([]);
63
- });
64
-
65
- it("handles deeply nested null removal", () => {
66
- const input = {
67
- level1: {
68
- level2: {
69
- level3: {
70
- keep: "value",
71
- drop: null
72
- }
73
- }
74
- }
75
- };
76
-
77
- expect(stripNullsDeep(input)).toEqual({
78
- level1: {
79
- level2: {
80
- level3: {
81
- keep: "value"
82
- }
83
- }
84
- }
85
- });
86
- });
87
-
88
- it("handles mixed array contents", () => {
89
- const input = [1, "hello", null, true, null, { key: null, keep: "yes" }];
90
-
91
- expect(stripNullsDeep(input)).toEqual([1, "hello", true, { keep: "yes" }]);
92
- });
93
-
94
- it("handles nested arrays within objects", () => {
95
- const input = {
96
- items: [{ name: "a", value: null }, null, { name: "b", value: 1 }]
97
- };
98
-
99
- expect(stripNullsDeep(input)).toEqual({
100
- items: [{ name: "a" }, { name: "b", value: 1 }]
101
- });
102
- });
103
-
104
- it("preserves zero, empty string, and false values", () => {
105
- const input = {
106
- zero: 0,
107
- emptyStr: "",
108
- falsy: false,
109
- nullValue: null
110
- };
111
-
112
- expect(stripNullsDeep(input)).toEqual({
113
- zero: 0,
114
- emptyStr: "",
115
- falsy: false
116
- });
117
- });
118
-
119
- it("handles arrays nested within arrays", () => {
120
- const input = [[null, 1], [2, null], null];
121
- expect(stripNullsDeep(input)).toEqual([[1], [2]]);
122
- });
123
- });
@@ -1,49 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { getTotalSessions, hasReachedSessionCapacity } from "../src/lib/session-capacity.js";
4
-
5
- describe("session capacity helpers", () => {
6
- it("counts streamable, pending, and SSE sessions together", () => {
7
- expect(
8
- getTotalSessions({
9
- streamableSessions: 2,
10
- pendingSessions: 1,
11
- sseSessions: 3,
12
- maxSessions: 10
13
- })
14
- ).toBe(6);
15
- });
16
-
17
- it("treats capacity as reached when total equals max", () => {
18
- expect(
19
- hasReachedSessionCapacity({
20
- streamableSessions: 1,
21
- pendingSessions: 1,
22
- sseSessions: 1,
23
- maxSessions: 3
24
- })
25
- ).toBe(true);
26
- });
27
-
28
- it("treats capacity as reached when total exceeds max", () => {
29
- expect(
30
- hasReachedSessionCapacity({
31
- streamableSessions: 2,
32
- pendingSessions: 1,
33
- sseSessions: 2,
34
- maxSessions: 4
35
- })
36
- ).toBe(true);
37
- });
38
-
39
- it("does not reach capacity when total is below max", () => {
40
- expect(
41
- hasReachedSessionCapacity({
42
- streamableSessions: 1,
43
- pendingSessions: 0,
44
- sseSessions: 1,
45
- maxSessions: 3
46
- })
47
- ).toBe(false);
48
- });
49
- });
@@ -1,88 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { parseProjectUploadReference } from "../src/tools/gitlab.js";
4
-
5
- describe("parseProjectUploadReference", () => {
6
- it("parses absolute upload URL", () => {
7
- expect(
8
- parseProjectUploadReference("https://gitlab.example.com/group/repo/uploads/abc123/file.txt")
9
- ).toEqual({
10
- secret: "abc123",
11
- filename: "file.txt"
12
- });
13
- });
14
-
15
- it("parses relative upload path", () => {
16
- expect(parseProjectUploadReference("/uploads/abc123/file.txt")).toEqual({
17
- secret: "abc123",
18
- filename: "file.txt"
19
- });
20
- });
21
-
22
- it("parses relative upload path with encoded filename", () => {
23
- expect(parseProjectUploadReference("/uploads/abc123/%E6%B5%8B%E8%AF%95.txt")).toEqual({
24
- secret: "abc123",
25
- filename: "测试.txt"
26
- });
27
- });
28
-
29
- it("returns undefined for non-upload paths", () => {
30
- expect(parseProjectUploadReference("/api/v4/projects/1/issues")).toBeUndefined();
31
- });
32
-
33
- it("returns undefined for empty string", () => {
34
- expect(parseProjectUploadReference("")).toBeUndefined();
35
- });
36
-
37
- it("returns undefined for whitespace-only string", () => {
38
- expect(parseProjectUploadReference(" ")).toBeUndefined();
39
- });
40
-
41
- it("handles URL with query parameters", () => {
42
- const result = parseProjectUploadReference(
43
- "https://gitlab.example.com/group/repo/uploads/abc123/file.txt?inline=true"
44
- );
45
- expect(result).toEqual({
46
- secret: "abc123",
47
- filename: "file.txt"
48
- });
49
- });
50
-
51
- it("handles URL with fragment", () => {
52
- const result = parseProjectUploadReference(
53
- "https://gitlab.example.com/group/repo/uploads/abc123/file.txt#section"
54
- );
55
- expect(result).toEqual({
56
- secret: "abc123",
57
- filename: "file.txt"
58
- });
59
- });
60
-
61
- it("handles URL with nested group paths", () => {
62
- const result = parseProjectUploadReference(
63
- "https://gitlab.example.com/group/subgroup/repo/uploads/secret123/document.pdf"
64
- );
65
- expect(result).toEqual({
66
- secret: "secret123",
67
- filename: "document.pdf"
68
- });
69
- });
70
-
71
- it("returns undefined for invalid URL", () => {
72
- expect(parseProjectUploadReference("not a url at all")).toBeUndefined();
73
- });
74
-
75
- it("returns undefined for malformed percent-encoding", () => {
76
- expect(parseProjectUploadReference("/uploads/abc123/%E0%A4%A")).toBeUndefined();
77
- });
78
-
79
- it("handles upload path without leading slash", () => {
80
- // This depends on implementation, but should handle gracefully
81
- const result = parseProjectUploadReference("uploads/abc123/file.txt");
82
- // May or may not match depending on implementation
83
- if (result) {
84
- expect(result.secret).toBe("abc123");
85
- expect(result.filename).toBe("file.txt");
86
- }
87
- });
88
- });
@@ -1,11 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "compilerOptions": {
4
- "declaration": true,
5
- "outDir": "dist",
6
- "rootDir": "src",
7
- "sourceMap": true,
8
- "types": ["node"]
9
- },
10
- "include": ["src"]
11
- }
package/tsconfig.json DELETED
@@ -1,21 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "lib": ["ES2022", "DOM"],
7
- "strict": true,
8
- "noUncheckedIndexedAccess": true,
9
- "noImplicitOverride": true,
10
- "useUnknownInCatchVariables": true,
11
- "skipLibCheck": true,
12
- "resolveJsonModule": true,
13
- "esModuleInterop": false,
14
- "forceConsistentCasingInFileNames": true,
15
- "isolatedModules": true,
16
- "verbatimModuleSyntax": true,
17
- "types": ["node", "vitest/globals"]
18
- },
19
- "include": ["src", "tests", "eslint.config.js", "vitest.config.ts"],
20
- "exclude": ["dist", "node_modules"]
21
- }
package/vitest.config.ts DELETED
@@ -1,12 +0,0 @@
1
- import { defineConfig } from "vitest/config";
2
-
3
- export default defineConfig({
4
- test: {
5
- environment: "node",
6
- include: ["tests/**/*.test.ts"],
7
- coverage: {
8
- reporter: ["text", "html"],
9
- include: ["src/**/*.ts"]
10
- }
11
- }
12
- });