gitlab-mcp 1.0.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 (104) 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 +21 -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 -605
  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/server/build-server.ts +0 -17
  87. package/src/tools/gitlab.ts +0 -3128
  88. package/src/tools/health.ts +0 -27
  89. package/src/tools/mr-code-context.ts +0 -473
  90. package/tests/auth-context.test.ts +0 -102
  91. package/tests/gitlab-client.test.ts +0 -674
  92. package/tests/graphql-guard.test.ts +0 -121
  93. package/tests/integration/agent-loop.integration.test.ts +0 -552
  94. package/tests/integration/server.integration.test.ts +0 -543
  95. package/tests/mr-code-context.test.ts +0 -600
  96. package/tests/oauth.test.ts +0 -43
  97. package/tests/output.test.ts +0 -186
  98. package/tests/policy.test.ts +0 -324
  99. package/tests/request-runtime.test.ts +0 -252
  100. package/tests/sanitize.test.ts +0 -123
  101. package/tests/upload-reference.test.ts +0 -84
  102. package/tsconfig.build.json +0 -11
  103. package/tsconfig.json +0 -21
  104. package/vitest.config.ts +0 -12
package/src/lib/logger.ts DELETED
@@ -1,17 +0,0 @@
1
- import pino from "pino";
2
-
3
- import { env } from "../config/env.js";
4
-
5
- export const logger = pino(
6
- {
7
- name: env.MCP_SERVER_NAME,
8
- level: env.LOG_LEVEL,
9
- redact: [
10
- "req.headers.authorization",
11
- "req.headers.private-token",
12
- "config.GITLAB_TOKEN",
13
- "token"
14
- ]
15
- },
16
- pino.destination({ fd: 2, sync: false })
17
- );
@@ -1,45 +0,0 @@
1
- import * as fs from "node:fs";
2
-
3
- import type { Logger } from "pino";
4
- import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
5
-
6
- import type { AppEnv } from "../config/env.js";
7
-
8
- export function configureNetworkRuntime(env: AppEnv, logger: Logger): void {
9
- const rejectUnauthorized = env.NODE_TLS_REJECT_UNAUTHORIZED !== "0";
10
- const proxyUrl = env.HTTPS_PROXY || env.HTTP_PROXY;
11
- const connectOptions = {
12
- rejectUnauthorized,
13
- ca: loadCaBundle(env, logger)
14
- };
15
-
16
- if (proxyUrl) {
17
- const proxyDispatcher = new ProxyAgent({
18
- uri: proxyUrl,
19
- requestTls: connectOptions
20
- });
21
- setGlobalDispatcher(proxyDispatcher);
22
- logger.info({ proxyUrl, rejectUnauthorized }, "Configured global proxy dispatcher");
23
- return;
24
- }
25
-
26
- const agent = new Agent({
27
- connect: connectOptions
28
- });
29
- setGlobalDispatcher(agent);
30
- logger.info({ rejectUnauthorized }, "Configured global network agent");
31
- }
32
-
33
- function loadCaBundle(env: AppEnv, logger: Logger): string | undefined {
34
- const caPath = env.GITLAB_CA_CERT_PATH?.trim();
35
- if (!caPath) {
36
- return undefined;
37
- }
38
-
39
- try {
40
- return fs.readFileSync(caPath, "utf8");
41
- } catch (error) {
42
- logger.warn({ err: error, caPath }, "Failed to load GITLAB_CA_CERT_PATH");
43
- return undefined;
44
- }
45
- }
package/src/lib/oauth.ts DELETED
@@ -1,287 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import * as fs from "node:fs/promises";
3
- import * as http from "node:http";
4
- import * as os from "node:os";
5
- import * as path from "node:path";
6
-
7
- import open from "open";
8
- import pkceChallenge from "pkce-challenge";
9
- import type { Logger } from "pino";
10
-
11
- export interface GitLabOAuthConfig {
12
- clientId: string;
13
- clientSecret?: string;
14
- gitlabUrl: string;
15
- redirectUri: string;
16
- scopes: string[];
17
- tokenStoragePath?: string;
18
- autoOpenBrowser: boolean;
19
- }
20
-
21
- interface OAuthTokenData {
22
- access_token: string;
23
- token_type: string;
24
- refresh_token?: string;
25
- expires_in?: number;
26
- created_at: number;
27
- }
28
-
29
- interface TokenResponse {
30
- access_token: string;
31
- token_type: string;
32
- refresh_token?: string;
33
- expires_in?: number;
34
- }
35
-
36
- export class GitLabOAuthManager {
37
- private readonly tokenPath: string;
38
- private readonly callbackTimeoutMs = 180_000;
39
- private inFlightTokenRequest: Promise<string | undefined> | null = null;
40
-
41
- constructor(
42
- private readonly config: GitLabOAuthConfig,
43
- private readonly logger: Logger
44
- ) {
45
- this.tokenPath =
46
- config.tokenStoragePath || path.join(os.homedir(), ".gitlab-mcp-oauth-token.json");
47
- }
48
-
49
- async getAccessToken(): Promise<string | undefined> {
50
- if (this.inFlightTokenRequest) {
51
- return this.inFlightTokenRequest;
52
- }
53
-
54
- this.inFlightTokenRequest = this.resolveAccessToken().finally(() => {
55
- this.inFlightTokenRequest = null;
56
- });
57
-
58
- return this.inFlightTokenRequest;
59
- }
60
-
61
- private async resolveAccessToken(): Promise<string | undefined> {
62
- const stored = await this.readStoredToken();
63
- if (stored && !isExpired(stored)) {
64
- return stored.access_token;
65
- }
66
-
67
- if (stored?.refresh_token) {
68
- try {
69
- const refreshed = await this.refreshToken(stored.refresh_token);
70
- await this.persistToken(refreshed);
71
- return refreshed.access_token;
72
- } catch (error) {
73
- this.logger.warn({ err: error }, "OAuth token refresh failed; running interactive auth");
74
- }
75
- }
76
-
77
- const token = await this.runInteractiveAuthorization();
78
- await this.persistToken(token);
79
- return token.access_token;
80
- }
81
-
82
- private async runInteractiveAuthorization(): Promise<OAuthTokenData> {
83
- const redirectUrl = new URL(this.config.redirectUri);
84
- if (redirectUrl.protocol !== "http:") {
85
- throw new Error("Only http redirect URI is supported for local OAuth callback server");
86
- }
87
-
88
- const challenge = await pkceChallenge();
89
- const state = randomBytes(16).toString("hex");
90
- const authorizationUrl = this.buildAuthorizationUrl({
91
- state,
92
- codeChallenge: challenge.code_challenge
93
- });
94
-
95
- if (this.config.autoOpenBrowser) {
96
- void open(authorizationUrl.toString()).catch((error) => {
97
- this.logger.warn(
98
- { err: error, authorizationUrl: authorizationUrl.toString() },
99
- "Failed to open browser"
100
- );
101
- });
102
- }
103
-
104
- this.logger.info(
105
- { authorizationUrl: authorizationUrl.toString() },
106
- "OAuth authorization required"
107
- );
108
-
109
- const code = await this.waitForAuthorizationCode(redirectUrl, state);
110
- return this.exchangeCodeForToken(code, challenge.code_verifier);
111
- }
112
-
113
- private buildAuthorizationUrl(options: { state: string; codeChallenge: string }): URL {
114
- const url = new URL("/oauth/authorize", this.config.gitlabUrl);
115
- url.searchParams.set("client_id", this.config.clientId);
116
- url.searchParams.set("redirect_uri", this.config.redirectUri);
117
- url.searchParams.set("response_type", "code");
118
- url.searchParams.set("scope", this.config.scopes.join(" "));
119
- url.searchParams.set("state", options.state);
120
- url.searchParams.set("code_challenge", options.codeChallenge);
121
- url.searchParams.set("code_challenge_method", "S256");
122
- return url;
123
- }
124
-
125
- private async waitForAuthorizationCode(redirectUrl: URL, expectedState: string): Promise<string> {
126
- const port = Number.parseInt(redirectUrl.port || "80", 10);
127
- const hostname = redirectUrl.hostname;
128
- const callbackPath = redirectUrl.pathname;
129
-
130
- return new Promise<string>((resolve, reject) => {
131
- let settled = false;
132
-
133
- const finalize = (fn: () => void) => {
134
- if (settled) {
135
- return;
136
- }
137
- settled = true;
138
- clearTimeout(timeout);
139
- fn();
140
- };
141
-
142
- const server = http.createServer((req, res) => {
143
- const host = req.headers.host || `${hostname}:${port}`;
144
- const requestUrl = new URL(req.url || "/", `http://${host}`);
145
- if (requestUrl.pathname !== callbackPath) {
146
- res.statusCode = 404;
147
- res.end("Not found");
148
- return;
149
- }
150
-
151
- const code = requestUrl.searchParams.get("code");
152
- const state = requestUrl.searchParams.get("state");
153
- if (!code || state !== expectedState) {
154
- res.statusCode = 400;
155
- res.end("Invalid OAuth callback");
156
- finalize(() => {
157
- server.close();
158
- reject(new Error("Invalid OAuth callback state or missing code"));
159
- });
160
- return;
161
- }
162
-
163
- res.statusCode = 200;
164
- res.setHeader("content-type", "text/html; charset=utf-8");
165
- res.end(
166
- "<html><body><h3>Authentication complete. You can close this tab.</h3></body></html>"
167
- );
168
- finalize(() => {
169
- server.close();
170
- resolve(code);
171
- });
172
- });
173
-
174
- server.on("error", (error) => {
175
- finalize(() => reject(error));
176
- });
177
-
178
- const timeout = setTimeout(() => {
179
- finalize(() => {
180
- server.close();
181
- reject(new Error("OAuth callback timeout"));
182
- });
183
- }, this.callbackTimeoutMs);
184
-
185
- server.listen(port, hostname);
186
- });
187
- }
188
-
189
- private async exchangeCodeForToken(code: string, codeVerifier: string): Promise<OAuthTokenData> {
190
- const endpoint = new URL("/oauth/token", this.config.gitlabUrl);
191
- const body = new URLSearchParams({
192
- client_id: this.config.clientId,
193
- grant_type: "authorization_code",
194
- code,
195
- redirect_uri: this.config.redirectUri,
196
- code_verifier: codeVerifier
197
- });
198
- if (this.config.clientSecret) {
199
- body.set("client_secret", this.config.clientSecret);
200
- }
201
-
202
- return this.fetchToken(endpoint, body);
203
- }
204
-
205
- private async refreshToken(refreshToken: string): Promise<OAuthTokenData> {
206
- const endpoint = new URL("/oauth/token", this.config.gitlabUrl);
207
- const body = new URLSearchParams({
208
- client_id: this.config.clientId,
209
- grant_type: "refresh_token",
210
- refresh_token: refreshToken,
211
- redirect_uri: this.config.redirectUri
212
- });
213
- if (this.config.clientSecret) {
214
- body.set("client_secret", this.config.clientSecret);
215
- }
216
-
217
- return this.fetchToken(endpoint, body);
218
- }
219
-
220
- private async fetchToken(endpoint: URL, body: URLSearchParams): Promise<OAuthTokenData> {
221
- const response = await fetch(endpoint, {
222
- method: "POST",
223
- headers: {
224
- "Content-Type": "application/x-www-form-urlencoded"
225
- },
226
- body: body.toString()
227
- });
228
-
229
- if (!response.ok) {
230
- const raw = await response.text();
231
- throw new Error(`OAuth token endpoint failed: ${response.status} ${raw.slice(0, 500)}`);
232
- }
233
-
234
- const payload = (await response.json()) as TokenResponse;
235
- if (!payload.access_token) {
236
- throw new Error("OAuth response did not include access_token");
237
- }
238
-
239
- return {
240
- access_token: payload.access_token,
241
- token_type: payload.token_type || "Bearer",
242
- refresh_token: payload.refresh_token,
243
- expires_in: payload.expires_in,
244
- created_at: Date.now()
245
- };
246
- }
247
-
248
- private async persistToken(token: OAuthTokenData): Promise<void> {
249
- const dir = path.dirname(this.tokenPath);
250
- await fs.mkdir(dir, { recursive: true });
251
- await fs.writeFile(this.tokenPath, JSON.stringify(token, null, 2), { mode: 0o600 });
252
- }
253
-
254
- private async readStoredToken(): Promise<OAuthTokenData | undefined> {
255
- try {
256
- const raw = await fs.readFile(this.tokenPath, "utf8");
257
- const parsed = JSON.parse(raw) as Partial<OAuthTokenData>;
258
- if (!parsed.access_token) {
259
- return undefined;
260
- }
261
- return {
262
- access_token: parsed.access_token,
263
- token_type: parsed.token_type || "Bearer",
264
- refresh_token: parsed.refresh_token,
265
- expires_in: parsed.expires_in,
266
- created_at: parsed.created_at || 0
267
- };
268
- } catch {
269
- return undefined;
270
- }
271
- }
272
- }
273
-
274
- function isExpired(token: OAuthTokenData): boolean {
275
- if (!token.expires_in) {
276
- return false;
277
- }
278
-
279
- const expiresAt = token.created_at + token.expires_in * 1000;
280
- return Date.now() >= expiresAt - 5 * 60 * 1000;
281
- }
282
-
283
- export function deriveGitLabBaseUrl(apiUrl: string): string {
284
- const url = new URL(apiUrl);
285
- const prefix = url.pathname.replace(/\/api\/v4\/?$/, "");
286
- return new URL(prefix || "/", url.origin).toString().replace(/\/$/, "");
287
- }
package/src/lib/output.ts DELETED
@@ -1,51 +0,0 @@
1
- import { stringify as toYaml } from "yaml";
2
-
3
- export interface FormatOptions {
4
- responseMode: "json" | "compact-json" | "yaml";
5
- maxBytes: number;
6
- }
7
-
8
- export interface FormattedPayload {
9
- text: string;
10
- truncated: boolean;
11
- bytes: number;
12
- }
13
-
14
- export class OutputFormatter {
15
- constructor(private readonly options: FormatOptions) {}
16
-
17
- format(value: unknown): FormattedPayload {
18
- const serialized = serializeValue(value, this.options.responseMode);
19
- const bytes = Buffer.byteLength(serialized, "utf8");
20
-
21
- if (bytes <= this.options.maxBytes) {
22
- return {
23
- text: serialized,
24
- truncated: false,
25
- bytes
26
- };
27
- }
28
-
29
- const buffer = Buffer.from(serialized, "utf8");
30
- const head = buffer.subarray(0, this.options.maxBytes);
31
- const truncatedBytes = bytes - this.options.maxBytes;
32
-
33
- return {
34
- text: `${head.toString("utf8")}\n... [truncated ${truncatedBytes} bytes]`,
35
- truncated: true,
36
- bytes
37
- };
38
- }
39
- }
40
-
41
- function serializeValue(value: unknown, mode: FormatOptions["responseMode"]): string {
42
- switch (mode) {
43
- case "compact-json":
44
- return JSON.stringify(value);
45
- case "yaml":
46
- return toYaml(value);
47
- case "json":
48
- default:
49
- return JSON.stringify(value, null, 2);
50
- }
51
- }
package/src/lib/policy.ts DELETED
@@ -1,78 +0,0 @@
1
- export interface ToolPolicyMeta {
2
- name: string;
3
- mutating: boolean;
4
- requiresFeature?: "wiki" | "milestone" | "pipeline" | "release";
5
- }
6
-
7
- export interface ToolPolicyConfig {
8
- readOnlyMode: boolean;
9
- allowedTools: string[];
10
- deniedToolsRegex?: RegExp;
11
- enabledFeatures: {
12
- wiki: boolean;
13
- milestone: boolean;
14
- pipeline: boolean;
15
- release: boolean;
16
- };
17
- }
18
-
19
- export class ToolPolicyEngine {
20
- private readonly normalizedAllowedTools: Set<string>;
21
-
22
- constructor(private readonly config: ToolPolicyConfig) {
23
- this.normalizedAllowedTools = new Set(
24
- config.allowedTools.flatMap((name) => normalizeAllowedToolName(name))
25
- );
26
- }
27
-
28
- filterTools(tools: ToolPolicyMeta[]): ToolPolicyMeta[] {
29
- return tools.filter((tool) => this.isToolEnabled(tool));
30
- }
31
-
32
- assertCanExecute(tool: ToolPolicyMeta): void {
33
- if (!this.isToolEnabled(tool)) {
34
- throw new Error(`Tool '${tool.name}' is disabled by policy`);
35
- }
36
- }
37
-
38
- isToolEnabled(tool: ToolPolicyMeta): boolean {
39
- if (this.config.readOnlyMode && tool.mutating) {
40
- return false;
41
- }
42
-
43
- if (!this.isFeatureEnabled(tool)) {
44
- return false;
45
- }
46
-
47
- if (this.normalizedAllowedTools.size > 0 && !this.normalizedAllowedTools.has(tool.name)) {
48
- return false;
49
- }
50
-
51
- if (this.config.deniedToolsRegex && this.config.deniedToolsRegex.test(tool.name)) {
52
- return false;
53
- }
54
-
55
- return true;
56
- }
57
-
58
- private isFeatureEnabled(tool: ToolPolicyMeta): boolean {
59
- if (!tool.requiresFeature) {
60
- return true;
61
- }
62
-
63
- return this.config.enabledFeatures[tool.requiresFeature];
64
- }
65
- }
66
-
67
- function normalizeAllowedToolName(name: string): string[] {
68
- const value = name.trim();
69
- if (value.length === 0) {
70
- return [];
71
- }
72
-
73
- if (value.startsWith("gitlab_")) {
74
- return [value];
75
- }
76
-
77
- return [value, `gitlab_${value}`];
78
- }