@zereight/mcp-gitlab 2.3.8-next.145 → 2.3.8-next.146
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/build/index.js +50 -10
- package/build/schemas.js +4 -0
- package/build/tools/discover-activation.js +5 -4
- package/build/tools/listed-tools.js +20 -1
- package/build/utils/error-hints.js +42 -0
- package/build/utils/list-summary.js +90 -0
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -114,6 +114,8 @@ import { parseGitLabVersionApiResponse, } from "./utils/gitlab-instance-version.
|
|
|
114
114
|
import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./utils/bulk-publish-options.js";
|
|
115
115
|
import { appendFilterParam, appendFilterParams, cleanMutuallyExclusiveIdUsernameOptions, dropBlankArrayEntries, isBlankFilterValue, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
|
|
116
116
|
import { applyJmespathToToolResult, getJmespathSyntaxError, JMESPATH_TOOL_ARGUMENT, JMESPATH_TOOL_ARGUMENT_DESCRIPTION, omitJmespathArgument, readJmespathExpression, } from "./utils/jmespath-tool-result.js";
|
|
117
|
+
import { appendRecoveryHint } from "./utils/error-hints.js";
|
|
118
|
+
import { applyListSummaryToToolResult, omitListSummaryArgument, readListSummaryRequested, toolAcceptsDecoratedSummary, } from "./utils/list-summary.js";
|
|
117
119
|
import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
|
|
118
120
|
import { zodSchemaFor } from "./utils/schema.js";
|
|
119
121
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
@@ -229,14 +231,18 @@ function createServer() {
|
|
|
229
231
|
const toolName = request.params.name;
|
|
230
232
|
const start = Date.now();
|
|
231
233
|
let maskingEngine;
|
|
232
|
-
const
|
|
234
|
+
const requestArguments = readOwnRecord(request.params.arguments);
|
|
235
|
+
const jmespathExpression = readJmespathExpression(requestArguments === undefined ? undefined : { ...requestArguments });
|
|
236
|
+
const listSummaryRequested = toolAcceptsDecoratedSummary(toolName) &&
|
|
237
|
+
readListSummaryRequested(requestArguments === undefined ? undefined : { ...requestArguments });
|
|
233
238
|
const logCompletion = (result, completionOptions) => {
|
|
234
239
|
const durationMs = Date.now() - start;
|
|
235
240
|
logger.info({ tool: toolName, event: "tool_call_done", durationMs }, `tool_call_done: ${toolName} (${durationMs}ms)`);
|
|
236
241
|
const isPlainTextResult = toolName === "get_pipeline_job_output" ||
|
|
237
242
|
toolName === "get_job_artifact_file" ||
|
|
238
243
|
(toolName === "download_release_asset" && !IS_REMOTE);
|
|
239
|
-
const
|
|
244
|
+
const summarized = applyListSummaryToToolResult(redactErrorResultUrls(result), listSummaryRequested);
|
|
245
|
+
const filtered = applyJmespathToToolResult(summarized, jmespathExpression, {
|
|
240
246
|
...completionOptions,
|
|
241
247
|
maskValue: maskingEngine
|
|
242
248
|
? (value) => maskingEngine.maskValue(value)
|
|
@@ -477,8 +483,13 @@ function createServer() {
|
|
|
477
483
|
const { _confirmed, ...cleanArgs } = request.params.arguments || {};
|
|
478
484
|
request.params.arguments = cleanArgs;
|
|
479
485
|
}
|
|
480
|
-
|
|
481
|
-
|
|
486
|
+
const ownedArguments = readOwnRecord(request.params.arguments);
|
|
487
|
+
if (ownedArguments) {
|
|
488
|
+
let cleanedArguments = omitJmespathArgument({ ...ownedArguments });
|
|
489
|
+
if (toolAcceptsDecoratedSummary(toolName)) {
|
|
490
|
+
cleanedArguments = omitListSummaryArgument(cleanedArguments);
|
|
491
|
+
}
|
|
492
|
+
request.params.arguments = cleanedArguments;
|
|
482
493
|
}
|
|
483
494
|
if (sessionContext) {
|
|
484
495
|
// Policy selection and the tool handler must use the same request-local
|
|
@@ -500,7 +511,7 @@ function createServer() {
|
|
|
500
511
|
content: [
|
|
501
512
|
{
|
|
502
513
|
type: "text",
|
|
503
|
-
text: safeError instanceof Error ? safeError.message : String(safeError),
|
|
514
|
+
text: appendRecoveryHint(safeError instanceof Error ? safeError.message : String(safeError)),
|
|
504
515
|
},
|
|
505
516
|
],
|
|
506
517
|
isError: true,
|
|
@@ -1418,12 +1429,12 @@ async function handleGitLabError(response) {
|
|
|
1418
1429
|
// before the common tool-call boundary returns/logs it.
|
|
1419
1430
|
logger.error("GitLab API Rate Limit Exceeded");
|
|
1420
1431
|
logger.error("User API Key Rate limit exceeded. Please try again later.");
|
|
1421
|
-
const error = new GitLabApiError(`GitLab API Rate Limit Exceeded: ${errorBody}`);
|
|
1432
|
+
const error = new GitLabApiError(appendRecoveryHint(`GitLab API Rate Limit Exceeded: ${errorBody}`));
|
|
1422
1433
|
throw error;
|
|
1423
1434
|
}
|
|
1424
1435
|
else {
|
|
1425
1436
|
// Handle other API errors
|
|
1426
|
-
const error = new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
1437
|
+
const error = new GitLabApiError(appendRecoveryHint(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`));
|
|
1427
1438
|
throw error;
|
|
1428
1439
|
}
|
|
1429
1440
|
}
|
|
@@ -7646,7 +7657,7 @@ function getSafeAttachmentFilename(filename) {
|
|
|
7646
7657
|
}
|
|
7647
7658
|
return filename;
|
|
7648
7659
|
}
|
|
7649
|
-
async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
7660
|
+
async function downloadAttachment(projectId, secret, filename, localPath, metadataOnly = false) {
|
|
7650
7661
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
7651
7662
|
const safeFilename = getSafeAttachmentFilename(filename);
|
|
7652
7663
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/uploads/${encodeGitLabPathSegment(secret)}/${encodeGitLabPathSegment(safeFilename)}`);
|
|
@@ -7655,6 +7666,18 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
|
7655
7666
|
await handleGitLabError(response);
|
|
7656
7667
|
}
|
|
7657
7668
|
const mimeType = getImageMimeType(safeFilename);
|
|
7669
|
+
if (metadataOnly) {
|
|
7670
|
+
const headerLength = Number(response.headers.get("content-length"));
|
|
7671
|
+
if (response.body) {
|
|
7672
|
+
await response.body.cancel();
|
|
7673
|
+
}
|
|
7674
|
+
return {
|
|
7675
|
+
buffer: Buffer.alloc(0),
|
|
7676
|
+
filename: safeFilename,
|
|
7677
|
+
mimeType,
|
|
7678
|
+
byteLength: Number.isFinite(headerLength) ? headerLength : undefined,
|
|
7679
|
+
};
|
|
7680
|
+
}
|
|
7658
7681
|
// Non-image files, and images with local_path, are saved as new files under the
|
|
7659
7682
|
// download directory. Upload names and local_path come from untrusted content, so
|
|
7660
7683
|
// dot names (.git, .vscode, .envrc) are refused and existing files are never replaced.
|
|
@@ -10718,10 +10741,27 @@ async function handleToolCall(params) {
|
|
|
10718
10741
|
};
|
|
10719
10742
|
}
|
|
10720
10743
|
// Readonly mode never writes to disk: images still come back inline.
|
|
10721
|
-
if (GITLAB_PERMISSION_MODE === "readonly" &&
|
|
10744
|
+
if (GITLAB_PERMISSION_MODE === "readonly" &&
|
|
10745
|
+
!args.metadata_only &&
|
|
10746
|
+
(!mimeType || args.local_path)) {
|
|
10722
10747
|
throw new Error("download_attachment saves this file to disk, which readonly mode does not allow. Only images without local_path can be downloaded in readonly mode.");
|
|
10723
10748
|
}
|
|
10724
|
-
const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
|
|
10749
|
+
const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path, args.metadata_only === true);
|
|
10750
|
+
if (args.metadata_only) {
|
|
10751
|
+
return {
|
|
10752
|
+
content: [
|
|
10753
|
+
{
|
|
10754
|
+
type: "text",
|
|
10755
|
+
text: JSON.stringify({
|
|
10756
|
+
filename: result.filename,
|
|
10757
|
+
mimeType: result.mimeType,
|
|
10758
|
+
byte_length: result.byteLength ?? result.buffer.byteLength,
|
|
10759
|
+
metadata_only: true,
|
|
10760
|
+
}),
|
|
10761
|
+
},
|
|
10762
|
+
],
|
|
10763
|
+
};
|
|
10764
|
+
}
|
|
10725
10765
|
if (result.mimeType && !args.local_path) {
|
|
10726
10766
|
// Return image inline as base64 so the AI can see it
|
|
10727
10767
|
const base64 = result.buffer.toString("base64");
|
package/build/schemas.js
CHANGED
|
@@ -3522,6 +3522,10 @@ export const DownloadAttachmentSchema = z.object({
|
|
|
3522
3522
|
.string()
|
|
3523
3523
|
.optional()
|
|
3524
3524
|
.describe("Subdirectory of the download directory to save the file in (optional, defaults to the download directory itself). Names starting with a dot are rejected"),
|
|
3525
|
+
metadata_only: z
|
|
3526
|
+
.boolean()
|
|
3527
|
+
.optional()
|
|
3528
|
+
.describe("If true, return filename, mime type, and byte length without inline image bytes or a saved file. Default false keeps the existing download behavior."),
|
|
3525
3529
|
});
|
|
3526
3530
|
export const DownloadAttachmentRemoteSchema = z.object({
|
|
3527
3531
|
project_id: z.string().describe("Project ID or URL-encoded path of the project"),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appendRecoveryHint } from "../utils/error-hints.js";
|
|
1
2
|
/**
|
|
2
3
|
* Same skips `discover_tools` uses when it adds tools: permission mode, regex
|
|
3
4
|
* denial, hidden policy, and the slim profile. Those tools never join the list.
|
|
@@ -22,15 +23,15 @@ export function buildUnavailableToolMessage(input) {
|
|
|
22
23
|
const base = `Tool "${input.toolName}" is not available on this server`;
|
|
23
24
|
const toolset = input.toolset;
|
|
24
25
|
if (toolset === undefined || input.deniedByRegex) {
|
|
25
|
-
return base;
|
|
26
|
+
return appendRecoveryHint(base);
|
|
26
27
|
}
|
|
27
28
|
if (input.excludedBySlimProfile) {
|
|
28
|
-
return `${base}. ${slimExclusionGuidance(input.toolName, toolset, input.enabledByCurrentToolsets)}
|
|
29
|
+
return appendRecoveryHint(`${base}. ${slimExclusionGuidance(input.toolName, toolset, input.enabledByCurrentToolsets)}`);
|
|
29
30
|
}
|
|
30
31
|
if (input.explicitToolsetOnly) {
|
|
31
|
-
return `${base}. The "${toolset}" toolset is not enabled. discover_tools cannot activate it. Add "${toolset}" to GITLAB_TOOLSETS. GITLAB_TOOLSETS=all, GITLAB_TOOLS, and GITLAB_TOOL_PROFILE do not enable it
|
|
32
|
+
return appendRecoveryHint(`${base}. The "${toolset}" toolset is not enabled. discover_tools cannot activate it. Add "${toolset}" to GITLAB_TOOLSETS. GITLAB_TOOLSETS=all, GITLAB_TOOLS, and GITLAB_TOOL_PROFILE do not enable it.`);
|
|
32
33
|
}
|
|
33
|
-
return `${base}. It belongs to the "${toolset}" toolset, which is not enabled: call discover_tools with category "${toolset}" or add "${toolset}" to GITLAB_TOOLSETS
|
|
34
|
+
return appendRecoveryHint(`${base}. It belongs to the "${toolset}" toolset, which is not enabled: call discover_tools with category "${toolset}" or add "${toolset}" to GITLAB_TOOLSETS.`);
|
|
34
35
|
}
|
|
35
36
|
function slimExclusionGuidance(toolName, toolset, enabledByCurrentToolsets) {
|
|
36
37
|
const viaToolsets = `add "${toolset}" to GITLAB_TOOLSETS (setting GITLAB_TOOLSETS disables the slim profile)`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LIST_SUMMARY_ARGUMENT, LIST_SUMMARY_ARGUMENT_DESCRIPTION, toolAcceptsDecoratedSummary, } from "../utils/list-summary.js";
|
|
1
2
|
const CONFIRMED_ARGUMENT = "_confirmed";
|
|
2
3
|
const CONFIRMED_ARGUMENT_DESCRIPTION = "Set to true to confirm execution of this approval-required tool.";
|
|
3
4
|
function isRecord(value) {
|
|
@@ -36,11 +37,29 @@ function withJmespathArgument(schema, context) {
|
|
|
36
37
|
};
|
|
37
38
|
return { ...schema, properties };
|
|
38
39
|
}
|
|
40
|
+
function withSummaryArgument(schema, toolName) {
|
|
41
|
+
if (!toolAcceptsDecoratedSummary(toolName) || !isRecord(schema.properties)) {
|
|
42
|
+
return schema;
|
|
43
|
+
}
|
|
44
|
+
if (LIST_SUMMARY_ARGUMENT in schema.properties) {
|
|
45
|
+
return schema;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...schema,
|
|
49
|
+
properties: {
|
|
50
|
+
...schema.properties,
|
|
51
|
+
[LIST_SUMMARY_ARGUMENT]: {
|
|
52
|
+
type: "boolean",
|
|
53
|
+
description: LIST_SUMMARY_ARGUMENT_DESCRIPTION,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
39
58
|
function decorateInputSchema(schema, toolName, context) {
|
|
40
59
|
if (!isRecord(schema)) {
|
|
41
60
|
return undefined;
|
|
42
61
|
}
|
|
43
|
-
return withJmespathArgument(withConfirmedArgument(cloneSchema(schema), toolName, context), context);
|
|
62
|
+
return withSummaryArgument(withJmespathArgument(withConfirmedArgument(cloneSchema(schema), toolName, context), context), toolName);
|
|
44
63
|
}
|
|
45
64
|
export function decorateToolForList(tool, context) {
|
|
46
65
|
const inputSchema = decorateInputSchema(tool.inputSchema, tool.name, context);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const NEXT_PREFIX = "\nNext: ";
|
|
2
|
+
export function hasRecoveryHint(message) {
|
|
3
|
+
return message.includes(NEXT_PREFIX);
|
|
4
|
+
}
|
|
5
|
+
export function recoveryHintFor(message) {
|
|
6
|
+
if (/^Access denied:/i.test(message)) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
if (/is not available on this server/i.test(message)) {
|
|
10
|
+
if (/slim tool profile/i.test(message) || /cannot activate/i.test(message)) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
if (/discover_tools|GITLAB_TOOLSETS|GITLAB_TOOLS/.test(message)) {
|
|
14
|
+
return "call discover_tools with the category, or add it to GITLAB_TOOLSETS.";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (/\b401\b/.test(message) || /unauthorized/i.test(message)) {
|
|
18
|
+
return "set GITLAB_PERSONAL_ACCESS_TOKEN (or run `zereight-mcp-gitlab auth`) and retry.";
|
|
19
|
+
}
|
|
20
|
+
if (/rate limit/i.test(message)) {
|
|
21
|
+
return "wait and retry; lower per_page or paginate if the client is bursting.";
|
|
22
|
+
}
|
|
23
|
+
if (/\b403\b/.test(message) ||
|
|
24
|
+
/forbidden/i.test(message) ||
|
|
25
|
+
/insufficient_scope/i.test(message)) {
|
|
26
|
+
return "check token scopes and GITLAB_PERMISSION_MODE; grant read_user or User:Read for whoami.";
|
|
27
|
+
}
|
|
28
|
+
if (/\b404\b/.test(message) || /404 not found/i.test(message)) {
|
|
29
|
+
return "check project_id (numeric ID or group%2Fproject) and the resource iid.";
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
export function appendRecoveryHint(message) {
|
|
34
|
+
if (hasRecoveryHint(message)) {
|
|
35
|
+
return message;
|
|
36
|
+
}
|
|
37
|
+
const hint = recoveryHintFor(message);
|
|
38
|
+
if (hint === undefined) {
|
|
39
|
+
return message;
|
|
40
|
+
}
|
|
41
|
+
return `${message}${NEXT_PREFIX}${hint}`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** MCP-only argument; stripped before GitLab handlers run. */
|
|
2
|
+
export const LIST_SUMMARY_ARGUMENT = "summary";
|
|
3
|
+
export const LIST_SUMMARY_ARGUMENT_DESCRIPTION = "If true, return only high-signal fields (id, iid, title, state, web_url, …). Default false keeps the full GitLab JSON.";
|
|
4
|
+
const SUMMARY_FIELDS = [
|
|
5
|
+
"id",
|
|
6
|
+
"iid",
|
|
7
|
+
"title",
|
|
8
|
+
"name",
|
|
9
|
+
"state",
|
|
10
|
+
"status",
|
|
11
|
+
"ref",
|
|
12
|
+
"web_url",
|
|
13
|
+
"path_with_namespace",
|
|
14
|
+
"username",
|
|
15
|
+
"short_id",
|
|
16
|
+
"key",
|
|
17
|
+
"tag_name",
|
|
18
|
+
"slug",
|
|
19
|
+
"filename",
|
|
20
|
+
"new_path",
|
|
21
|
+
];
|
|
22
|
+
const NATIVE_SUMMARY_TOOLS = new Set(["list_webhook_events"]);
|
|
23
|
+
export function toolAcceptsDecoratedSummary(toolName) {
|
|
24
|
+
if (NATIVE_SUMMARY_TOOLS.has(toolName)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return toolName.startsWith("list_") || toolName === "mr_discussions";
|
|
28
|
+
}
|
|
29
|
+
export function readListSummaryRequested(args) {
|
|
30
|
+
if (!args) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const raw = args[LIST_SUMMARY_ARGUMENT];
|
|
34
|
+
return raw === true || raw === "true";
|
|
35
|
+
}
|
|
36
|
+
export function omitListSummaryArgument(args) {
|
|
37
|
+
if (!(LIST_SUMMARY_ARGUMENT in args)) {
|
|
38
|
+
return args;
|
|
39
|
+
}
|
|
40
|
+
const { [LIST_SUMMARY_ARGUMENT]: _omit, ...rest } = args;
|
|
41
|
+
return rest;
|
|
42
|
+
}
|
|
43
|
+
function isRecord(value) {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
function summarizeItem(item) {
|
|
47
|
+
if (!isRecord(item)) {
|
|
48
|
+
return item;
|
|
49
|
+
}
|
|
50
|
+
const summarized = {};
|
|
51
|
+
for (const field of SUMMARY_FIELDS) {
|
|
52
|
+
if (field in item) {
|
|
53
|
+
summarized[field] = item[field];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Object.keys(summarized).length > 0 ? summarized : item;
|
|
57
|
+
}
|
|
58
|
+
export function summarizeListPayload(data) {
|
|
59
|
+
if (Array.isArray(data)) {
|
|
60
|
+
return data.map(summarizeItem);
|
|
61
|
+
}
|
|
62
|
+
if (isRecord(data) && Array.isArray(data.items)) {
|
|
63
|
+
return { ...data, items: data.items.map(summarizeItem) };
|
|
64
|
+
}
|
|
65
|
+
return data;
|
|
66
|
+
}
|
|
67
|
+
function isTextContentBlock(value) {
|
|
68
|
+
return typeof value === "object" && value !== null;
|
|
69
|
+
}
|
|
70
|
+
export function applyListSummaryToToolResult(result, requested) {
|
|
71
|
+
if (!requested || result.isError) {
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(result.content)) {
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
const content = result.content.map(block => {
|
|
78
|
+
if (!isTextContentBlock(block) || block.type !== "text" || typeof block.text !== "string") {
|
|
79
|
+
return block;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(block.text);
|
|
83
|
+
return { ...block, text: JSON.stringify(summarizeListPayload(parsed)) };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return block;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return { ...result, content };
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.3.8-next.
|
|
3
|
+
"version": "2.3.8-next.146",
|
|
4
4
|
"mcpName": "io.github.zereight/gitlab-mcp",
|
|
5
5
|
"description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
|
|
6
6
|
"keywords": [
|