@zereight/mcp-gitlab 2.0.28 → 2.0.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.
- package/README.md +40 -0
- package/build/index.js +363 -44
- package/build/schemas.js +260 -89
- package/build/test/test-download-attachment.js +144 -0
- package/build/test/test-toolset-filtering.js +451 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -356,6 +356,46 @@ docker run -i --rm \
|
|
|
356
356
|
- `USE_GITLAB_WIKI`: When set to 'true', enables the wiki-related tools (list_wiki_pages, get_wiki_page, create_wiki_page, update_wiki_page, delete_wiki_page). By default, wiki features are disabled.
|
|
357
357
|
- `USE_MILESTONE`: When set to 'true', enables the milestone-related tools (list_milestones, get_milestone, create_milestone, edit_milestone, delete_milestone, get_milestone_issue, get_milestone_merge_requests, promote_milestone, get_milestone_burndown_events). By default, milestone features are disabled.
|
|
358
358
|
- `USE_PIPELINE`: When set to 'true', enables the pipeline-related tools (list_pipelines, get_pipeline, list_pipeline_jobs, list_pipeline_trigger_jobs, get_pipeline_job, get_pipeline_job_output, create_pipeline, retry_pipeline, cancel_pipeline, play_pipeline_job, retry_pipeline_job, cancel_pipeline_job). By default, pipeline features are disabled.
|
|
359
|
+
- `GITLAB_TOOLSETS`: Comma-separated list of toolset IDs to enable. When empty or unset, default toolsets are used. Set to `"all"` to enable every toolset. Available toolsets (default toolsets marked with `*`):
|
|
360
|
+
- `merge_requests`\* — MR operations, notes, discussions, draft notes, threads (31 tools)
|
|
361
|
+
- `issues`\* — Issue CRUD, notes, links, discussions (14 tools)
|
|
362
|
+
- `repositories`\* — Search, create, file contents, push, fork, tree (7 tools)
|
|
363
|
+
- `branches`\* — Branch creation, commits, diffs (4 tools)
|
|
364
|
+
- `projects`\* — Project/namespace info, group projects, iterations (8 tools)
|
|
365
|
+
- `labels`\* — Label CRUD (5 tools)
|
|
366
|
+
- `pipelines` — Pipeline and job operations (12 tools)
|
|
367
|
+
- `milestones` — Milestone CRUD, issues, MRs, burndown (9 tools)
|
|
368
|
+
- `wiki` — Wiki page CRUD (5 tools)
|
|
369
|
+
- `releases`\* — Release CRUD, evidence, asset download (7 tools)
|
|
370
|
+
- `users`\* — User info, events, markdown upload, attachments (5 tools)
|
|
371
|
+
|
|
372
|
+
Note: `execute_graphql` is not in any toolset and must be added individually via `GITLAB_TOOLS` if needed.
|
|
373
|
+
Exposing arbitrary GraphQL would allow bypassing toolset boundaries (e.g. querying data that the user intentionally disabled via toolsets like wiki or pipelines), which is a security and permission-containment concern. Keeping `execute_graphql` out of all toolsets and requiring explicit opt-in via `GITLAB_TOOLS=execute_graphql` is intentional, to align with that principle rather than for backward compatibility.
|
|
374
|
+
CLI arg: `--toolsets`
|
|
375
|
+
- `GITLAB_TOOLS`: Comma-separated list of individual tool names to add on top of the enabled toolsets (additive). Useful for cherry-picking specific tools without enabling an entire toolset. Example: `GITLAB_TOOLS="list_pipelines,execute_graphql"`. CLI arg: `--tools`
|
|
376
|
+
|
|
377
|
+
Combined logic: `final tools = (tools from enabled toolsets) ∪ (GITLAB_TOOLS) ∪ (legacy flag overrides)`
|
|
378
|
+
|
|
379
|
+
Examples:
|
|
380
|
+
```bash
|
|
381
|
+
# Default behavior (unchanged)
|
|
382
|
+
GITLAB_PERSONAL_ACCESS_TOKEN=xxx npx @zereight/mcp-gitlab
|
|
383
|
+
|
|
384
|
+
# Only issues and repositories
|
|
385
|
+
GITLAB_TOOLSETS="issues,repositories" npx @zereight/mcp-gitlab
|
|
386
|
+
|
|
387
|
+
# All toolsets
|
|
388
|
+
GITLAB_TOOLSETS="all" npx @zereight/mcp-gitlab
|
|
389
|
+
|
|
390
|
+
# Default toolsets + one extra pipeline tool
|
|
391
|
+
GITLAB_TOOLS="list_pipelines" npx @zereight/mcp-gitlab
|
|
392
|
+
|
|
393
|
+
# Specific toolsets + individual tools
|
|
394
|
+
GITLAB_TOOLSETS="issues,merge_requests" GITLAB_TOOLS="list_pipelines,get_pipeline" npx @zereight/mcp-gitlab
|
|
395
|
+
|
|
396
|
+
# Legacy flags still work (backward compatible)
|
|
397
|
+
USE_PIPELINE=true npx @zereight/mcp-gitlab
|
|
398
|
+
```
|
|
359
399
|
- `GITLAB_AUTH_COOKIE_PATH`: Path to an authentication cookie file for GitLab instances that require cookie-based authentication. When provided, the cookie will be included in all GitLab API requests.
|
|
360
400
|
- `SSE`: When set to 'true', enables the Server-Sent Events transport.
|
|
361
401
|
- `STREAMABLE_HTTP`: When set to 'true', enables the Streamable HTTP transport. If both **SSE** and **STREAMABLE_HTTP** are set to 'true', the server will prioritize Streamable HTTP over SSE transport.
|
package/build/index.js
CHANGED
|
@@ -95,6 +95,29 @@ catch {
|
|
|
95
95
|
* cross-client data leakage (GHSA-345p-7cg4-v4c7).
|
|
96
96
|
*/
|
|
97
97
|
function createServer() {
|
|
98
|
+
// Precompute filtered tool list once at server creation (Steps 1–5 are static)
|
|
99
|
+
// Step 1: Toolset filter — keep tools in enabled toolsets
|
|
100
|
+
const toolsAfterToolsets = allTools.filter(tool => isToolInEnabledToolset(tool.name, enabledToolsets));
|
|
101
|
+
// Step 2: Add GITLAB_TOOLS (individual tools bypass toolset filter)
|
|
102
|
+
const toolsetToolNames = new Set(toolsAfterToolsets.map(t => t.name));
|
|
103
|
+
const toolsAfterIndividual = [
|
|
104
|
+
...toolsAfterToolsets,
|
|
105
|
+
...allTools.filter(tool => individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name)),
|
|
106
|
+
];
|
|
107
|
+
// Step 3: Add legacy flag overrides (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI)
|
|
108
|
+
const afterIndividualNames = new Set(toolsAfterIndividual.map(t => t.name));
|
|
109
|
+
const toolsAfterLegacy = [
|
|
110
|
+
...toolsAfterIndividual,
|
|
111
|
+
...allTools.filter(tool => featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name)),
|
|
112
|
+
];
|
|
113
|
+
// Step 4: Read-only filter
|
|
114
|
+
const toolsAfterReadOnly = GITLAB_READ_ONLY_MODE
|
|
115
|
+
? toolsAfterLegacy.filter(tool => readOnlyTools.has(tool.name))
|
|
116
|
+
: toolsAfterLegacy;
|
|
117
|
+
// Step 5: Regex denial filter
|
|
118
|
+
const precomputedFilteredTools = GITLAB_DENIED_TOOLS_REGEX
|
|
119
|
+
? toolsAfterReadOnly.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
|
|
120
|
+
: toolsAfterReadOnly;
|
|
98
121
|
const serverInstance = new Server({
|
|
99
122
|
name: "better-gitlab-mcp-server",
|
|
100
123
|
version: SERVER_VERSION,
|
|
@@ -104,39 +127,25 @@ function createServer() {
|
|
|
104
127
|
},
|
|
105
128
|
});
|
|
106
129
|
serverInstance.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Toggle wiki tools by USE_GITLAB_WIKI flag
|
|
112
|
-
const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
|
|
113
|
-
// Toggle milestone tools by USE_MILESTONE flag
|
|
114
|
-
const tools2 = USE_MILESTONE
|
|
115
|
-
? tools1
|
|
116
|
-
: tools1.filter(tool => !milestoneToolNames.has(tool.name));
|
|
117
|
-
// Toggle pipeline tools by USE_PIPELINE flag
|
|
118
|
-
let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
|
|
119
|
-
tools = GITLAB_DENIED_TOOLS_REGEX
|
|
120
|
-
? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
|
|
121
|
-
: tools;
|
|
122
|
-
// <<< START: Gemini 호환성을 위해 $schema 제거 >>>
|
|
123
|
-
tools = tools.map(tool => {
|
|
124
|
-
// inputSchema가 존재하고 객체인지 확인
|
|
130
|
+
// Step 6: Gemini $schema cleanup (only dynamic step per request)
|
|
131
|
+
// <<< START: Remove $schema for Gemini compatibility >>>
|
|
132
|
+
const tools = precomputedFilteredTools.map(tool => {
|
|
133
|
+
// Check if inputSchema exists and is an object
|
|
125
134
|
if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
|
|
126
|
-
// $schema
|
|
135
|
+
// Remove $schema key if present
|
|
127
136
|
if ("$schema" in tool.inputSchema) {
|
|
128
|
-
//
|
|
137
|
+
// Create a new object to preserve immutability (optional but recommended)
|
|
129
138
|
const modifiedSchema = { ...tool.inputSchema };
|
|
130
139
|
delete modifiedSchema.$schema;
|
|
131
140
|
return { ...tool, inputSchema: modifiedSchema };
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
|
-
//
|
|
143
|
+
// Return as-is if no modification needed
|
|
135
144
|
return tool;
|
|
136
145
|
});
|
|
137
|
-
// <<< END:
|
|
146
|
+
// <<< END: Remove $schema for Gemini compatibility >>>
|
|
138
147
|
return {
|
|
139
|
-
tools, //
|
|
148
|
+
tools, // return tool list with $schema removed
|
|
140
149
|
};
|
|
141
150
|
});
|
|
142
151
|
serverInstance.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -269,6 +278,8 @@ const GITLAB_DENIED_TOOLS_REGEX = (() => {
|
|
|
269
278
|
const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
|
|
270
279
|
const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
|
|
271
280
|
const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
|
|
281
|
+
const GITLAB_TOOLSETS_RAW = getConfig("toolsets", "GITLAB_TOOLSETS");
|
|
282
|
+
const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
|
|
272
283
|
const SSE = getConfig("sse", "SSE") === "true";
|
|
273
284
|
const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
|
|
274
285
|
const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
@@ -315,7 +326,7 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
|
|
|
315
326
|
httpAgent = httpAgent || new Agent();
|
|
316
327
|
// Initialize the client pool for managing multiple GitLab instances
|
|
317
328
|
const clientPool = new GitLabClientPool({
|
|
318
|
-
apiUrls: (
|
|
329
|
+
apiUrls: (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
|
|
319
330
|
.split(",")
|
|
320
331
|
.map(normalizeGitLabApiUrl),
|
|
321
332
|
httpProxy: HTTP_PROXY,
|
|
@@ -1036,7 +1047,7 @@ const allTools = [
|
|
|
1036
1047
|
},
|
|
1037
1048
|
{
|
|
1038
1049
|
name: "download_attachment",
|
|
1039
|
-
description: "Download an uploaded file from a GitLab project by secret and filename",
|
|
1050
|
+
description: "Download an uploaded file from a GitLab project by secret and filename. Image files (png, jpg, gif, webp, svg, bmp, ico) are returned inline as base64 image content so the AI can view them directly. Non-image files are saved to disk. Use local_path to force saving image files to disk instead.",
|
|
1040
1051
|
inputSchema: toJSONSchema(DownloadAttachmentSchema),
|
|
1041
1052
|
},
|
|
1042
1053
|
{
|
|
@@ -1176,6 +1187,259 @@ const pipelineToolNames = new Set([
|
|
|
1176
1187
|
"retry_pipeline_job",
|
|
1177
1188
|
"cancel_pipeline_job",
|
|
1178
1189
|
]);
|
|
1190
|
+
const TOOLSET_DEFINITIONS = [
|
|
1191
|
+
{
|
|
1192
|
+
id: "merge_requests",
|
|
1193
|
+
isDefault: true,
|
|
1194
|
+
tools: new Set([
|
|
1195
|
+
"merge_merge_request",
|
|
1196
|
+
"approve_merge_request",
|
|
1197
|
+
"unapprove_merge_request",
|
|
1198
|
+
"get_merge_request_approval_state",
|
|
1199
|
+
"get_merge_request",
|
|
1200
|
+
"get_merge_request_diffs",
|
|
1201
|
+
"list_merge_request_diffs",
|
|
1202
|
+
"list_merge_request_versions",
|
|
1203
|
+
"get_merge_request_version",
|
|
1204
|
+
"update_merge_request",
|
|
1205
|
+
"create_merge_request",
|
|
1206
|
+
"list_merge_requests",
|
|
1207
|
+
"get_branch_diffs",
|
|
1208
|
+
"mr_discussions",
|
|
1209
|
+
"create_merge_request_note",
|
|
1210
|
+
"update_merge_request_note",
|
|
1211
|
+
"delete_merge_request_note",
|
|
1212
|
+
"get_merge_request_note",
|
|
1213
|
+
"get_merge_request_notes",
|
|
1214
|
+
"delete_merge_request_discussion_note",
|
|
1215
|
+
"update_merge_request_discussion_note",
|
|
1216
|
+
"create_merge_request_discussion_note",
|
|
1217
|
+
"get_draft_note",
|
|
1218
|
+
"list_draft_notes",
|
|
1219
|
+
"create_draft_note",
|
|
1220
|
+
"update_draft_note",
|
|
1221
|
+
"delete_draft_note",
|
|
1222
|
+
"publish_draft_note",
|
|
1223
|
+
"bulk_publish_draft_notes",
|
|
1224
|
+
"create_merge_request_thread",
|
|
1225
|
+
"resolve_merge_request_thread",
|
|
1226
|
+
]),
|
|
1227
|
+
},
|
|
1228
|
+
{
|
|
1229
|
+
id: "issues",
|
|
1230
|
+
isDefault: true,
|
|
1231
|
+
tools: new Set([
|
|
1232
|
+
"create_issue",
|
|
1233
|
+
"list_issues",
|
|
1234
|
+
"my_issues",
|
|
1235
|
+
"get_issue",
|
|
1236
|
+
"update_issue",
|
|
1237
|
+
"delete_issue",
|
|
1238
|
+
"create_issue_note",
|
|
1239
|
+
"update_issue_note",
|
|
1240
|
+
"list_issue_links",
|
|
1241
|
+
"list_issue_discussions",
|
|
1242
|
+
"get_issue_link",
|
|
1243
|
+
"create_issue_link",
|
|
1244
|
+
"delete_issue_link",
|
|
1245
|
+
"create_note",
|
|
1246
|
+
]),
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
id: "repositories",
|
|
1250
|
+
isDefault: true,
|
|
1251
|
+
tools: new Set([
|
|
1252
|
+
"search_repositories",
|
|
1253
|
+
"create_repository",
|
|
1254
|
+
"get_file_contents",
|
|
1255
|
+
"push_files",
|
|
1256
|
+
"create_or_update_file",
|
|
1257
|
+
"fork_repository",
|
|
1258
|
+
"get_repository_tree",
|
|
1259
|
+
]),
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
id: "branches",
|
|
1263
|
+
isDefault: true,
|
|
1264
|
+
tools: new Set([
|
|
1265
|
+
"create_branch",
|
|
1266
|
+
"list_commits",
|
|
1267
|
+
"get_commit",
|
|
1268
|
+
"get_commit_diff",
|
|
1269
|
+
]),
|
|
1270
|
+
},
|
|
1271
|
+
{
|
|
1272
|
+
id: "projects",
|
|
1273
|
+
isDefault: true,
|
|
1274
|
+
tools: new Set([
|
|
1275
|
+
"get_project",
|
|
1276
|
+
"list_projects",
|
|
1277
|
+
"list_project_members",
|
|
1278
|
+
"list_namespaces",
|
|
1279
|
+
"get_namespace",
|
|
1280
|
+
"verify_namespace",
|
|
1281
|
+
"list_group_projects",
|
|
1282
|
+
"list_group_iterations",
|
|
1283
|
+
]),
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
id: "labels",
|
|
1287
|
+
isDefault: true,
|
|
1288
|
+
tools: new Set([
|
|
1289
|
+
"list_labels",
|
|
1290
|
+
"get_label",
|
|
1291
|
+
"create_label",
|
|
1292
|
+
"update_label",
|
|
1293
|
+
"delete_label",
|
|
1294
|
+
]),
|
|
1295
|
+
},
|
|
1296
|
+
{
|
|
1297
|
+
id: "pipelines",
|
|
1298
|
+
isDefault: false,
|
|
1299
|
+
tools: new Set([
|
|
1300
|
+
"list_pipelines",
|
|
1301
|
+
"get_pipeline",
|
|
1302
|
+
"list_pipeline_jobs",
|
|
1303
|
+
"list_pipeline_trigger_jobs",
|
|
1304
|
+
"get_pipeline_job",
|
|
1305
|
+
"get_pipeline_job_output",
|
|
1306
|
+
"create_pipeline",
|
|
1307
|
+
"retry_pipeline",
|
|
1308
|
+
"cancel_pipeline",
|
|
1309
|
+
"play_pipeline_job",
|
|
1310
|
+
"retry_pipeline_job",
|
|
1311
|
+
"cancel_pipeline_job",
|
|
1312
|
+
]),
|
|
1313
|
+
},
|
|
1314
|
+
{
|
|
1315
|
+
id: "milestones",
|
|
1316
|
+
isDefault: false,
|
|
1317
|
+
tools: new Set([
|
|
1318
|
+
"list_milestones",
|
|
1319
|
+
"get_milestone",
|
|
1320
|
+
"create_milestone",
|
|
1321
|
+
"edit_milestone",
|
|
1322
|
+
"delete_milestone",
|
|
1323
|
+
"get_milestone_issue",
|
|
1324
|
+
"get_milestone_merge_requests",
|
|
1325
|
+
"promote_milestone",
|
|
1326
|
+
"get_milestone_burndown_events",
|
|
1327
|
+
]),
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
id: "wiki",
|
|
1331
|
+
isDefault: false,
|
|
1332
|
+
tools: new Set([
|
|
1333
|
+
"list_wiki_pages",
|
|
1334
|
+
"get_wiki_page",
|
|
1335
|
+
"create_wiki_page",
|
|
1336
|
+
"update_wiki_page",
|
|
1337
|
+
"delete_wiki_page",
|
|
1338
|
+
]),
|
|
1339
|
+
},
|
|
1340
|
+
{
|
|
1341
|
+
id: "releases",
|
|
1342
|
+
isDefault: true,
|
|
1343
|
+
tools: new Set([
|
|
1344
|
+
"list_releases",
|
|
1345
|
+
"get_release",
|
|
1346
|
+
"create_release",
|
|
1347
|
+
"update_release",
|
|
1348
|
+
"delete_release",
|
|
1349
|
+
"create_release_evidence",
|
|
1350
|
+
"download_release_asset",
|
|
1351
|
+
]),
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
id: "users",
|
|
1355
|
+
isDefault: true,
|
|
1356
|
+
tools: new Set([
|
|
1357
|
+
"get_users",
|
|
1358
|
+
"list_events",
|
|
1359
|
+
"get_project_events",
|
|
1360
|
+
"upload_markdown",
|
|
1361
|
+
"download_attachment",
|
|
1362
|
+
]),
|
|
1363
|
+
},
|
|
1364
|
+
];
|
|
1365
|
+
// Derived lookup: tool name → toolset ID
|
|
1366
|
+
const TOOLSET_BY_TOOL_NAME = new Map();
|
|
1367
|
+
for (const def of TOOLSET_DEFINITIONS) {
|
|
1368
|
+
for (const tool of def.tools) {
|
|
1369
|
+
if (TOOLSET_BY_TOOL_NAME.has(tool)) {
|
|
1370
|
+
logger.warn(`Tool "${tool}" is defined in multiple toolsets: "${TOOLSET_BY_TOOL_NAME.get(tool)}" and "${def.id}"`);
|
|
1371
|
+
}
|
|
1372
|
+
TOOLSET_BY_TOOL_NAME.set(tool, def.id);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
const DEFAULT_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.filter(d => d.isDefault).map(d => d.id));
|
|
1376
|
+
const ALL_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.map(d => d.id));
|
|
1377
|
+
function parseEnabledToolsets(raw) {
|
|
1378
|
+
if (!raw || raw.trim() === "") {
|
|
1379
|
+
return DEFAULT_TOOLSET_IDS;
|
|
1380
|
+
}
|
|
1381
|
+
const trimmed = raw.trim().toLowerCase();
|
|
1382
|
+
if (trimmed === "all") {
|
|
1383
|
+
return ALL_TOOLSET_IDS;
|
|
1384
|
+
}
|
|
1385
|
+
const selected = new Set(trimmed
|
|
1386
|
+
.split(",")
|
|
1387
|
+
.map(s => s.trim())
|
|
1388
|
+
.filter((s) => ALL_TOOLSET_IDS.has(s)));
|
|
1389
|
+
if (selected.size === 0) {
|
|
1390
|
+
logger.warn(`No valid toolsets found in configuration (${raw}). Falling back to default toolsets.`);
|
|
1391
|
+
return DEFAULT_TOOLSET_IDS;
|
|
1392
|
+
}
|
|
1393
|
+
return selected;
|
|
1394
|
+
}
|
|
1395
|
+
function parseIndividualTools(raw) {
|
|
1396
|
+
if (!raw || raw.trim() === "") {
|
|
1397
|
+
return new Set();
|
|
1398
|
+
}
|
|
1399
|
+
const allToolNames = new Set(allTools.map((t) => t.name));
|
|
1400
|
+
const parsed = raw
|
|
1401
|
+
.trim()
|
|
1402
|
+
.split(",")
|
|
1403
|
+
.map(s => s.trim().toLowerCase())
|
|
1404
|
+
.filter(Boolean);
|
|
1405
|
+
const unknown = parsed.filter(name => !allToolNames.has(name));
|
|
1406
|
+
if (unknown.length > 0) {
|
|
1407
|
+
logger.warn(`Unknown tool names in GITLAB_TOOLS (will be ignored): ${unknown.join(", ")}`);
|
|
1408
|
+
}
|
|
1409
|
+
return new Set(parsed);
|
|
1410
|
+
}
|
|
1411
|
+
function buildFeatureFlagOverrides() {
|
|
1412
|
+
const overrides = new Set();
|
|
1413
|
+
if (USE_GITLAB_WIKI) {
|
|
1414
|
+
for (const t of wikiToolNames)
|
|
1415
|
+
overrides.add(t);
|
|
1416
|
+
}
|
|
1417
|
+
if (USE_MILESTONE) {
|
|
1418
|
+
for (const t of milestoneToolNames)
|
|
1419
|
+
overrides.add(t);
|
|
1420
|
+
}
|
|
1421
|
+
if (USE_PIPELINE) {
|
|
1422
|
+
for (const t of pipelineToolNames)
|
|
1423
|
+
overrides.add(t);
|
|
1424
|
+
}
|
|
1425
|
+
return overrides;
|
|
1426
|
+
}
|
|
1427
|
+
function isToolInEnabledToolset(toolName, enabledToolsets) {
|
|
1428
|
+
const toolsetId = TOOLSET_BY_TOOL_NAME.get(toolName);
|
|
1429
|
+
// Tools not in any toolset (e.g. execute_graphql) are excluded by default
|
|
1430
|
+
if (toolsetId === undefined)
|
|
1431
|
+
return false;
|
|
1432
|
+
return enabledToolsets.has(toolsetId);
|
|
1433
|
+
}
|
|
1434
|
+
// Compute at startup
|
|
1435
|
+
const enabledToolsets = parseEnabledToolsets(GITLAB_TOOLSETS_RAW);
|
|
1436
|
+
const individuallyEnabledTools = parseIndividualTools(GITLAB_TOOLS_RAW);
|
|
1437
|
+
const featureFlagOverrides = buildFeatureFlagOverrides();
|
|
1438
|
+
// Warn about potentially confusing configuration
|
|
1439
|
+
if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
|
|
1440
|
+
logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
|
|
1441
|
+
"Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
|
|
1442
|
+
}
|
|
1179
1443
|
/**
|
|
1180
1444
|
* Smart URL handling for GitLab API
|
|
1181
1445
|
*
|
|
@@ -1196,7 +1460,7 @@ function normalizeGitLabApiUrl(url) {
|
|
|
1196
1460
|
return normalizedUrl;
|
|
1197
1461
|
}
|
|
1198
1462
|
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
1199
|
-
const GITLAB_API_URLS = (
|
|
1463
|
+
const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
|
|
1200
1464
|
.split(",")
|
|
1201
1465
|
.map(normalizeGitLabApiUrl);
|
|
1202
1466
|
const GITLAB_API_URL = GITLAB_API_URLS[0];
|
|
@@ -1292,7 +1556,7 @@ async function forkProject(projectId, namespace) {
|
|
|
1292
1556
|
...getFetchConfig(),
|
|
1293
1557
|
method: "POST",
|
|
1294
1558
|
});
|
|
1295
|
-
//
|
|
1559
|
+
// Handle case where project already exists
|
|
1296
1560
|
if (response.status === 409) {
|
|
1297
1561
|
throw new Error("Project already exists in the target namespace");
|
|
1298
1562
|
}
|
|
@@ -1354,7 +1618,7 @@ async function getFileContents(projectId, filePath, ref) {
|
|
|
1354
1618
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
1355
1619
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
1356
1620
|
const encodedPath = encodeURIComponent(filePath);
|
|
1357
|
-
//
|
|
1621
|
+
// Fall back to default branch if ref is not provided
|
|
1358
1622
|
if (!ref) {
|
|
1359
1623
|
ref = await getDefaultBranchRef(projectId);
|
|
1360
1624
|
}
|
|
@@ -1363,14 +1627,14 @@ async function getFileContents(projectId, filePath, ref) {
|
|
|
1363
1627
|
const response = await fetch(url.toString(), {
|
|
1364
1628
|
...getFetchConfig(),
|
|
1365
1629
|
});
|
|
1366
|
-
//
|
|
1630
|
+
// Handle file not found
|
|
1367
1631
|
if (response.status === 404) {
|
|
1368
1632
|
throw new Error(`File not found: ${filePath}`);
|
|
1369
1633
|
}
|
|
1370
1634
|
await handleGitLabError(response);
|
|
1371
1635
|
const data = await response.json();
|
|
1372
1636
|
const parsedData = GitLabContentSchema.parse(data);
|
|
1373
|
-
// Base64
|
|
1637
|
+
// Decode Base64-encoded file content to UTF-8
|
|
1374
1638
|
if (!Array.isArray(parsedData) && parsedData.content) {
|
|
1375
1639
|
parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8");
|
|
1376
1640
|
parsedData.encoding = "utf8";
|
|
@@ -1400,7 +1664,7 @@ async function createIssue(projectId, options) {
|
|
|
1400
1664
|
labels: options.labels?.join(","),
|
|
1401
1665
|
}),
|
|
1402
1666
|
});
|
|
1403
|
-
//
|
|
1667
|
+
// Handle bad request
|
|
1404
1668
|
if (response.status === 400) {
|
|
1405
1669
|
const errorBody = await response.text();
|
|
1406
1670
|
throw new Error(`Invalid request: ${errorBody}`);
|
|
@@ -2421,10 +2685,10 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
|
2421
2685
|
* @param {string} body - The content of the note
|
|
2422
2686
|
* @returns {Promise<any>} The created note
|
|
2423
2687
|
*/
|
|
2424
|
-
async function createNote(projectId, noteableType, // 'issue'
|
|
2688
|
+
async function createNote(projectId, noteableType, // specifies 'issue' or 'merge_request' type
|
|
2425
2689
|
noteableIid, body) {
|
|
2426
2690
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
2427
|
-
// ⚙️
|
|
2691
|
+
// ⚙️ Response type can be adjusted according to the GitLab API documentation
|
|
2428
2692
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
|
|
2429
2693
|
);
|
|
2430
2694
|
const response = await fetch(url.toString(), {
|
|
@@ -2472,14 +2736,18 @@ async function listDraftNotes(projectId, mergeRequestIid) {
|
|
|
2472
2736
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2473
2737
|
* @param {number|string} mergeRequestIid - The internal ID of the merge request
|
|
2474
2738
|
* @param {string} body - The content of the draft note
|
|
2739
|
+
* @param {string} [inReplyToDiscussionId] - The ID of a discussion the draft note replies to
|
|
2475
2740
|
* @param {MergeRequestThreadPosition} [position] - Position information for diff notes
|
|
2476
2741
|
* @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing
|
|
2477
2742
|
* @returns {Promise<GitLabDraftNote>} The created draft note
|
|
2478
2743
|
*/
|
|
2479
|
-
async function createDraftNote(projectId, mergeRequestIid, body, position, resolveDiscussion) {
|
|
2744
|
+
async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscussionId, position, resolveDiscussion) {
|
|
2480
2745
|
projectId = decodeURIComponent(projectId);
|
|
2481
2746
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes`);
|
|
2482
2747
|
const requestBody = { note: body };
|
|
2748
|
+
if (inReplyToDiscussionId) {
|
|
2749
|
+
requestBody.in_reply_to_discussion_id = inReplyToDiscussionId;
|
|
2750
|
+
}
|
|
2483
2751
|
if (position) {
|
|
2484
2752
|
requestBody.position = position;
|
|
2485
2753
|
}
|
|
@@ -3902,6 +4170,20 @@ async function markdownUpload(projectId, filePath) {
|
|
|
3902
4170
|
const data = await response.json();
|
|
3903
4171
|
return GitLabMarkdownUploadSchema.parse(data);
|
|
3904
4172
|
}
|
|
4173
|
+
const IMAGE_MIME_TYPES = {
|
|
4174
|
+
".png": "image/png",
|
|
4175
|
+
".jpg": "image/jpeg",
|
|
4176
|
+
".jpeg": "image/jpeg",
|
|
4177
|
+
".gif": "image/gif",
|
|
4178
|
+
".webp": "image/webp",
|
|
4179
|
+
".svg": "image/svg+xml",
|
|
4180
|
+
".bmp": "image/bmp",
|
|
4181
|
+
".ico": "image/x-icon",
|
|
4182
|
+
};
|
|
4183
|
+
function getImageMimeType(filename) {
|
|
4184
|
+
const ext = path.extname(filename).toLowerCase();
|
|
4185
|
+
return IMAGE_MIME_TYPES[ext] ?? null;
|
|
4186
|
+
}
|
|
3905
4187
|
async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
3906
4188
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
3907
4189
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
|
|
@@ -3916,12 +4198,33 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
|
3916
4198
|
await handleGitLabError(response);
|
|
3917
4199
|
}
|
|
3918
4200
|
// Get the file content as buffer
|
|
3919
|
-
const buffer = await response.arrayBuffer();
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
//
|
|
3923
|
-
|
|
3924
|
-
|
|
4201
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
4202
|
+
const mimeType = getImageMimeType(filename);
|
|
4203
|
+
// For non-image files, always save to disk.
|
|
4204
|
+
// For image files, only save to disk if local_path is explicitly provided.
|
|
4205
|
+
if (!mimeType || localPath) {
|
|
4206
|
+
let savePath;
|
|
4207
|
+
if (localPath) {
|
|
4208
|
+
const normalizedLocalPath = path.normalize(localPath);
|
|
4209
|
+
if (path.isAbsolute(normalizedLocalPath) ||
|
|
4210
|
+
normalizedLocalPath === ".." ||
|
|
4211
|
+
normalizedLocalPath.startsWith(".." + path.sep) ||
|
|
4212
|
+
normalizedLocalPath.includes(path.sep + ".." + path.sep)) {
|
|
4213
|
+
throw new Error("Invalid local_path: directory traversal is not allowed.");
|
|
4214
|
+
}
|
|
4215
|
+
savePath = path.join(normalizedLocalPath, filename);
|
|
4216
|
+
}
|
|
4217
|
+
else {
|
|
4218
|
+
savePath = filename;
|
|
4219
|
+
}
|
|
4220
|
+
const dir = path.dirname(savePath);
|
|
4221
|
+
if (!fs.existsSync(dir)) {
|
|
4222
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4223
|
+
}
|
|
4224
|
+
fs.writeFileSync(savePath, buffer);
|
|
4225
|
+
return { buffer, filename, mimeType, savedPath: savePath };
|
|
4226
|
+
}
|
|
4227
|
+
return { buffer, filename, mimeType };
|
|
3925
4228
|
}
|
|
3926
4229
|
/**
|
|
3927
4230
|
* List all events for the currently authenticated user
|
|
@@ -4573,8 +4876,8 @@ async function handleToolCall(params) {
|
|
|
4573
4876
|
}
|
|
4574
4877
|
case "create_draft_note": {
|
|
4575
4878
|
const args = CreateDraftNoteSchema.parse(params.arguments);
|
|
4576
|
-
const { project_id, merge_request_iid, body, position, resolve_discussion } = args;
|
|
4577
|
-
const draftNote = await createDraftNote(project_id, merge_request_iid, body, position, resolve_discussion);
|
|
4879
|
+
const { project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion } = args;
|
|
4880
|
+
const draftNote = await createDraftNote(project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion);
|
|
4578
4881
|
return {
|
|
4579
4882
|
content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }],
|
|
4580
4883
|
};
|
|
@@ -5110,10 +5413,26 @@ async function handleToolCall(params) {
|
|
|
5110
5413
|
}
|
|
5111
5414
|
case "download_attachment": {
|
|
5112
5415
|
const args = DownloadAttachmentSchema.parse(params.arguments);
|
|
5113
|
-
const
|
|
5416
|
+
const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
|
|
5417
|
+
if (result.mimeType && !args.local_path) {
|
|
5418
|
+
// Return image inline as base64 so the AI can see it
|
|
5419
|
+
const base64 = result.buffer.toString("base64");
|
|
5420
|
+
return {
|
|
5421
|
+
content: [
|
|
5422
|
+
{ type: "image", data: base64, mimeType: result.mimeType },
|
|
5423
|
+
{
|
|
5424
|
+
type: "text",
|
|
5425
|
+
text: JSON.stringify({ filename: result.filename, mimeType: result.mimeType }, null, 2),
|
|
5426
|
+
},
|
|
5427
|
+
],
|
|
5428
|
+
};
|
|
5429
|
+
}
|
|
5114
5430
|
return {
|
|
5115
5431
|
content: [
|
|
5116
|
-
{
|
|
5432
|
+
{
|
|
5433
|
+
type: "text",
|
|
5434
|
+
text: JSON.stringify({ success: true, file_path: result.savedPath }, null, 2),
|
|
5435
|
+
},
|
|
5117
5436
|
],
|
|
5118
5437
|
};
|
|
5119
5438
|
}
|