gitlab-mcp 0.1.4 → 1.0.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 (55) hide show
  1. package/.dockerignore +7 -0
  2. package/.editorconfig +9 -0
  3. package/.env.example +75 -0
  4. package/.github/workflows/nodejs.yml +31 -0
  5. package/.github/workflows/npm-publish.yml +31 -0
  6. package/.husky/pre-commit +1 -0
  7. package/.nvmrc +1 -0
  8. package/.prettierrc.json +6 -0
  9. package/Dockerfile +20 -0
  10. package/README.md +416 -251
  11. package/docker-compose.yml +10 -0
  12. package/docs/architecture.md +310 -0
  13. package/docs/authentication.md +299 -0
  14. package/docs/configuration.md +149 -0
  15. package/docs/deployment.md +336 -0
  16. package/docs/tools.md +294 -0
  17. package/eslint.config.js +23 -0
  18. package/package.json +70 -32
  19. package/scripts/get-oauth-token.example.sh +15 -0
  20. package/src/config/env.ts +171 -0
  21. package/src/http.ts +605 -0
  22. package/src/index.ts +77 -0
  23. package/src/lib/auth-context.ts +19 -0
  24. package/src/lib/gitlab-client.ts +1810 -0
  25. package/src/lib/logger.ts +17 -0
  26. package/src/lib/network.ts +45 -0
  27. package/src/lib/oauth.ts +287 -0
  28. package/src/lib/output.ts +51 -0
  29. package/src/lib/policy.ts +78 -0
  30. package/src/lib/request-runtime.ts +376 -0
  31. package/src/lib/sanitize.ts +25 -0
  32. package/src/server/build-server.ts +17 -0
  33. package/src/tools/gitlab.ts +3128 -0
  34. package/src/tools/health.ts +27 -0
  35. package/src/tools/mr-code-context.ts +473 -0
  36. package/src/types/context.ts +13 -0
  37. package/tests/auth-context.test.ts +102 -0
  38. package/tests/gitlab-client.test.ts +674 -0
  39. package/tests/graphql-guard.test.ts +121 -0
  40. package/tests/integration/agent-loop.integration.test.ts +552 -0
  41. package/tests/integration/server.integration.test.ts +543 -0
  42. package/tests/mr-code-context.test.ts +600 -0
  43. package/tests/oauth.test.ts +43 -0
  44. package/tests/output.test.ts +186 -0
  45. package/tests/policy.test.ts +324 -0
  46. package/tests/request-runtime.test.ts +252 -0
  47. package/tests/sanitize.test.ts +123 -0
  48. package/tests/upload-reference.test.ts +84 -0
  49. package/tsconfig.build.json +11 -0
  50. package/tsconfig.json +21 -0
  51. package/vitest.config.ts +12 -0
  52. package/LICENSE +0 -21
  53. package/build/index.js +0 -1641
  54. package/build/schemas.js +0 -684
  55. package/build/test-note.js +0 -54
