@zereight/mcp-gitlab 2.1.28 → 2.1.30

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 (72) hide show
  1. package/README.ko.md +82 -28
  2. package/README.md +56 -23
  3. package/README.zh-CN.md +82 -28
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +564 -482
  7. package/build/oauth-proxy.js +18 -4
  8. package/build/schemas.js +47 -9
  9. package/build/scripts/check-skill-sync.js +137 -0
  10. package/build/scripts/generate-tool-docs.js +7 -2
  11. package/build/server/metrics.js +84 -0
  12. package/build/server/transport-mode.js +25 -0
  13. package/build/test/callback-proxy-tests.js +1 -1
  14. package/build/test/client-pool-test.js +1 -1
  15. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  16. package/build/test/dynamic-api-url-test.js +4 -4
  17. package/build/test/dynamic-routing-tests.js +4 -4
  18. package/build/test/mcp-oauth-tests.js +17 -4
  19. package/build/test/multi-server-test.js +3 -3
  20. package/build/test/no-proxy-integration-test.js +1 -1
  21. package/build/test/path-segment-encoding.test.js +27 -0
  22. package/build/test/remote-auth-simple-test.js +226 -199
  23. package/build/test/server/metrics.test.js +47 -0
  24. package/build/test/sse-auth-guard.test.js +2 -2
  25. package/build/test/stateless/callback-proxy.test.js +8 -0
  26. package/build/test/stateless/session-id-integration.test.js +3 -3
  27. package/build/test/streamable-http-concurrent-session.test.js +1 -1
  28. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  29. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  30. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  31. package/build/test/test-ci-catalog.js +1 -1
  32. package/build/test/test-ci-lint.js +1 -1
  33. package/build/test/test-ci-variables.js +8 -5
  34. package/build/test/test-dependency-proxy.js +11 -7
  35. package/build/test/test-deployment-tools.js +16 -2
  36. package/build/test/test-download-attachment.js +1 -1
  37. package/build/test/test-get-file-blame.js +1 -1
  38. package/build/test/test-geteffectiveprojectid.js +7 -7
  39. package/build/test/test-issue-description-patch.js +1 -1
  40. package/build/test/test-job-artifacts.js +1 -1
  41. package/build/test/test-list-issues.js +1 -1
  42. package/build/test/test-list-merge-requests.js +1 -1
  43. package/build/test/test-list-project-members.js +1 -1
  44. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  45. package/build/test/test-merge-request-pipelines.js +1 -1
  46. package/build/test/test-mr-diffs-filter.js +1 -1
  47. package/build/test/test-mr-file-diffs.js +2 -2
  48. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  49. package/build/test/test-permission-mode.js +215 -0
  50. package/build/test/test-protected-branches.js +1 -1
  51. package/build/test/test-remote-downloads.js +2 -2
  52. package/build/test/test-search-code.js +1 -1
  53. package/build/test/test-tags.js +1 -1
  54. package/build/test/test-todos.js +1 -1
  55. package/build/test/test-token-optimizations.js +3 -3
  56. package/build/test/test-toolset-filtering.js +1 -1
  57. package/build/test/test-update-issue-slim.js +141 -0
  58. package/build/test/test-upload-markdown.js +1 -1
  59. package/build/test/utils/download-token.test.js +35 -0
  60. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  61. package/build/test/utils/graphql-query.test.js +64 -1
  62. package/build/test/utils/mock-gitlab-server.js +24 -31
  63. package/build/test/utils/redact-sensitive.test.js +32 -0
  64. package/build/test/utils/server-launcher.js +1 -2
  65. package/build/test/utils/version-check.test.js +52 -0
  66. package/build/tools/registry.js +30 -5
  67. package/build/utils/download-token.js +71 -0
  68. package/build/utils/forwarded-public-base-url.js +22 -0
  69. package/build/utils/graphql-query.js +109 -12
  70. package/build/utils/redact-sensitive.js +21 -0
  71. package/build/utils/version-check.js +40 -0
  72. package/package.json +5 -4
@@ -67,6 +67,12 @@ const REQUIRED_GITLAB_SCOPES_RO = ["read_api"];
67
67
  const PENDING_AUTH_MAX_SIZE = 1000;
68
68
  const PENDING_AUTH_TTL_MS = 10 * 60 * 1000; // 10 minutes
69
69
  const CLIENT_CACHE_MAX_SIZE = 1000;
