@zereight/mcp-gitlab 2.1.21 → 2.1.23
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.
- package/README.ko.md +45 -45
- package/README.md +36 -22
- package/README.zh-CN.md +44 -44
- package/build/config.js +8 -2
- package/build/index.js +127 -32
- package/build/oauth.js +9 -9
- package/build/schemas.js +6 -3
- package/build/scripts/generate-tool-docs.js +404 -0
- package/build/test/config-allowed-groups.test.js +97 -0
- package/build/test/test-oauth-proxy-rate-limit.js +133 -0
- package/build/test/test-remote-downloads.js +162 -1
- package/build/test/utils/proxy-client-ip.test.js +28 -0
- package/build/utils/proxy-client-ip.js +11 -0
- package/package.json +2 -2
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Generate per-group tool reference pages under docs/tools/ from the
|
|
4
|
+
* authoritative tool registry (tools/registry.ts).
|
|
5
|
+
*
|
|
6
|
+
* Run with: npx tsx scripts/generate-tool-docs.ts
|
|
7
|
+
* Or via: make tools-docs
|
|
8
|
+
*/
|
|
9
|
+
import { writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { allTools, readOnlyTools, TOOLSET_DEFINITIONS } from "../tools/registry.js";
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const REPO_ROOT = join(__dirname, "..");
|
|
15
|
+
const OUT_DIR = join(REPO_ROOT, "docs", "tools");
|
|
16
|
+
// Legacy single-group env flags that pre-date GITLAB_TOOLSETS.
|
|
17
|
+
// Only three groups have these for backward compatibility; everything else
|
|
18
|
+
// opt-in is configured via GITLAB_TOOLSETS / GITLAB_TOOLS / discover_tools.
|
|
19
|
+
const LEGACY_TOGGLE_ENV = {
|
|
20
|
+
pipelines: "USE_PIPELINE",
|
|
21
|
+
milestones: "USE_MILESTONE",
|
|
22
|
+
wiki: "USE_GITLAB_WIKI",
|
|
23
|
+
};
|
|
24
|
+
function isDefaultToolset(id) {
|
|
25
|
+
return TOOLSET_DEFINITIONS.find(d => d.id === id)?.isDefault ?? false;
|
|
26
|
+
}
|
|
27
|
+
function computeToggleNote(id) {
|
|
28
|
+
if (isDefaultToolset(id))
|
|
29
|
+
return undefined;
|
|
30
|
+
// Synthetic group for tools not in any TOOLSET_DEFINITIONS entry.
|
|
31
|
+
// discover_tools is always exposed; execute_graphql is opt-in via GITLAB_TOOLS.
|
|
32
|
+
if (id === "meta") {
|
|
33
|
+
return "Mixed availability. `discover_tools` is always exposed (the server re-adds it after every toolset filter). `execute_graphql` is not part of any toolset — enable it explicitly with `GITLAB_TOOLS=execute_graphql`.";
|
|
34
|
+
}
|
|
35
|
+
const legacy = LEGACY_TOGGLE_ENV[id];
|
|
36
|
+
if (legacy) {
|
|
37
|
+
return `Opt-in. Enable via \`GITLAB_TOOLSETS=${id}\` (or \`GITLAB_TOOLSETS=all\`), or use the legacy \`${legacy}=true\` flag for backward compatibility.`;
|
|
38
|
+
}
|
|
39
|
+
return `Opt-in. Enable via \`GITLAB_TOOLSETS=${id}\` (or \`GITLAB_TOOLSETS=all\`), list individual tools in \`GITLAB_TOOLS=\`, or activate at runtime with the \`discover_tools\` MCP tool.`;
|
|
40
|
+
}
|
|
41
|
+
const GROUP_META = {
|
|
42
|
+
merge_requests: {
|
|
43
|
+
title: "Merge Requests",
|
|
44
|
+
blurb: "MR lifecycle — create, update, merge, approve, plus diff/conflict inspection and the full discussion/note/draft API.",
|
|
45
|
+
},
|
|
46
|
+
issues: {
|
|
47
|
+
title: "Issues",
|
|
48
|
+
blurb: "Issue CRUD, links, discussions and notes, todos, and emoji reactions.",
|
|
49
|
+
},
|
|
50
|
+
repositories: {
|
|
51
|
+
title: "Projects & Files",
|
|
52
|
+
blurb: "Project search/creation/fork plus the Files API for reading and writing repository content without shelling out to git.",
|
|
53
|
+
},
|
|
54
|
+
branches: {
|
|
55
|
+
title: "Branches & Commits",
|
|
56
|
+
blurb: "Branch management, commit listing/inspection, file blame, and CI commit-status manipulation.",
|
|
57
|
+
},
|
|
58
|
+
projects: {
|
|
59
|
+
title: "Projects & Namespaces",
|
|
60
|
+
blurb: "Project/namespace listing, member queries, group iterations, and server health.",
|
|
61
|
+
},
|
|
62
|
+
labels: {
|
|
63
|
+
title: "Labels",
|
|
64
|
+
blurb: "Project label CRUD.",
|
|
65
|
+
},
|
|
66
|
+
ci: {
|
|
67
|
+
title: "CI Lint",
|
|
68
|
+
blurb: "Validate `.gitlab-ci.yml` snippets and project pipeline configs.",
|
|
69
|
+
},
|
|
70
|
+
groups: {
|
|
71
|
+
title: "Groups",
|
|
72
|
+
blurb: "Create new groups and subgroups.",
|
|
73
|
+
},
|
|
74
|
+
pipelines: {
|
|
75
|
+
title: "Pipelines, Jobs & Deployments",
|
|
76
|
+
blurb: "Pipeline + job control (trigger, retry, cancel, play manual jobs, fetch logs/artifacts), and the deployments/environments view.",
|
|
77
|
+
},
|
|
78
|
+
milestones: {
|
|
79
|
+
title: "Milestones",
|
|
80
|
+
blurb: "Project milestone CRUD plus associated issues/MRs and burndown events.",
|
|
81
|
+
},
|
|
82
|
+
wiki: {
|
|
83
|
+
title: "Wiki",
|
|
84
|
+
blurb: "Project and group wiki page CRUD. Attachment uploads where supported.",
|
|
85
|
+
},
|
|
86
|
+
releases: {
|
|
87
|
+
title: "Releases",
|
|
88
|
+
blurb: "Release lifecycle, release evidence, and asset download.",
|
|
89
|
+
},
|
|
90
|
+
tags: {
|
|
91
|
+
title: "Tags",
|
|
92
|
+
blurb: "Tag listing, creation, deletion, and signature inspection.",
|
|
93
|
+
},
|
|
94
|
+
users: {
|
|
95
|
+
title: "Users & Events",
|
|
96
|
+
blurb: "User lookup, the authenticated user (`whoami`), event streams, and markdown attachment upload/download.",
|
|
97
|
+
},
|
|
98
|
+
workitems: {
|
|
99
|
+
title: "Work Items",
|
|
100
|
+
blurb: "Modern unified API for issues, tasks, incidents, and other typed work items — including notes, emoji reactions, and incident timeline events.",
|
|
101
|
+
},
|
|
102
|
+
webhooks: {
|
|
103
|
+
title: "Webhooks",
|
|
104
|
+
blurb: "List webhooks configured on projects or groups, and inspect recent webhook events.",
|
|
105
|
+
},
|
|
106
|
+
search: {
|
|
107
|
+
title: "Search",
|
|
108
|
+
blurb: "Code search across all visible projects, a specific project, or a specific group.",
|
|
109
|
+
},
|
|
110
|
+
variables: {
|
|
111
|
+
title: "Variables",
|
|
112
|
+
blurb: "Project and group CI/CD variable CRUD.",
|
|
113
|
+
},
|
|
114
|
+
dependency_proxy: {
|
|
115
|
+
title: "Dependency Proxy",
|
|
116
|
+
blurb: "Inspect and manage the GitLab dependency proxy cache settings, blob storage, and purge operations.",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const GROUP_ORDER = [
|
|
120
|
+
"projects",
|
|
121
|
+
"repositories",
|
|
122
|
+
"branches",
|
|
123
|
+
"groups",
|
|
124
|
+
"merge_requests",
|
|
125
|
+
"issues",
|
|
126
|
+
"labels",
|
|
127
|
+
"workitems",
|
|
128
|
+
"ci",
|
|
129
|
+
"pipelines",
|
|
130
|
+
"milestones",
|
|
131
|
+
"wiki",
|
|
132
|
+
"releases",
|
|
133
|
+
"tags",
|
|
134
|
+
"users",
|
|
135
|
+
"variables",
|
|
136
|
+
"webhooks",
|
|
137
|
+
"search",
|
|
138
|
+
"dependency_proxy",
|
|
139
|
+
];
|
|
140
|
+
// Authoritative classification — uses the `readOnlyTools` set from
|
|
141
|
+
// tools/registry.ts. That set is what the server itself consults to decide
|
|
142
|
+
// which tools survive `GITLAB_READ_ONLY_MODE=true`, so the badges here
|
|
143
|
+
// match real runtime behavior exactly (no prefix heuristics).
|
|
144
|
+
function classify(name) {
|
|
145
|
+
return readOnlyTools.has(name) ? "read" : "write";
|
|
146
|
+
}
|
|
147
|
+
function rwBadge(name) {
|
|
148
|
+
return classify(name) === "read" ? "📖 Read-only" : "✏️ Writes";
|
|
149
|
+
}
|
|
150
|
+
function describeType(prop) {
|
|
151
|
+
if (prop.enum) {
|
|
152
|
+
return `enum (\`${prop.enum.map(String).join("` \\| `")}\`)`;
|
|
153
|
+
}
|
|
154
|
+
if (prop.anyOf || prop.oneOf) {
|
|
155
|
+
const variants = (prop.anyOf || prop.oneOf || []).map(describeType).filter(Boolean);
|
|
156
|
+
return variants.join(" \\| ") || "any";
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(prop.type)) {
|
|
159
|
+
return prop.type.join(" \\| ");
|
|
160
|
+
}
|
|
161
|
+
if (prop.type === "array") {
|
|
162
|
+
return `array<${prop.items ? describeType(prop.items) : "any"}>`;
|
|
163
|
+
}
|
|
164
|
+
if (prop.format && prop.type) {
|
|
165
|
+
return `${prop.type} (${prop.format})`;
|
|
166
|
+
}
|
|
167
|
+
return prop.type || "any";
|
|
168
|
+
}
|
|
169
|
+
function escapePipe(text) {
|
|
170
|
+
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
171
|
+
}
|
|
172
|
+
function paramTable(schema) {
|
|
173
|
+
if (!schema || !schema.properties || Object.keys(schema.properties).length === 0) {
|
|
174
|
+
return "_No parameters._";
|
|
175
|
+
}
|
|
176
|
+
const required = new Set(schema.required || []);
|
|
177
|
+
const rows = ["| Parameter | Type | Required | Description |", "|---|---|:-:|---|"];
|
|
178
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
179
|
+
const type = describeType(prop);
|
|
180
|
+
const req = required.has(name) ? "✓" : "";
|
|
181
|
+
const desc = escapePipe(prop.description || "");
|
|
182
|
+
rows.push(`| \`${name}\` | ${type} | ${req} | ${desc} |`);
|
|
183
|
+
}
|
|
184
|
+
return rows.join("\n");
|
|
185
|
+
}
|
|
186
|
+
function toolSection(name, description, schema) {
|
|
187
|
+
return [
|
|
188
|
+
`### \`${name}\``,
|
|
189
|
+
"",
|
|
190
|
+
`*${rwBadge(name)}*`,
|
|
191
|
+
"",
|
|
192
|
+
description,
|
|
193
|
+
"",
|
|
194
|
+
"**Parameters**",
|
|
195
|
+
"",
|
|
196
|
+
paramTable(schema),
|
|
197
|
+
"",
|
|
198
|
+
].join("\n");
|
|
199
|
+
}
|
|
200
|
+
function buildGroupPage(id, toolNames) {
|
|
201
|
+
const meta = GROUP_META[id];
|
|
202
|
+
const lines = [`# ${meta.title}`, "", meta.blurb, ""];
|
|
203
|
+
const toggle = computeToggleNote(id);
|
|
204
|
+
if (toggle) {
|
|
205
|
+
lines.push(`!!! note "Feature toggle"`);
|
|
206
|
+
lines.push(` ${toggle}`);
|
|
207
|
+
lines.push("");
|
|
208
|
+
}
|
|
209
|
+
// Quick index
|
|
210
|
+
lines.push("## Tools in this group");
|
|
211
|
+
lines.push("");
|
|
212
|
+
for (const name of toolNames) {
|
|
213
|
+
lines.push(`- [\`${name}\`](#${name.replace(/_/g, "_")}) — ${rwBadge(name)}`);
|
|
214
|
+
}
|
|
215
|
+
lines.push("");
|
|
216
|
+
lines.push("---");
|
|
217
|
+
lines.push("");
|
|
218
|
+
for (const name of toolNames) {
|
|
219
|
+
const tool = allTools.find(t => t.name === name);
|
|
220
|
+
if (!tool) {
|
|
221
|
+
throw new Error(`Tool '${name}' referenced in toolset '${id}' but missing from allTools registry`);
|
|
222
|
+
}
|
|
223
|
+
lines.push(toolSection(name, tool.description, tool.inputSchema));
|
|
224
|
+
}
|
|
225
|
+
return lines.join("\n");
|
|
226
|
+
}
|
|
227
|
+
function buildToggleSection(groupedToolsList) {
|
|
228
|
+
const grouped = groupedToolsList.filter(([id]) => GROUP_META[id]);
|
|
229
|
+
const defaults = grouped.filter(([id]) => isDefaultToolset(id));
|
|
230
|
+
const optins = grouped.filter(([id]) => !isDefaultToolset(id));
|
|
231
|
+
const formatList = (items) => items
|
|
232
|
+
.map(([id]) => {
|
|
233
|
+
const slug = id.replace(/_/g, "-");
|
|
234
|
+
const legacy = LEGACY_TOGGLE_ENV[id];
|
|
235
|
+
const suffix = legacy ? ` (also \`${legacy}=true\`)` : "";
|
|
236
|
+
return `[${GROUP_META[id].title}](${slug}.md)${suffix}`;
|
|
237
|
+
})
|
|
238
|
+
.join(", ");
|
|
239
|
+
return [
|
|
240
|
+
"| Status | Groups |",
|
|
241
|
+
"|---|---|",
|
|
242
|
+
`| **Default** — always exposed | ${formatList(defaults)} |`,
|
|
243
|
+
`| **Opt-in** — must be enabled | ${formatList(optins)} |`,
|
|
244
|
+
"",
|
|
245
|
+
"**How to enable opt-in groups** (any one is sufficient):",
|
|
246
|
+
"",
|
|
247
|
+
"- `GITLAB_TOOLSETS=<group,…>` — comma-separated toolset IDs.",
|
|
248
|
+
"- `GITLAB_TOOLSETS=all` — enables every group.",
|
|
249
|
+
"- `GITLAB_TOOLS=<tool,…>` — enables individual tools regardless of group.",
|
|
250
|
+
"- `USE_PIPELINE=true` / `USE_MILESTONE=true` / `USE_GITLAB_WIKI=true` —" +
|
|
251
|
+
" legacy single-group flags (Pipelines, Milestones, Wiki only).",
|
|
252
|
+
"- Call the `discover_tools` MCP tool at runtime to activate categories" +
|
|
253
|
+
" for the current session.",
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
function buildIndexPage(groupedToolsList) {
|
|
257
|
+
const lines = [
|
|
258
|
+
"# Tools Reference",
|
|
259
|
+
"",
|
|
260
|
+
"Complete catalog of every tool the GitLab MCP server exposes.",
|
|
261
|
+
"",
|
|
262
|
+
"> **Setup first** — if you haven't connected your Personal Access Token or",
|
|
263
|
+
"> OAuth credentials yet, follow one of the [client setup guides](../clients/claude-code.md)",
|
|
264
|
+
"> or read [Getting Started](../getting-started/index.md). Tools listed below",
|
|
265
|
+
"> will be unavailable until the server is authenticated.",
|
|
266
|
+
"",
|
|
267
|
+
"## Feature toggles",
|
|
268
|
+
"",
|
|
269
|
+
"Toolsets are split into a **default** set (exposed automatically) and an",
|
|
270
|
+
"**opt-in** set (must be explicitly enabled). The lists below are derived",
|
|
271
|
+
"directly from `TOOLSET_DEFINITIONS` in",
|
|
272
|
+
"[`tools/registry.ts`](https://github.com/zereight/gitlab-mcp/blob/main/tools/registry.ts).",
|
|
273
|
+
"",
|
|
274
|
+
...buildToggleSection(groupedToolsList),
|
|
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)",
|
|
278
|
+
"and [CLI Arguments](../getting-started/cli-arguments.md) for the full list.",
|
|
279
|
+
"",
|
|
280
|
+
"## Legend",
|
|
281
|
+
"",
|
|
282
|
+
"| Marker | Meaning |",
|
|
283
|
+
"|---|---|",
|
|
284
|
+
"| 📖 | **Read-only** — fetches data, does not modify GitLab state. Safe to invoke freely. |",
|
|
285
|
+
"| ✏️ | **Writes** — creates, updates, or deletes data on GitLab. Confirm intent before running. |",
|
|
286
|
+
"",
|
|
287
|
+
"## Browse by group",
|
|
288
|
+
"",
|
|
289
|
+
"Each group has its own page with full parameter tables — click any tool name to jump to its details, or click the group title for the per-group view.",
|
|
290
|
+
"",
|
|
291
|
+
];
|
|
292
|
+
for (const [id, tools] of groupedToolsList) {
|
|
293
|
+
const meta = GROUP_META[id];
|
|
294
|
+
const slug = id.replace(/_/g, "-");
|
|
295
|
+
lines.push(`### [${meta.title}](${slug}.md)`);
|
|
296
|
+
lines.push("");
|
|
297
|
+
lines.push(`${meta.blurb} *(${tools.length} tools)*`);
|
|
298
|
+
lines.push("");
|
|
299
|
+
const toggle = computeToggleNote(id);
|
|
300
|
+
if (toggle) {
|
|
301
|
+
lines.push(`> ${toggle}`);
|
|
302
|
+
lines.push("");
|
|
303
|
+
}
|
|
304
|
+
lines.push("| Tool | What it does | R/W |");
|
|
305
|
+
lines.push("|---|---|:-:|");
|
|
306
|
+
for (const name of tools) {
|
|
307
|
+
const tool = allTools.find(t => t.name === name);
|
|
308
|
+
if (!tool)
|
|
309
|
+
continue;
|
|
310
|
+
const desc = escapePipe(tool.description);
|
|
311
|
+
const marker = classify(name) === "read" ? "📖" : "✏️";
|
|
312
|
+
lines.push(`| [\`${name}\`](${slug}.md#${name}) | ${desc} | ${marker} |`);
|
|
313
|
+
}
|
|
314
|
+
lines.push("");
|
|
315
|
+
}
|
|
316
|
+
lines.push("---");
|
|
317
|
+
lines.push("");
|
|
318
|
+
lines.push("## Argument schemas");
|
|
319
|
+
lines.push("");
|
|
320
|
+
lines.push("Each group page includes a parameter table per tool, generated from");
|
|
321
|
+
lines.push("the authoritative Zod schemas in");
|
|
322
|
+
lines.push("[`schemas.ts`](https://github.com/zereight/gitlab-mcp/blob/main/schemas.ts).");
|
|
323
|
+
lines.push("For runtime schema inspection from a connected MCP client, call the");
|
|
324
|
+
lines.push("`discover_tools` tool.");
|
|
325
|
+
return lines.join("\n");
|
|
326
|
+
}
|
|
327
|
+
// --- Main -----------------------------------------------------------------
|
|
328
|
+
function main() {
|
|
329
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
330
|
+
// Clean previously generated per-group files (keep index.md until last)
|
|
331
|
+
for (const file of readdirSync(OUT_DIR)) {
|
|
332
|
+
const full = join(OUT_DIR, file);
|
|
333
|
+
if (statSync(full).isFile() && file.endsWith(".md")) {
|
|
334
|
+
unlinkSync(full);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const grouped = [];
|
|
338
|
+
const allCategorizedNames = new Set();
|
|
339
|
+
for (const id of GROUP_ORDER) {
|
|
340
|
+
const def = TOOLSET_DEFINITIONS.find(d => d.id === id);
|
|
341
|
+
if (!def) {
|
|
342
|
+
throw new Error(`Toolset '${id}' listed in GROUP_ORDER but missing from TOOLSET_DEFINITIONS — keep them in sync`);
|
|
343
|
+
}
|
|
344
|
+
// Preserve insertion order from TOOLSET_DEFINITIONS
|
|
345
|
+
const tools = [...def.tools];
|
|
346
|
+
for (const t of tools)
|
|
347
|
+
allCategorizedNames.add(t);
|
|
348
|
+
grouped.push([id, tools]);
|
|
349
|
+
const page = buildGroupPage(id, tools);
|
|
350
|
+
const slug = id.replace(/_/g, "-");
|
|
351
|
+
writeFileSync(join(OUT_DIR, `${slug}.md`), page);
|
|
352
|
+
console.log(`generated docs/tools/${slug}.md — ${tools.length} tools`);
|
|
353
|
+
}
|
|
354
|
+
// Tools NOT in any toolset (e.g., execute_graphql, discover_tools)
|
|
355
|
+
const uncategorized = allTools.map(t => t.name).filter(n => !allCategorizedNames.has(n));
|
|
356
|
+
if (uncategorized.length > 0) {
|
|
357
|
+
const metaToggle = computeToggleNote("meta");
|
|
358
|
+
const lines = [
|
|
359
|
+
`# Meta & GraphQL`,
|
|
360
|
+
"",
|
|
361
|
+
"Tools the MCP exposes that aren't tied to a specific GitLab feature group — server diagnostics and the GraphQL escape hatch.",
|
|
362
|
+
"",
|
|
363
|
+
];
|
|
364
|
+
if (metaToggle) {
|
|
365
|
+
lines.push(`!!! note "Feature toggle"`);
|
|
366
|
+
lines.push(` ${metaToggle}`);
|
|
367
|
+
lines.push("");
|
|
368
|
+
}
|
|
369
|
+
lines.push("## Tools in this group");
|
|
370
|
+
lines.push("");
|
|
371
|
+
for (const name of uncategorized) {
|
|
372
|
+
lines.push(`- [\`${name}\`](#${name}) — ${rwBadge(name)}`);
|
|
373
|
+
}
|
|
374
|
+
lines.push("");
|
|
375
|
+
lines.push("---");
|
|
376
|
+
lines.push("");
|
|
377
|
+
for (const name of uncategorized) {
|
|
378
|
+
const tool = allTools.find(t => t.name === name);
|
|
379
|
+
if (!tool) {
|
|
380
|
+
throw new Error(`Uncategorized tool '${name}' missing from allTools registry`);
|
|
381
|
+
}
|
|
382
|
+
lines.push(toolSection(name, tool.description, tool.inputSchema));
|
|
383
|
+
}
|
|
384
|
+
writeFileSync(join(OUT_DIR, "meta.md"), lines.join("\n"));
|
|
385
|
+
console.log(`generated docs/tools/meta.md — ${uncategorized.length} tools`);
|
|
386
|
+
grouped.push(["meta", uncategorized]);
|
|
387
|
+
GROUP_META["meta"] = {
|
|
388
|
+
title: "Meta & GraphQL",
|
|
389
|
+
blurb: "Server diagnostics, tool discovery, and the GraphQL escape hatch.",
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
writeFileSync(join(OUT_DIR, "index.md"), buildIndexPage(grouped));
|
|
393
|
+
console.log(`generated docs/tools/index.md`);
|
|
394
|
+
// Emit nav fragment so you can paste it into mkdocs.yml if needed
|
|
395
|
+
console.log("\n--- mkdocs.yml nav fragment ---");
|
|
396
|
+
console.log(" - Tools:");
|
|
397
|
+
console.log(" - Overview: tools/index.md");
|
|
398
|
+
for (const [id] of grouped) {
|
|
399
|
+
const meta = GROUP_META[id];
|
|
400
|
+
const slug = id.replace(/_/g, "-");
|
|
401
|
+
console.log(` - ${meta.title}: tools/${slug}.md`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
main();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GITLAB_OAUTH_ALLOWED_GROUPS config resolution.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that:
|
|
5
|
+
* - The new GITLAB_OAUTH_ALLOWED_GROUPS env var is preferred when set.
|
|
6
|
+
* - The deprecated GITLAB_ALLOWED_GROUPS is accepted as a fallback and
|
|
7
|
+
* sets the deprecation flag so callers can emit a warning.
|
|
8
|
+
* - When both are set, the new var wins and GITLAB_ALLOWED_GROUPS_RAW remains
|
|
9
|
+
* set, so callers can emit a "set but ignored" warning.
|
|
10
|
+
*
|
|
11
|
+
* config.ts reads process.env at module load, so each scenario runs in a
|
|
12
|
+
* fresh child process — same pattern as test/stateless/config-ttl.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import * as url from "node:url";
|
|
18
|
+
import { describe, test } from "node:test";
|
|
19
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
20
|
+
const CONFIG_PATH = path.resolve(__dirname, "../config.ts");
|
|
21
|
+
function loadConfig(env) {
|
|
22
|
+
const script = `
|
|
23
|
+
import(${JSON.stringify(url.pathToFileURL(CONFIG_PATH).href)}).then((m) => {
|
|
24
|
+
const out = {
|
|
25
|
+
GITLAB_OAUTH_ALLOWED_GROUPS: m.GITLAB_OAUTH_ALLOWED_GROUPS ?? null,
|
|
26
|
+
GITLAB_OAUTH_ALLOWED_GROUPS_RAW: m.GITLAB_OAUTH_ALLOWED_GROUPS_RAW ?? null,
|
|
27
|
+
GITLAB_ALLOWED_GROUPS_RAW: m.GITLAB_ALLOWED_GROUPS_RAW ?? null,
|
|
28
|
+
};
|
|
29
|
+
process.stdout.write(JSON.stringify(out));
|
|
30
|
+
}).catch((err) => {
|
|
31
|
+
process.stderr.write(String(err && err.stack || err));
|
|
32
|
+
process.exit(2);
|
|
33
|
+
});
|
|
34
|
+
`;
|
|
35
|
+
const childEnv = {
|
|
36
|
+
...process.env,
|
|
37
|
+
};
|
|
38
|
+
delete childEnv.GITLAB_OAUTH_ALLOWED_GROUPS;
|
|
39
|
+
delete childEnv.GITLAB_ALLOWED_GROUPS;
|
|
40
|
+
for (const [k, v] of Object.entries(env)) {
|
|
41
|
+
if (v === undefined) {
|
|
42
|
+
delete childEnv[k];
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
childEnv[k] = v;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", "--input-type=module", "--eval", script], { env: childEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
49
|
+
return JSON.parse(stdout);
|
|
50
|
+
}
|
|
51
|
+
describe("config.ts — GITLAB_OAUTH_ALLOWED_GROUPS resolution", () => {
|
|
52
|
+
test("neither var set — resolved value and raws are null", () => {
|
|
53
|
+
const cfg = loadConfig({});
|
|
54
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
|
|
55
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, null);
|
|
56
|
+
assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, null);
|
|
57
|
+
});
|
|
58
|
+
test("only new var set — resolved correctly, deprecated raw is null", () => {
|
|
59
|
+
const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: "my-org" });
|
|
60
|
+
assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["my-org"]);
|
|
61
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, "my-org");
|
|
62
|
+
assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, null);
|
|
63
|
+
});
|
|
64
|
+
test("only deprecated var set — resolved via fallback, deprecated raw is set", () => {
|
|
65
|
+
const cfg = loadConfig({ GITLAB_ALLOWED_GROUPS: "my-org" });
|
|
66
|
+
assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["my-org"]);
|
|
67
|
+
assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, "my-org");
|
|
68
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, null);
|
|
69
|
+
});
|
|
70
|
+
test("both vars set — new var wins, both raws are set (triggers 'set but ignored' warning)", () => {
|
|
71
|
+
const cfg = loadConfig({
|
|
72
|
+
GITLAB_OAUTH_ALLOWED_GROUPS: "new-org",
|
|
73
|
+
GITLAB_ALLOWED_GROUPS: "old-org",
|
|
74
|
+
});
|
|
75
|
+
assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["new-org"]);
|
|
76
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, "new-org");
|
|
77
|
+
assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, "old-org");
|
|
78
|
+
});
|
|
79
|
+
test("comma-separated values are split and trimmed", () => {
|
|
80
|
+
const cfg = loadConfig({
|
|
81
|
+
GITLAB_OAUTH_ALLOWED_GROUPS: "my-org/team-a , my-org/team-b , my-org",
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, [
|
|
84
|
+
"my-org/team-a",
|
|
85
|
+
"my-org/team-b",
|
|
86
|
+
"my-org",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
test("empty string resolves to null", () => {
|
|
90
|
+
const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: "" });
|
|
91
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
|
|
92
|
+
});
|
|
93
|
+
test("whitespace-only entries are filtered out", () => {
|
|
94
|
+
const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: " , , " });
|
|
95
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for OAuth endpoint rate limiting behind trusted proxies.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, test, after, before } from "node:test";
|
|
5
|
+
import assert from "node:assert";
|
|
6
|
+
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
7
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
8
|
+
const MOCK_GITLAB_PORT_BASE = 9210;
|
|
9
|
+
const MCP_SERVER_PORT_BASE = 3210;
|
|
10
|
+
const MOCK_CLIENT_ID = "mock-app-uid-from-dcr";
|
|
11
|
+
function addOAuthEndpoints(mockGitLab, baseUrl) {
|
|
12
|
+
mockGitLab.addRootHandler("post", "/oauth/register", (req, res) => {
|
|
13
|
+
res.status(201).json({
|
|
14
|
+
client_id: MOCK_CLIENT_ID,
|
|
15
|
+
client_name: req.body?.client_name ?? "test",
|
|
16
|
+
redirect_uris: req.body?.redirect_uris ?? [],
|
|
17
|
+
token_endpoint_auth_method: "none",
|
|
18
|
+
require_pkce: true,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
mockGitLab.addRootHandler("get", "/.well-known/oauth-authorization-server", (_req, res) => {
|
|
22
|
+
res.json({
|
|
23
|
+
issuer: baseUrl,
|
|
24
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
25
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
26
|
+
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
27
|
+
revocation_endpoint: `${baseUrl}/oauth/revoke`,
|
|
28
|
+
scopes_supported: ["api", "read_api", "read_user"],
|
|
29
|
+
response_types_supported: ["code"],
|
|
30
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
31
|
+
code_challenge_methods_supported: ["S256"],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
describe("When MCP OAuth runs behind a trusted proxy", () => {
|
|
36
|
+
describe("with X-Forwarded-For containing client ports", () => {
|
|
37
|
+
let mcpBaseUrl;
|
|
38
|
+
let mockGitLab;
|
|
39
|
+
let servers = [];
|
|
40
|
+
before(async () => {
|
|
41
|
+
const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
|
|
42
|
+
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [] });
|
|
43
|
+
await mockGitLab.start();
|
|
44
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
45
|
+
addOAuthEndpoints(mockGitLab, mockGitLabUrl);
|
|
46
|
+
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
|
|
47
|
+
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
48
|
+
const server = await launchServer({
|
|
49
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
50
|
+
port: mcpPort,
|
|
51
|
+
timeout: 5000,
|
|
52
|
+
env: {
|
|
53
|
+
STREAMABLE_HTTP: "true",
|
|
54
|
+
GITLAB_MCP_OAUTH: "true",
|
|
55
|
+
MCP_TRUST_PROXY: "true",
|
|
56
|
+
GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
|
|
57
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
58
|
+
MCP_SERVER_URL: mcpBaseUrl,
|
|
59
|
+
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
servers.push(server);
|
|
63
|
+
});
|
|
64
|
+
after(async () => {
|
|
65
|
+
cleanupServers(servers);
|
|
66
|
+
if (mockGitLab) {
|
|
67
|
+
await mockGitLab.stop();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
test("should not return 500 from /register", async () => {
|
|
71
|
+
const res = await fetch(`${mcpBaseUrl}/register`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"X-Forwarded-For": "160.79.106.36:38914",
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
redirect_uris: ["https://client.example/callback"],
|
|
79
|
+
client_name: "proxy-rate-limit-test",
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
assert.notEqual(res.status, 500, "rate-limit key generation should not crash /register");
|
|
83
|
+
assert.strictEqual(res.status, 201, "DCR should succeed behind forwarded IPv4:port");
|
|
84
|
+
});
|
|
85
|
+
test("should not return 500 from /authorize", async () => {
|
|
86
|
+
const params = new URLSearchParams({
|
|
87
|
+
response_type: "code",
|
|
88
|
+
client_id: MOCK_CLIENT_ID,
|
|
89
|
+
redirect_uri: "https://client.example/callback",
|
|
90
|
+
code_challenge: "challenge",
|
|
91
|
+
code_challenge_method: "S256",
|
|
92
|
+
state: "proxy-rate-limit-state",
|
|
93
|
+
scope: "api",
|
|
94
|
+
});
|
|
95
|
+
const res = await fetch(`${mcpBaseUrl}/authorize?${params}`, {
|
|
96
|
+
redirect: "manual",
|
|
97
|
+
headers: {
|
|
98
|
+
"X-Forwarded-For": "[2001:db8::1]:5678",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
assert.notEqual(res.status, 500, "rate-limit key generation should not crash /authorize");
|
|
102
|
+
});
|
|
103
|
+
test("should not return 500 from /token", async () => {
|
|
104
|
+
const res = await fetch(`${mcpBaseUrl}/token`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
108
|
+
"X-Forwarded-For": "160.79.106.36:38914",
|
|
109
|
+
},
|
|
110
|
+
body: new URLSearchParams({
|
|
111
|
+
grant_type: "authorization_code",
|
|
112
|
+
client_id: MOCK_CLIENT_ID,
|
|
113
|
+
code: "invalid-code",
|
|
114
|
+
redirect_uri: "https://client.example/callback",
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
assert.notEqual(res.status, 500, "rate-limit key generation should not crash /token");
|
|
118
|
+
});
|
|
119
|
+
test("should not return 500 from /revoke", async () => {
|
|
120
|
+
const res = await fetch(`${mcpBaseUrl}/revoke`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: {
|
|
123
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
124
|
+
"X-Forwarded-For": "[2001:db8::1]",
|
|
125
|
+
},
|
|
126
|
+
body: new URLSearchParams({
|
|
127
|
+
token: "invalid-token",
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
assert.notEqual(res.status, 500, "rate-limit key generation should not crash /revoke");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|