@@ -0,0 +1,376 @@
1
+ import { exec as execCb } from "node:child_process";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import fetchCookie from "fetch-cookie";
8
+ import type { Logger } from "pino";
9
+ import { Cookie, CookieJar } from "tough-cookie";
10
+
11
+ import type { AppEnv } from "../config/env.js";
12
+ import type { GitLabBeforeRequestContext, GitLabBeforeRequestResult } from "./gitlab-client.js";
13
+ import { deriveGitLabBaseUrl, GitLabOAuthManager } from "./oauth.js";
14
+
15
+ const execAsync = promisify(execCb);
16
+ const DEFAULT_BROWSER_UA =
17
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36";
18
+
19
+ interface TokenState {
20
+ value: string;
21
+ expiresAt: number;
22
+ }
23
+
24
+ export class GitLabRequestRuntime {
25
+ private readonly cookiePath?: string;
26
+ private readonly warmupPath: string;
27
+ private readonly tokenFilePath?: string;
28
+ private readonly tokenScript?: string;
29
+ private readonly oauthManager?: GitLabOAuthManager;
30
+
31
+ private fetchImpl: typeof fetch = fetch;
32
+ private cookieJar: CookieJar | null = null;
33
+ private cookieMtime = 0;
34
+ private cookieReloadLock: Promise<void> | null = null;
35
+ private readonly warmedApiRoots = new Set<string>();
36
+ private cachedToken: TokenState | null = null;
37
+
38
+ constructor(
39
+ private readonly env: AppEnv,
40
+ private readonly logger: Logger
41
+ ) {
42
+ this.cookiePath = resolveHomePath(env.GITLAB_AUTH_COOKIE_PATH);
43
+ this.warmupPath = normalizeWarmupPath(env.GITLAB_COOKIE_WARMUP_PATH);
44
+ this.tokenFilePath = resolveHomePath(env.GITLAB_TOKEN_FILE);
45
+ this.tokenScript = env.GITLAB_TOKEN_SCRIPT?.trim() || undefined;
46
+
47
+ if (env.GITLAB_USE_OAUTH && env.GITLAB_OAUTH_CLIENT_ID) {
48
+ this.oauthManager = new GitLabOAuthManager(
49
+ {
50
+ clientId: env.GITLAB_OAUTH_CLIENT_ID,
51
+ clientSecret: env.GITLAB_OAUTH_CLIENT_SECRET,
52
+ gitlabUrl: env.GITLAB_OAUTH_GITLAB_URL || deriveGitLabBaseUrl(env.GITLAB_API_URL),
53
+ redirectUri: env.GITLAB_OAUTH_REDIRECT_URI || "http://127.0.0.1:8765/callback",
54
+ scopes: parseOauthScopes(env.GITLAB_OAUTH_SCOPES),
55
+ tokenStoragePath: resolveHomePath(env.GITLAB_OAUTH_TOKEN_PATH),
56
+ autoOpenBrowser: env.GITLAB_OAUTH_AUTO_OPEN_BROWSER
57
+ },
58
+ this.logger
59
+ );
60
+ }
61
+ }
62
+
63
+ async beforeRequest(context: GitLabBeforeRequestContext): Promise<GitLabBeforeRequestResult> {
64
+ await this.reloadCookiesIfChanged();
65
+
66
+ const headers = new Headers(context.headers);
67
+ this.applyCompatibilityHeaders(headers);
68
+
69
+ let token = context.token;
70
+ if (!token) {
71
+ token = await this.resolveFallbackToken();
72
+ }
73
+
74
+ if (this.cookieJar) {
75
+ await this.ensureSessionWarmup(context.url, headers, token);
76
+ }
77
+
78
+ return {
79
+ headers,
80
+ token,
81
+ fetchImpl: this.fetchImpl
82
+ };
83
+ }
84
+
85
+ private async resolveFallbackToken(): Promise<string | undefined> {
86
+ const now = Date.now();
87
+ if (this.cachedToken && this.cachedToken.expiresAt > now) {
88
+ return this.cachedToken.value;
89
+ }
90
+
91
+ if (this.oauthManager) {
92
+ const token = await this.oauthManager.getAccessToken();
93
+ if (token) {
94
+ return token;
95
+ }
96
+ }
97
+
98
+ if (this.tokenScript) {
99
+ const token = await this.loadTokenFromScript(this.tokenScript);
100
+ if (token) {
101
+ const ttlMs = this.env.GITLAB_TOKEN_CACHE_SECONDS * 1000;
102
+ this.cachedToken = {
103
+ value: token,
104
+ expiresAt: now + ttlMs
105
+ };
106
+ }
107
+ return token;
108
+ }
109
+
110
+ if (this.tokenFilePath) {
111
+ const token = await this.loadTokenFromFile(this.tokenFilePath);
112
+ if (token) {
113
+ const ttlMs = this.env.GITLAB_TOKEN_CACHE_SECONDS * 1000;
114
+ this.cachedToken = {
115
+ value: token,
116
+ expiresAt: now + ttlMs
117
+ };
118
+ }
119
+ return token;
120
+ }
121
+
122
+ return undefined;
123
+ }
124
+
125
+ private async loadTokenFromScript(script: string): Promise<string | undefined> {
126
+ try {
127
+ const { stdout } = await execAsync(script, {
128
+ timeout: this.env.GITLAB_TOKEN_SCRIPT_TIMEOUT_MS,
129
+ maxBuffer: 1024 * 1024
130
+ });
131
+
132
+ return parseTokenOutput(stdout);
133
+ } catch (error) {
134
+ this.logger.warn({ err: error }, "Failed to execute GITLAB_TOKEN_SCRIPT");
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ private async loadTokenFromFile(tokenFilePath: string): Promise<string | undefined> {
140
+ try {
141
+ const stat = await fs.stat(tokenFilePath);
142
+
143
+ // Group/other bits on token files are rejected unless explicitly allowed.
144
+ if ((stat.mode & 0o077) !== 0 && !this.env.GITLAB_ALLOW_INSECURE_TOKEN_FILE) {
145
+ throw new Error(
146
+ `Token file '${tokenFilePath}' is too permissive. Set chmod 600 or GITLAB_ALLOW_INSECURE_TOKEN_FILE=true.`
147
+ );
148
+ }
149
+
150
+ const content = (await fs.readFile(tokenFilePath, "utf8")).trim();
151
+ return parseTokenOutput(content);
152
+ } catch (error) {
153
+ this.logger.warn({ err: error, tokenFilePath }, "Failed to read GITLAB_TOKEN_FILE");
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ private applyCompatibilityHeaders(headers: Headers): void {
159
+ const userAgent =
160
+ this.env.GITLAB_USER_AGENT?.trim() ||
161
+ (this.env.GITLAB_CLOUDFLARE_BYPASS ? DEFAULT_BROWSER_UA : undefined);
162
+ if (userAgent && !headers.has("User-Agent")) {
163
+ headers.set("User-Agent", userAgent);
164
+ }
165
+
166
+ if (this.env.GITLAB_CLOUDFLARE_BYPASS) {
167
+ if (!headers.has("Accept-Language")) {
168
+ headers.set("Accept-Language", this.env.GITLAB_ACCEPT_LANGUAGE || "en-US,en;q=0.9");
169
+ }
170
+ if (!headers.has("Cache-Control")) {
171
+ headers.set("Cache-Control", "no-cache");
172
+ }
173
+ if (!headers.has("Pragma")) {
174
+ headers.set("Pragma", "no-cache");
175
+ }
176
+ }
177
+ }
178
+
179
+ private async reloadCookiesIfChanged(): Promise<void> {
180
+ if (!this.cookiePath) {
181
+ return;
182
+ }
183
+
184
+ if (this.cookieReloadLock) {
185
+ await this.cookieReloadLock;
186
+ return;
187
+ }
188
+
189
+ this.cookieReloadLock = (async () => {
190
+ try {
191
+ const stat = await fs.stat(this.cookiePath!);
192
+ if (stat.mtimeMs === this.cookieMtime) {
193
+ return;
194
+ }
195
+
196
+ const cookieContent = await fs.readFile(this.cookiePath!, "utf8");
197
+ const jar = createCookieJarFromNetscape(cookieContent);
198
+ this.cookieJar = jar;
199
+ this.fetchImpl = fetchCookie(fetch, jar) as unknown as typeof fetch;
200
+ this.cookieMtime = stat.mtimeMs;
201
+ this.warmedApiRoots.clear();
202
+ this.logger.info({ cookiePath: this.cookiePath }, "Loaded auth cookies");
203
+ } catch (error) {
204
+ if (this.cookieJar) {
205
+ this.logger.warn({ err: error, cookiePath: this.cookiePath }, "Clearing auth cookies");
206
+ }
207
+ this.cookieJar = null;
208
+ this.fetchImpl = fetch;
209
+ this.cookieMtime = 0;
210
+ this.warmedApiRoots.clear();
211
+ }
212
+ })();
213
+
214
+ try {
215
+ await this.cookieReloadLock;
216
+ } finally {
217
+ this.cookieReloadLock = null;
218
+ }
219
+ }
220
+
221
+ private async ensureSessionWarmup(url: URL, headers: Headers, token?: string): Promise<void> {
222
+ const apiRoot = resolveApiRoot(url);
223
+ if (!apiRoot) {
224
+ return;
225
+ }
226
+ const apiRootKey = `${url.origin}${apiRoot}`;
227
+ if (this.warmedApiRoots.has(apiRootKey)) {
228
+ return;
229
+ }
230
+
231
+ const warmupUrl = new URL(`${apiRoot}${this.warmupPath}`, url.origin);
232
+ const warmupHeaders = new Headers(headers);
233
+ if (!warmupHeaders.has("Accept")) {
234
+ warmupHeaders.set("Accept", "application/json");
235
+ }
236
+ if (token && !warmupHeaders.has("PRIVATE-TOKEN")) {
237
+ warmupHeaders.set("PRIVATE-TOKEN", token);
238
+ }
239
+
240
+ try {
241
+ const response = await this.fetchImpl(warmupUrl, {
242
+ method: "GET",
243
+ headers: warmupHeaders,
244
+ redirect: "follow",
245
+ signal: AbortSignal.timeout(Math.min(this.env.GITLAB_HTTP_TIMEOUT_MS, 12_000))
246
+ });
247
+
248
+ if (response.status < 500) {
249
+ this.warmedApiRoots.add(apiRootKey);
250
+ }
251
+ } catch (error) {
252
+ this.logger.debug({ err: error, warmupUrl: warmupUrl.toString() }, "Cookie warmup failed");
253
+ }
254
+ }
255
+ }
256
+
257
+ function resolveHomePath(input?: string): string | undefined {
258
+ if (!input) {
259
+ return undefined;
260
+ }
261
+
262
+ if (input.startsWith("~/")) {
263
+ return path.join(os.homedir(), input.slice(2));
264
+ }
265
+
266
+ return input;
267
+ }
268
+
269
+ function normalizeWarmupPath(value: string): string {
270
+ const trimmed = value.trim();
271
+ if (!trimmed) {
272
+ return "/user";
273
+ }
274
+
275
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
276
+ }
277
+
278
+ function resolveApiRoot(url: URL): string | undefined {
279
+ const match = url.pathname.match(/^(.*\/api\/v4)(?:\/|$)/);
280
+ return match?.[1];
281
+ }
282
+
283
+ function createCookieJarFromNetscape(content: string): CookieJar {
284
+ const jar = new CookieJar();
285
+ const lines = content.split("\n");
286
+
287
+ for (let raw of lines) {
288
+ let httpOnly = false;
289
+ if (raw.startsWith("#HttpOnly_")) {
290
+ raw = raw.slice("#HttpOnly_".length);
291
+ httpOnly = true;
292
+ }
293
+
294
+ if (!raw.trim() || raw.startsWith("#")) {
295
+ continue;
296
+ }
297
+
298
+ const parts = raw.split("\t");
299
+ if (parts.length < 7) {
300
+ continue;
301
+ }
302
+
303
+ const domain = parts[0];
304
+ const cookiePath = parts[2];
305
+ const secure = parts[3];
306
+ const expires = parts[4];
307
+ const name = parts[5];
308
+ const value = parts[6];
309
+ if (
310
+ domain === undefined ||
311
+ cookiePath === undefined ||
312
+ secure === undefined ||
313
+ expires === undefined ||
314
+ name === undefined ||
315
+ value === undefined
316
+ ) {
317
+ continue;
318
+ }
319
+ const secureFlag = secure === "TRUE" ? "; Secure" : "";
320
+ const httpOnlyFlag = httpOnly ? "; HttpOnly" : "";
321
+ const expiresFlag =
322
+ expires === "0"
323
+ ? ""
324
+ : `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
325
+
326
+ const cookieString = `${name}=${value}; Domain=${domain}; Path=${cookiePath}${secureFlag}${httpOnlyFlag}${expiresFlag}`;
327
+ const cookie = Cookie.parse(cookieString);
328
+ if (!cookie) {
329
+ continue;
330
+ }
331
+
332
+ const normalizedDomain = domain.startsWith(".") ? domain.slice(1) : domain;
333
+ const targetUrl = `${secure === "TRUE" ? "https" : "http"}://${normalizedDomain}`;
334
+ try {
335
+ jar.setCookieSync(cookie, targetUrl);
336
+ } catch {
337
+ // ignore invalid cookies from external files
338
+ }
339
+ }
340
+
341
+ return jar;
342
+ }
343
+
344
+ function parseTokenOutput(rawOutput: string): string | undefined {
345
+ const output = rawOutput.trim();
346
+ if (!output) {
347
+ return undefined;
348
+ }
349
+
350
+ try {
351
+ const parsed = JSON.parse(output) as Record<string, unknown>;
352
+ const token =
353
+ getStringField(parsed, "token") ||
354
+ getStringField(parsed, "access_token") ||
355
+ getStringField(parsed, "private_token");
356
+ if (token) {
357
+ return token;
358
+ }
359
+ } catch {
360
+ // Plain string output is valid.
361
+ }
362
+
363
+ return output.split(/\r?\n/, 1)[0]?.trim() || undefined;
364
+ }
365
+
366
+ function getStringField(record: Record<string, unknown>, key: string): string | undefined {
367
+ const value = record[key];
368
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
369
+ }
370
+
371
+ function parseOauthScopes(rawScopes: string): string[] {
372
+ return rawScopes
373
+ .split(/[,\s]+/)
374
+ .map((scope) => scope.trim())
375
+ .filter((scope) => scope.length > 0);
376
+ }
@@ -0,0 +1,25 @@
1
+ export function stripNullsDeep<T>(value: T): T {
2
+ if (value === null) {
3
+ return undefined as T;
4
+ }
5
+
6
+ if (Array.isArray(value)) {
7
+ return value.map((item) => stripNullsDeep(item)).filter((item) => item !== undefined) as T;
8
+ }
9
+
10
+ if (typeof value === "object" && value !== undefined) {
11
+ const input = value as Record<string, unknown>;
12
+ const output: Record<string, unknown> = {};
13
+
14
+ for (const [key, item] of Object.entries(input)) {
15
+ const normalized = stripNullsDeep(item);
16
+ if (normalized !== undefined) {
17
+ output[key] = normalized;
18
+ }
19
+ }
20
+
21
+ return output as T;
22
+ }
23
+
24
+ return value;
25
+ }
@@ -0,0 +1,17 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+
3
+ import { registerGitLabTools } from "../tools/gitlab.js";
4
+ import { registerHealthTool } from "../tools/health.js";
5
+ import type { AppContext } from "../types/context.js";
6
+
7
+ export function createMcpServer(context: AppContext): McpServer {
8
+ const server = new McpServer({
9
+ name: context.env.MCP_SERVER_NAME,
10
+ version: context.env.MCP_SERVER_VERSION
11
+ });
12
+
13
+ registerHealthTool(server);
14
+ registerGitLabTools(server, context);
15
+
16
+ return server;
17
+ }