70
+ function singleQueryParam(value) {
71
+ return typeof value === "string" ? value : undefined;
72
+ }
73
+ function invalidQueryParam(value) {
74
+ return value != null && typeof value !== "string";
75
+ }
70
76
  class BoundedLRUMap {
71
77
  _map = new Map();
72
78
  _maxSize;
@@ -503,11 +509,19 @@ class GitLabOAuthServerProvider {
503
509
  res.status(404).send("Callback proxy mode is not enabled");
504
510
  return;
505
511
  }
506
- const code = req.query.code;
507
- const state = req.query.state;
508
- const error = req.query.error;
512
+ if (invalidQueryParam(req.query.code) ||
513
+ invalidQueryParam(req.query.state) ||
514
+ invalidQueryParam(req.query.error) ||
515
+ invalidQueryParam(req.query.error_description)) {
516
+ res.status(400).send("Invalid query parameter");
517
+ return;
518
+ }
519
+ const code = singleQueryParam(req.query.code);
520
+ const state = singleQueryParam(req.query.state);
521
+ const error = singleQueryParam(req.query.error);
522
+ const errorDescription = singleQueryParam(req.query.error_description) ?? "(no description)";
509
523
  if (error) {
510
- logger.error(`GitLab OAuth error: ${error} — ${req.query.error_description ?? "(no description)"}`);
524
+ logger.error(`GitLab OAuth error: ${error} — ${errorDescription}`);
511
525
  res.status(400).send("Authorization failed");
512
526
  return;
513
527
  }
package/build/schemas.js CHANGED
@@ -527,7 +527,7 @@ export const GetPipelineJobOutputSchema = z.object({
527
527
  limit: z
528
528
  .number()
529
529
  .optional()
530
- .describe("Maximum number of lines to return from the end of the log (default: 1000)"),
530
+ .describe("Maximum number of lines to return from the end of the log (default/max: 1000)"),
531
531
  offset: z
532
532
  .number()
533
533
  .optional()
@@ -1704,7 +1704,17 @@ export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
1704
1704
  .optional()
1705
1705
  .describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^vendor/", "^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
1706
1706
  });
1707
- export const GetMergeRequestSchema = ProjectParamsSchema.extend({
1707
+ const flexibleBooleanOptional = z.preprocess(val => {
1708
+ if (typeof val !== "string")
1709
+ return val;
1710
+ const normalized = val.trim().toLowerCase();
1711
+ if (normalized === "true")
1712
+ return true;
1713
+ if (normalized === "false")
1714
+ return false;
1715
+ return val;
1716
+ }, z.boolean().optional());
1717
+ const MergeRequestParamsSchema = ProjectParamsSchema.extend({
1708
1718
  project_id: z
1709
1719
  .preprocess(value => (value === undefined || value === null ? value : String(value)), z
1710
1720
  .string({
@@ -1717,7 +1727,10 @@ export const GetMergeRequestSchema = ProjectParamsSchema.extend({
1717
1727
  merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
1718
1728
  source_branch: z.string().optional().describe("Source branch name"),
1719
1729
  });
1720
- export const UpdateMergeRequestSchema = GetMergeRequestSchema.extend({
1730
+ export const GetMergeRequestSchema = MergeRequestParamsSchema.extend({
1731
+ include_summaries: flexibleBooleanOptional.describe("If true, include deployment_summary, commit_addition_summary and approval_summary (extra API calls, larger response). Default false to reduce token usage."),
1732
+ });
1733
+ export const UpdateMergeRequestSchema = MergeRequestParamsSchema.extend({
1721
1734
  title: z.string().optional().describe("The title of the merge request"),
1722
1735
  description: z.string().optional().describe("The description of the merge request"),
1723
1736
  target_branch: z.string().optional().describe("The target branch"),
@@ -1859,14 +1872,14 @@ export const ListMergeRequestPipelinesSchema = ProjectParamsSchema.extend({
1859
1872
  .transform(value => value.trim()))
1860
1873
  .describe("The internal ID of the merge request"),
1861
1874
  }).merge(PaginationOptionsSchema);
1862
- export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1875
+ export const GetMergeRequestDiffsSchema = MergeRequestParamsSchema.extend({
1863
1876
  view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"),
1864
1877
  excluded_file_patterns: z
1865
1878
  .array(z.string())
1866
1879
  .optional()
1867
1880
  .describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^vendor/", "^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
1868
1881
  });
1869
- export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1882
+ export const ListMergeRequestDiffsSchema = MergeRequestParamsSchema.extend({
1870
1883
  page: z.coerce.number().optional().describe("Page number for pagination (default: 1)"),
1871
1884
  per_page: z.coerce
1872
1885
  .number()
@@ -1877,13 +1890,13 @@ export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1877
1890
  .optional()
1878
1891
  .describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
1879
1892
  });
1880
- export const ListMergeRequestChangedFilesSchema = GetMergeRequestSchema.extend({
1893
+ export const ListMergeRequestChangedFilesSchema = MergeRequestParamsSchema.extend({
1881
1894
  excluded_file_patterns: z
1882
1895
  .array(z.string())
1883
1896
  .optional()
1884
1897
  .describe('Array of regex patterns to exclude files. Examples: ["^vendor/", "\\.pb\\.go$"]'),
1885
1898
  });
1886
- export const GetMergeRequestFileDiffSchema = GetMergeRequestSchema.extend({
1899
+ export const GetMergeRequestFileDiffSchema = MergeRequestParamsSchema.extend({
1887
1900
  file_paths: z
1888
1901
  .array(z.string())
1889
1902
  .describe("List of file paths to retrieve diffs for (e.g. ['src/api/users.ts', 'src/repo/user.go']). " +
@@ -2063,6 +2076,7 @@ export const ListMergeRequestsSchema = z
2063
2076
  export const GetIssueSchema = z.object({
2064
2077
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2065
2078
  issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
2079
+ full_response: flexibleBooleanOptional.describe("If true, return the complete issue object including the full milestone description. Default returns a slim milestone (id, iid, title, state, web_url) to reduce token usage."),
2066
2080
  });
2067
2081
  export const UpdateIssueSchema = z.object({
2068
2082
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -2086,6 +2100,7 @@ export const UpdateIssueSchema = z.object({
2086
2100
  issue_type: z
2087
2101
  .preprocess(val => (typeof val === "string" ? val.toLowerCase() : val), z.enum(["issue", "incident", "test_case", "task"]).optional())
2088
2102
  .describe("The type of issue. One of issue, incident, test_case or task."),
2103
+ full_response: flexibleBooleanOptional.describe("If true, return the complete updated issue object. Default returns a slim confirmation (iid, title, state, web_url, updated_at) to reduce token usage."),
2089
2104
  });
2090
2105
  export const DeleteIssueSchema = z.object({
2091
2106
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -2321,11 +2336,19 @@ export const ListWikiPagesSchema = z
2321
2336
  .object({
2322
2337
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2323
2338
  with_content: z.coerce.boolean().optional().describe("Include content of the wiki pages"),
2339
+ render_html: z.coerce
2340
+ .boolean()
2341
+ .optional()
2342
+ .describe("Return rendered HTML content and include front_matter (e.g., the custom title)"),
2324
2343
  })
2325
2344
  .merge(PaginationOptionsSchema);
2326
2345
  export const GetWikiPageSchema = z.object({
2327
2346
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2328
2347
  slug: z.string().describe("Slug of the wiki page (will be URL-encoded internally)"),
2348
+ render_html: z.coerce
2349
+ .boolean()
2350
+ .optional()
2351
+ .describe("Return rendered HTML content and include front_matter (e.g., the custom title)"),
2329
2352
  });
2330
2353
  export const CreateWikiPageSchema = z.object({
2331
2354
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -2336,7 +2359,10 @@ export const CreateWikiPageSchema = z.object({
2336
2359
  export const UpdateWikiPageSchema = z.object({
2337
2360
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2338
2361
  slug: z.string().describe("Slug of the wiki page (will be URL-encoded internally)"),
2339
- title: z.string().optional().describe("New title of the wiki page"),
2362
+ title: z
2363
+ .string()
2364
+ .optional()
2365
+ .describe("New title of the wiki page. WARNING: setting this renames the page and changes its slug/URL (for nested pages it can also move the page to a different path), which breaks existing links. To change only the displayed title while keeping the URL, omit this parameter and instead set a `title:` field in the content's YAML front matter."),
2340
2366
  content: z.string().optional().describe("New content of the wiki page"),
2341
2367
  format: z.string().optional().describe("Content format, e.g., markdown, rdoc"),
2342
2368
  });
@@ -2350,6 +2376,7 @@ export const GitLabWikiPageSchema = z.object({
2350
2376
  slug: z.string(),
2351
2377
  format: z.string(),
2352
2378
  content: z.string().optional(),
2379
+ front_matter: z.record(z.unknown()).optional(),
2353
2380
  created_at: z.string().optional(),
2354
2381
  updated_at: z.string().optional(),
2355
2382
  });
@@ -2358,11 +2385,19 @@ export const ListGroupWikiPagesSchema = z
2358
2385
  .object({
2359
2386
  group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2360
2387
  with_content: z.coerce.boolean().optional().describe("Include content of the wiki pages"),
2388
+ render_html: z.coerce
2389
+ .boolean()
2390
+ .optional()
2391
+ .describe("Return rendered HTML content and include front_matter (e.g., the custom title)"),
2361
2392
  })
2362
2393
  .merge(PaginationOptionsSchema);
2363
2394
  export const GetGroupWikiPageSchema = z.object({
2364
2395
  group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2365
2396
  slug: z.string().describe("Slug of the wiki page (will be URL-encoded internally)"),
2397
+ render_html: z.coerce
2398
+ .boolean()
2399
+ .optional()
2400
+ .describe("Return rendered HTML content and include front_matter (e.g., the custom title)"),
2366
2401
  });
2367
2402
  export const CreateGroupWikiPageSchema = z.object({
2368
2403
  group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
@@ -2373,7 +2408,10 @@ export const CreateGroupWikiPageSchema = z.object({
2373
2408
  export const UpdateGroupWikiPageSchema = z.object({
2374
2409
  group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
2375
2410
  slug: z.string().describe("Slug of the wiki page (will be URL-encoded internally)"),
2376
- title: z.string().optional().describe("New title of the wiki page"),
2411
+ title: z
2412
+ .string()
2413
+ .optional()
2414
+ .describe("New title of the wiki page. WARNING: setting this renames the page and changes its slug/URL (for nested pages it can also move the page to a different path), which breaks existing links. To change only the displayed title while keeping the URL, omit this parameter and instead set a `title:` field in the content's YAML front matter."),
2377
2415
  content: z.string().optional().describe("New content of the wiki page"),
2378
2416
  format: z.string().optional().describe("Content format, e.g., markdown, rdoc"),
2379
2417
  });
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Verify skills/gitlab-mcp/ stays in sync with tools/registry.ts.
4
+ *
5
+ * Run with: npm run check:skill-sync
6
+ */
7
+ import { readFileSync, readdirSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { TOOLSET_DEFINITIONS, allTools, destructiveTools, DEFAULT_TOOLSET_IDS, } from "../tools/registry.js";
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const REPO_ROOT = join(__dirname, "..");
13
+ const SKILL_PATH = join(REPO_ROOT, "skills", "gitlab-mcp", "SKILL.md");
14
+ const REF_DIR = join(REPO_ROOT, "skills", "gitlab-mcp", "reference");
15
+ // Response field names in reference docs — not MCP tools.
16
+ const REFERENCE_FIELD_ALLOWLIST = new Set([
17
+ "allow_multiple",
18
+ "deleted_file",
19
+ "excluded_file_patterns",
20
+ "group_id",
21
+ "new_file",
22
+ "new_path",
23
+ "old_path",
24
+ "project_id",
25
+ "renamed_file",
26
+ ]);
27
+ function fail(message) {
28
+ console.error(`::error::${message}`);
29
+ process.exit(1);
30
+ }
31
+ function parseToolsetTable(skill) {
32
+ const rows = new Map();
33
+ const rowRe = /^\| ([a-z_]+) \((\d+) tools?\) \| (yes|no) \|/gm;
34
+ let match;
35
+ while ((match = rowRe.exec(skill)) !== null) {
36
+ rows.set(match[1], {
37
+ count: Number(match[2]),
38
+ isDefault: match[3] === "yes",
39
+ });
40
+ }
41
+ return rows;
42
+ }
43
+ function parseIntroCounts(skill) {
44
+ const match = skill.match(/providing (\d+) tools: (\d+) tools across (\d+) toolsets/);
45
+ if (!match) {
46
+ fail("skills/gitlab-mcp/SKILL.md intro line is missing or malformed");
47
+ }
48
+ return {
49
+ totalTools: Number(match[1]),
50
+ uniqueInToolsets: Number(match[2]),
51
+ toolsetCount: Number(match[3]),
52
+ };
53
+ }
54
+ function parseDestructiveTools(skill) {
55
+ const section = skill.match(/## Destructive Tools[^\n]*\n\n(`[^`]+`(?:, `[^`]+`)*)$/m);
56
+ if (!section) {
57
+ fail("skills/gitlab-mcp/SKILL.md destructive tools section is missing or malformed");
58
+ }
59
+ return new Set([...section[1].matchAll(/`([a-z_]+)`/g)].map((m) => m[1]));
60
+ }
61
+ function collectReferenceToolNames() {
62
+ const toolNames = new Set();
63
+ for (const file of readdirSync(REF_DIR).filter((name) => name.endsWith(".md"))) {
64
+ const text = readFileSync(join(REF_DIR, file), "utf8");
65
+ for (const match of text.matchAll(/`([a-z][a-z0-9_]*)`/g)) {
66
+ const name = match[1];
67
+ if (!REFERENCE_FIELD_ALLOWLIST.has(name)) {
68
+ toolNames.add(name);
69
+ }
70
+ }
71
+ }
72
+ return toolNames;
73
+ }
74
+ const skill = readFileSync(SKILL_PATH, "utf8");
75
+ const errors = [];
76
+ const registryCounts = Object.fromEntries(TOOLSET_DEFINITIONS.map((definition) => [definition.id, definition.tools.size]));
77
+ const skillRows = parseToolsetTable(skill);
78
+ if (skillRows.size !== TOOLSET_DEFINITIONS.length) {
79
+ errors.push(`toolset table has ${skillRows.size} rows; registry has ${TOOLSET_DEFINITIONS.length}`);
80
+ }
81
+ for (const definition of TOOLSET_DEFINITIONS) {
82
+ const row = skillRows.get(definition.id);
83
+ if (!row) {
84
+ errors.push(`missing toolset row for ${definition.id}`);
85
+ continue;
86
+ }
87
+ const actualCount = registryCounts[definition.id];
88
+ if (row.count !== actualCount) {
89
+ errors.push(`${definition.id}: skill says ${row.count}, registry has ${actualCount}`);
90
+ }
91
+ const expectedDefault = DEFAULT_TOOLSET_IDS.has(definition.id);
92
+ if (row.isDefault !== expectedDefault) {
93
+ errors.push(`${definition.id}: skill default=${row.isDefault}, registry default=${expectedDefault}`);
94
+ }
95
+ }
96
+ for (const id of skillRows.keys()) {
97
+ if (!registryCounts[id]) {
98
+ errors.push(`unknown toolset in skill table: ${id}`);
99
+ }
100
+ }
101
+ const uniqueInToolsets = new Set(TOOLSET_DEFINITIONS.flatMap((definition) => [...definition.tools]))
102
+ .size;
103
+ const intro = parseIntroCounts(skill);
104
+ if (intro.totalTools !== allTools.length) {
105
+ errors.push(`intro total tools: skill says ${intro.totalTools}, registry has ${allTools.length}`);
106
+ }
107
+ if (intro.uniqueInToolsets !== uniqueInToolsets) {
108
+ errors.push(`intro unique toolset tools: skill says ${intro.uniqueInToolsets}, registry has ${uniqueInToolsets}`);
109
+ }
110
+ if (intro.toolsetCount !== TOOLSET_DEFINITIONS.length) {
111
+ errors.push(`intro toolset count: skill says ${intro.toolsetCount}, registry has ${TOOLSET_DEFINITIONS.length}`);
112
+ }
113
+ const skillDestructive = parseDestructiveTools(skill);
114
+ for (const tool of skillDestructive) {
115
+ if (!destructiveTools.has(tool)) {
116
+ errors.push(`destructive list includes non-destructive tool: ${tool}`);
117
+ }
118
+ }
119
+ for (const tool of destructiveTools) {
120
+ if (!skillDestructive.has(tool)) {
121
+ errors.push(`destructive list missing registry tool: ${tool}`);
122
+ }
123
+ }
124
+ const registryToolNames = new Set(allTools.map((tool) => tool.name));
125
+ for (const tool of collectReferenceToolNames()) {
126
+ if (!registryToolNames.has(tool)) {
127
+ errors.push(`reference mentions unknown tool: ${tool}`);
128
+ }
129
+ }
130
+ if (errors.length > 0) {
131
+ console.error("skills/gitlab-mcp/ is out of sync with tools/registry.ts:");
132
+ for (const error of errors) {
133
+ console.error(`- ${error}`);
134
+ }
135
+ process.exit(1);
136
+ }
137
+ console.log("skills/gitlab-mcp/ is in sync with tools/registry.ts");
@@ -273,8 +273,13 @@ function buildIndexPage(groupedToolsList) {
273
273
  "",
274
274
  ...buildToggleSection(groupedToolsList),
275
275
  "",
276
- "Read-only mode (`GITLAB_READ_ONLY_MODE=true`) hides every write tool",
277
- "regardless of toggles. See [Environment Variables](../configuration/environment-variables.md)",
276
+ "Permission modes control which tools are exposed:",
277
+ "",
278
+ "- `GITLAB_PERMISSION_MODE=readonly` — hides every write tool regardless of toggles.",
279
+ "- `GITLAB_PERMISSION_MODE=modify` — allows create/update but blocks all `delete_*` tools.",
280
+ "- `GITLAB_READ_ONLY_MODE=true` (deprecated) — same as `readonly`; prefer `GITLAB_PERMISSION_MODE=readonly`.",
281
+ "",
282
+ "See [Environment Variables](../configuration/environment-variables.md)",
278
283
  "and [CLI Arguments](../getting-started/cli-arguments.md) for the full list.",
279
284
  "",
280
285
  "## Legend",
@@ -0,0 +1,84 @@
1
+ export function escapePrometheusLabel(value) {
2
+ return String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
3
+ }
4
+ export function formatPrometheusMetrics(snapshot) {
5
+ const configLabels = Object.entries({
6
+ max_sessions: snapshot.config.maxSessions,
7
+ max_requests_per_minute: snapshot.config.maxRequestsPerMinute,
8
+ session_timeout_seconds: snapshot.config.sessionTimeoutSeconds,
9
+ remote_auth_enabled: snapshot.config.remoteAuthEnabled,
10
+ mcp_oauth_enabled: snapshot.config.mcpOAuthEnabled,
11
+ stateless_mode_enabled: snapshot.config.statelessModeEnabled,
12
+ stateless_rotation_key: snapshot.config.statelessRotationKey,
13
+ })
14
+ .map(([key, value]) => `${key}="${escapePrometheusLabel(value)}"`)
15
+ .join(",");
16
+ return [
17
+ "# HELP gitlab_mcp_requests_processed_total Total MCP requests processed",
18
+ "# TYPE gitlab_mcp_requests_processed_total counter",
19
+ `gitlab_mcp_requests_processed_total ${snapshot.requestsProcessed}`,
20
+ "",
21
+ "# HELP gitlab_mcp_requests_rejected_total Requests rejected, by reason",
22
+ "# TYPE gitlab_mcp_requests_rejected_total counter",
23
+ `gitlab_mcp_requests_rejected_total{reason="rate_limit"} ${snapshot.rejectedByRateLimit}`,
24
+ `gitlab_mcp_requests_rejected_total{reason="capacity"} ${snapshot.rejectedByCapacity}`,
25
+ "",
26
+ "# HELP gitlab_mcp_auth_failures_total Authentication failures",
27
+ "# TYPE gitlab_mcp_auth_failures_total counter",
28
+ `gitlab_mcp_auth_failures_total ${snapshot.authFailures}`,
29
+ "",
30
+ "# HELP gitlab_mcp_sessions_total Total sessions created",
31
+ "# TYPE gitlab_mcp_sessions_total counter",
32
+ `gitlab_mcp_sessions_total ${snapshot.totalSessions}`,
33
+ "",
34
+ "# HELP gitlab_mcp_sessions_expired_total Sessions expired due to inactivity",
35
+ "# TYPE gitlab_mcp_sessions_expired_total counter",
36
+ `gitlab_mcp_sessions_expired_total ${snapshot.expiredSessions}`,
37
+ "",
38
+ "# HELP gitlab_mcp_active_sessions Currently active sessions",
39
+ "# TYPE gitlab_mcp_active_sessions gauge",
40
+ `gitlab_mcp_active_sessions ${snapshot.activeSessions}`,
41
+ "",
42
+ "# HELP gitlab_mcp_authenticated_sessions Currently authenticated sessions",
43
+ "# TYPE gitlab_mcp_authenticated_sessions gauge",
44
+ `gitlab_mcp_authenticated_sessions ${snapshot.authenticatedSessions}`,
45
+ "",
46
+ "# HELP gitlab_mcp_client_pool_size Current GitLab client pool size",
47
+ "# TYPE gitlab_mcp_client_pool_size gauge",
48
+ `gitlab_mcp_client_pool_size ${snapshot.gitlabClientPool.size}`,
49
+ "",
50
+ "# HELP gitlab_mcp_client_pool_max_size Maximum GitLab client pool size",
51
+ "# TYPE gitlab_mcp_client_pool_max_size gauge",
52
+ `gitlab_mcp_client_pool_max_size ${snapshot.gitlabClientPool.maxSize}`,
53
+ "",
54
+ "# HELP gitlab_mcp_uptime_seconds Process uptime in seconds",
55
+ "# TYPE gitlab_mcp_uptime_seconds gauge",
56
+ `gitlab_mcp_uptime_seconds ${snapshot.uptime}`,
57
+ "",
58
+ "# HELP gitlab_mcp_memory_usage_bytes Node.js memory usage by type",
59
+ "# TYPE gitlab_mcp_memory_usage_bytes gauge",
60
+ ...Object.entries(snapshot.memoryUsage).map(([key, value]) => `gitlab_mcp_memory_usage_bytes{type="${escapePrometheusLabel(key)}"} ${value}`),
61
+ "",
62
+ "# HELP gitlab_mcp_stateless_requests_total Stateless MCP requests processed",
63
+ "# TYPE gitlab_mcp_stateless_requests_total counter",
64
+ `gitlab_mcp_stateless_requests_total ${snapshot.statelessRequests}`,
65
+ "",
66
+ "# HELP gitlab_mcp_stateless_auth_total Stateless auth successes, by source",
67
+ "# TYPE gitlab_mcp_stateless_auth_total counter",
68
+ `gitlab_mcp_stateless_auth_total{source="header"} ${snapshot.statelessAuthFromHeader}`,
69
+ `gitlab_mcp_stateless_auth_total{source="sealed_session_id"} ${snapshot.statelessAuthFromSealedSid}`,
70
+ "",
71
+ "# HELP gitlab_mcp_stateless_auth_failures_total Stateless auth failures",
72
+ "# TYPE gitlab_mcp_stateless_auth_failures_total counter",
73
+ `gitlab_mcp_stateless_auth_failures_total ${snapshot.statelessAuthFailures}`,
74
+ "",
75
+ "# HELP gitlab_mcp_stateless_session_id_rotations_total Stateless session id rotations",
76
+ "# TYPE gitlab_mcp_stateless_session_id_rotations_total counter",
77
+ `gitlab_mcp_stateless_session_id_rotations_total ${snapshot.statelessSidRotated}`,
78
+ "",
79
+ "# HELP gitlab_mcp_config_info Static configuration (value is always 1)",
80
+ "# TYPE gitlab_mcp_config_info gauge",
81
+ `gitlab_mcp_config_info{${configLabels}} 1`,
82
+ "",
83
+ ].join("\n");
84
+ }
@@ -0,0 +1,25 @@
1
+ import { SSE, STREAMABLE_HTTP } from "../config.js";
2
+ /**
3
+ * Available transport modes for MCP server.
4
+ */
5
+ export var TransportMode;
6
+ (function (TransportMode) {
7
+ TransportMode["STDIO"] = "stdio";
8
+ TransportMode["SSE"] = "sse";
9
+ TransportMode["STREAMABLE_HTTP"] = "streamable-http";
10
+ })(TransportMode || (TransportMode = {}));
11
+ /**
12
+ * Determine the transport mode based on environment variables and availability.
13
+ *
14
+ * Transport mode priority (highest to lowest):
15
+ * 1. STREAMABLE_HTTP
16
+ * 2. SSE
17
+ * 3. STDIO
18
+ */
19
+ export function determineTransportMode() {
20
+ if (STREAMABLE_HTTP)
21
+ return TransportMode.STREAMABLE_HTTP;
22
+ if (SSE)
23
+ return TransportMode.SSE;
24
+ return TransportMode.STDIO;
25
+ }
@@ -95,7 +95,7 @@ describe("Callback Proxy Mode", () => {
95
95
  // Track the last code+verifier GitLab received so we can verify the proxy PKCE
96
96
  let lastTokenRequest = {};
97
97
  before(async () => {
98
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
98
+ const mockPort = await findMockServerPort();
99
99
  mockGitLab = new MockGitLabServer({
100
100
  port: mockPort,
101
101
  validTokens: [MOCK_ACCESS_TOKEN],
@@ -22,7 +22,7 @@ describe('Client Pool Limits', () => {
22
22
  let mockGitLabUrl;
23
23
  before(async () => {
24
24
  // Start mock GitLab server
25
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
25
+ const mockPort = await findMockServerPort();
26
26
  mockGitLab = new MockGitLabServer({
27
27
  port: mockPort,
28
28
  validTokens: [MOCK_TOKEN]
@@ -30,10 +30,10 @@ describe("Dynamic API URL allowlist", () => {
30
30
  let secondaryHit = false;
31
31
  let getAttackerHits = () => 0;
32
32
  before(async () => {
33
- const primaryPort = await findMockServerPort(9100);
33
+ const primaryPort = await findMockServerPort();
34
34
  primaryGitLab = new MockGitLabServer({ port: primaryPort, validTokens: [MOCK_TOKEN] });
35
35
  await primaryGitLab.start();
36
- const secondaryPort = await findMockServerPort(9200);
36
+ const secondaryPort = await findMockServerPort();
37
37
  secondaryGitLab = new MockGitLabServer({ port: secondaryPort, validTokens: [MOCK_TOKEN] });
38
38
  secondaryGitLab.addMockHandler("get", "/projects/1/issues", (_req, res) => {
39
39
  secondaryHit = true;
@@ -25,7 +25,7 @@ describe('Dynamic API URL - Multiple GitLab Instances', () => {
25
25
  let servers = [];
26
26
  before(async () => {
27
27
  // Start first mock GitLab server
28
- const mockPort1 = await findMockServerPort(MOCK_GITLAB_PORT_BASE_1);
28
+ const mockPort1 = await findMockServerPort();
29
29
  mockGitLab1 = new MockGitLabServer({
30
30
  port: mockPort1,
31
31
  validTokens: [MOCK_TOKEN_1]
@@ -33,7 +33,7 @@ describe('Dynamic API URL - Multiple GitLab Instances', () => {
33
33
  await mockGitLab1.start();
34
34
  mockGitLabUrl1 = mockGitLab1.getUrl();
35
35
  // Start second mock GitLab server
36
- const mockPort2 = await findMockServerPort(MOCK_GITLAB_PORT_BASE_2);
36
+ const mockPort2 = await findMockServerPort();
37
37
  mockGitLab2 = new MockGitLabServer({
38
38
  port: mockPort2,
39
39
  validTokens: [MOCK_TOKEN_2]
@@ -203,14 +203,14 @@ describe('Dynamic API URL - Connection Pool', () => {
203
203
  let servers = [];
204
204
  before(async () => {
205
205
  // Start mock GitLab servers
206
- const mockPort1 = await findMockServerPort(MOCK_GITLAB_PORT_BASE_1 + 50);
206
+ const mockPort1 = await findMockServerPort();
207
207
  mockGitLab1 = new MockGitLabServer({
208
208
  port: mockPort1,
209
209
  validTokens: [MOCK_TOKEN_1]
210
210
  });
211
211
  await mockGitLab1.start();
212
212
  mockGitLabUrl1 = mockGitLab1.getUrl();
213
- const mockPort2 = await findMockServerPort(MOCK_GITLAB_PORT_BASE_2 + 50);
213
+ const mockPort2 = await findMockServerPort();
214
214
  mockGitLab2 = new MockGitLabServer({
215
215
  port: mockPort2,
216
216
  validTokens: [MOCK_TOKEN_2]
@@ -29,7 +29,7 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
29
29
  // to avoid launchServer overwriting GITLAB_PERSONAL_ACCESS_TOKEN with a different value
30
30
  process.env.GITLAB_TOKEN_TEST = MOCK_TOKEN_DEFAULT;
31
31
  process.env.TEST_PROJECT_ID = '1';
32
- const mockPort = await findMockServerPort(9021);
32
+ const mockPort = await findMockServerPort();
33
33
  mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN_DEFAULT] });
34
34
  await mockServer.start();
35
35
  const mcpPort = await findAvailablePort(3021);
@@ -82,7 +82,7 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
82
82
  let mcpUrl;
83
83
  let mockServer;
84
84
  before(async () => {
85
- const mockPort = await findMockServerPort(9022);
85
+ const mockPort = await findMockServerPort();
86
86
  mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN_HEADER] });
87
87
  await mockServer.start();
88
88
  const mcpPort = await findAvailablePort(3022);
@@ -146,10 +146,10 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
146
146
  let defaultMockServer;
147
147
  let headerMockServer;
148
148
  before(async () => {
149
- const defaultPort = await findMockServerPort(9024);
149
+ const defaultPort = await findMockServerPort();
150
150
  defaultMockServer = new MockGitLabServer({ port: defaultPort, validTokens: [MOCK_TOKEN_DEFAULT, MOCK_TOKEN_HEADER] });
151
151
  await defaultMockServer.start();
152
- const headerPort = await findMockServerPort(9025);
152
+ const headerPort = await findMockServerPort();
153
153
  headerMockServer = new MockGitLabServer({ port: headerPort, validTokens: [MOCK_TOKEN_DEFAULT, MOCK_TOKEN_HEADER] });
154
154
  await headerMockServer.start();
155
155
  const mcpPort = await findAvailablePort(3024);
@@ -83,7 +83,7 @@ describe("MCP OAuth — Discovery Endpoints", () => {
83
83
  let mockGitLab;
84
84
  let servers = [];
85
85
  before(async () => {
86
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
86
+ const mockPort = await findMockServerPort();
87
87
  mockGitLab = new MockGitLabServer({
88
88
  port: mockPort,
89
89
  validTokens: [MOCK_OAUTH_TOKEN],
@@ -138,7 +138,7 @@ describe("MCP OAuth — Discovery Endpoints", () => {
138
138
  console.log(" ✓ Protected resource metadata returned");
139
139
  });
140
140
  test("path-prefixed MCP_SERVER_URL serves path-aware discovery metadata", async () => {
141
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 25);
141
+ const mockPort = await findMockServerPort();
142
142
  const prefixedMockGitLab = new MockGitLabServer({
143
143
  port: mockPort,
144
144
  validTokens: [MOCK_OAUTH_TOKEN],
@@ -196,7 +196,7 @@ describe("MCP OAuth — /mcp Auth Enforcement", () => {
196
196
  let mockGitLab;
197
197
  let servers = [];
198
198
  before(async () => {
199
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 50);
199
+ const mockPort = await findMockServerPort();
200
200
  mockGitLab = new MockGitLabServer({
201
201
  port: mockPort,
202
202
  validTokens: [MOCK_OAUTH_TOKEN],
@@ -492,7 +492,7 @@ describe("MCP OAuth — Header Auth Fallback", () => {
492
492
  let mockGitLab;
493
493
  let servers = [];
494
494
  before(async () => {
495
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 150);
495
+ const mockPort = await findMockServerPort();
496
496
  mockGitLab = new MockGitLabServer({
497
497
  port: mockPort,
498
498
  // Include both OAuth token and raw tokens so GitLab API calls succeed
@@ -566,6 +566,19 @@ describe("MCP OAuth — Header Auth Fallback", () => {
566
566
  assert.notStrictEqual(res.status, 403, "Should not return 403 with valid JOB-TOKEN");
567
567
  console.log(` ✓ JOB-TOKEN header accepted (status: ${res.status})`);
568
568
  });
569
+ test("POST /mcp with invalid Private-Token is rejected before session creation", async () => {
570
+ const res = await fetch(mcpUrl, {
571
+ method: "POST",
572
+ headers: {
573
+ "Content-Type": "application/json",
574
+ Accept: "application/json, text/event-stream",
575
+ "Private-Token": "glpat-invalid-token-0000",
576
+ },
577
+ body: initBody,
578
+ });
579
+ assert.strictEqual(res.status, 401);
580
+ assert.strictEqual(res.headers.get("mcp-session-id"), null);
581
+ });
569
582
  test("POST /mcp with valid OAuth Bearer token still works normally", async () => {
570
583
  const res = await fetch(mcpUrl, {
571
584
  method: "POST",
@@ -45,7 +45,7 @@ describe("Single Client Mode (ENABLE_DYNAMIC_API_URL=false)", () => {
45
45
  let mcpUrl;
46
46
  let mockServer1;
47
47
  before(async () => {
48
- const mockPort = await findMockServerPort(9001);
48
+ const mockPort = await findMockServerPort();
49
49
  mockServer1 = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
50
50
  mockServer1.addMockHandler('get', '/projects/1', (req, res) => { res.json(project1); });
51
51
  await mockServer1.start();
@@ -105,8 +105,8 @@ describe("Dynamic Client Mode (ENABLE_DYNAMIC_API_URL=true)", () => {
105
105
  let mockServer1;
106
106
  let mockServer2;
107
107
  before(async () => {
108
- const port1 = await findMockServerPort(9011);
109
- const port2 = await findMockServerPort(9012);
108
+ const port1 = await findMockServerPort();
109
+ const port2 = await findMockServerPort();
110
110
  mockServer1 = new MockGitLabServer({ port: port1, validTokens: [MOCK_TOKEN] });
111
111
  mockServer2 = new MockGitLabServer({ port: port2, validTokens: [MOCK_TOKEN] });
112
112
  mockServer1.addMockHandler('get', '/projects/1', (req, res) => { res.json(project1); });
@@ -22,7 +22,7 @@ describe('NO_PROXY Integration Tests', () => {
22
22
  let mockGitLabHost;
23
23
  before(async () => {
24
24
  // Start mock GitLab server
25
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
25
+ const mockPort = await findMockServerPort();
26
26
  mockGitLab = new MockGitLabServer({
27
27
  port: mockPort,
28
28
  validTokens: [MOCK_TOKEN]