@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 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 jmespathExpression = readJmespathExpression(request.params.arguments);
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 filtered = applyJmespathToToolResult(redactErrorResultUrls(result), jmespathExpression, {
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
- if (request.params.arguments && typeof request.params.arguments === "object") {
481
- request.params.arguments = omitJmespathArgument(request.params.arguments);
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" && (!mimeType || args.local_path)) {
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.145",
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": [