@zereight/mcp-gitlab 2.1.65 → 2.1.67
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 +2 -1
- package/README.md +2 -1
- package/README.zh-CN.md +2 -1
- package/build/index.js +35 -4
- package/build/schemas.js +3 -0
- package/build/test/gitlab-diff-schema.test.js +36 -0
- package/build/test/response-masking.test.js +33 -0
- package/build/test/utils/jmespath-tool-result.test.js +91 -0
- package/build/utils/jmespath-tool-result.js +78 -0
- package/package.json +3 -1
package/README.ko.md
CHANGED
|
@@ -24,6 +24,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
|
|
|
24
24
|
- **여러 전송 방식** — 로컬 클라이언트용 stdio, 레거시 클라이언트용 SSE, 최신 원격 배포용 Streamable HTTP
|
|
25
25
|
- **클라이언트 친화적 설정** — Claude Code, Codex, Antigravity, OpenCode, Copilot, Cline, Roo Code, Cursor, Kilo Code, Amp Code 예시 제공
|
|
26
26
|
- **셀프 호스팅 대응** — 커스텀 GitLab 인스턴스, 프록시 설정, 동적 API URL 라우팅 지원
|
|
27
|
+
- **JMESPath 결과 필터링** — 도구 호출에 선택적 `jmespath` 인자(`tools/list` 참고)를 넘기면 GitLab API 요청은 그대로 두고 JSON 결과만 줄여서 반환; 응답 마스킹이 켜져 있으면 마스킹된 데이터에 JMESPath가 적용됨
|
|
27
28
|
|
|
28
29
|
### 비교 요약
|
|
29
30
|
|
|
@@ -110,7 +111,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
|
|
|
110
111
|
|
|
111
112
|
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
112
113
|
|
|
113
|
-
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.
|
|
114
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.66`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
114
115
|
|
|
115
116
|
#### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
|
|
116
117
|
|
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization
|
|
|
29
29
|
- **Multiple transports** — stdio for local clients, SSE for legacy clients, and Streamable HTTP for modern remote deployments
|
|
30
30
|
- **Client-friendly setup** — examples for Claude Code, Codex, Antigravity, OpenCode, Copilot, Cline, Roo Code, Cursor, Kilo Code, and Amp Code
|
|
31
31
|
- **Self-hosted ready** — works with custom GitLab instances, proxy settings, and dynamic API URL routing
|
|
32
|
+
- **JMESPath result filtering** — optional `jmespath` on tool calls (see `tools/list`) shrinks JSON results without changing GitLab API requests; when response masking is enabled, JMESPath runs on masked data.
|
|
32
33
|
|
|
33
34
|
### How we compare
|
|
34
35
|
|
|
@@ -115,7 +116,7 @@ The store path is pinned by your lock file; update it with `nix flake update git
|
|
|
115
116
|
|
|
116
117
|
The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
|
|
117
118
|
|
|
118
|
-
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.
|
|
119
|
+
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.66`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
119
120
|
|
|
120
121
|
#### Using CLI Arguments (for clients with env var issues)
|
|
121
122
|
|
package/README.zh-CN.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
- **多种传输方式** — 本地客户端使用 stdio,旧客户端使用 SSE,现代远程部署使用 Streamable HTTP
|
|
25
25
|
- **客户端设置友好** — 提供 Claude Code、Codex、Antigravity、OpenCode、Copilot、Cline、Roo Code、Cursor、Kilo Code 和 Amp Code 示例
|
|
26
26
|
- **适合自托管** — 支持自定义 GitLab 实例、代理设置和动态 API URL 路由
|
|
27
|
+
- **JMESPath 结果过滤** — 在工具调用中传入可选的 `jmespath` 参数(见 `tools/list`),可在不改变 GitLab API 请求的情况下精简 JSON 结果;启用响应掩码时,JMESPath 作用于掩码后的数据
|
|
27
28
|
|
|
28
29
|
### 对比摘要
|
|
29
30
|
|
|
@@ -110,7 +111,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
|
|
|
110
111
|
|
|
111
112
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
112
113
|
|
|
113
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.
|
|
114
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.66`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
114
115
|
|
|
115
116
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
116
117
|
|
package/build/index.js
CHANGED
|
@@ -102,6 +102,7 @@ import { fetchWithValidatedRedirects } from "./utils/safe-redirect-fetch.js";
|
|
|
102
102
|
import { parseGitLabVersionApiResponse, } from "./utils/gitlab-instance-version.js";
|
|
103
103
|
import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./utils/bulk-publish-options.js";
|
|
104
104
|
import { appendFilterParam, appendFilterParams, cleanMutuallyExclusiveIdUsernameOptions, dropBlankArrayEntries, isBlankFilterValue, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
|
|
105
|
+
import { applyJmespathToToolResult, getJmespathSyntaxError, JMESPATH_TOOL_ARGUMENT, JMESPATH_TOOL_ARGUMENT_DESCRIPTION, omitJmespathArgument, readJmespathExpression, } from "./utils/jmespath-tool-result.js";
|
|
105
106
|
import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
|
|
106
107
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
107
108
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
@@ -289,6 +290,18 @@ function createServer() {
|
|
|
289
290
|
},
|
|
290
291
|
};
|
|
291
292
|
}
|
|
293
|
+
if (modified.inputSchema && typeof modified.inputSchema === "object") {
|
|
294
|
+
const properties = modified.inputSchema.properties && typeof modified.inputSchema.properties === "object"
|
|
295
|
+
? { ...modified.inputSchema.properties }
|
|
296
|
+
: {};
|
|
297
|
+
if (!(JMESPATH_TOOL_ARGUMENT in properties)) {
|
|
298
|
+
properties[JMESPATH_TOOL_ARGUMENT] = {
|
|
299
|
+
type: "string",
|
|
300
|
+
description: JMESPATH_TOOL_ARGUMENT_DESCRIPTION,
|
|
301
|
+
};
|
|
302
|
+
modified.inputSchema = { ...modified.inputSchema, properties };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
292
305
|
return modified;
|
|
293
306
|
});
|
|
294
307
|
// <<< END: Remove $schema for Gemini compatibility >>>
|
|
@@ -303,15 +316,24 @@ function createServer() {
|
|
|
303
316
|
const toolName = request.params.name;
|
|
304
317
|
const start = Date.now();
|
|
305
318
|
let maskingEngine;
|
|
306
|
-
const
|
|
319
|
+
const jmespathExpression = readJmespathExpression(request.params.arguments);
|
|
320
|
+
const logCompletion = (result, completionOptions) => {
|
|
307
321
|
const durationMs = Date.now() - start;
|
|
308
322
|
logger.info({ tool: toolName, event: "tool_call_done", durationMs }, `tool_call_done: ${toolName} (${durationMs}ms)`);
|
|
309
323
|
const isPlainTextResult = toolName === "get_pipeline_job_output" ||
|
|
310
324
|
toolName === "get_job_artifact_file" ||
|
|
311
325
|
(toolName === "download_release_asset" && !IS_REMOTE);
|
|
326
|
+
const filtered = applyJmespathToToolResult(result, jmespathExpression, {
|
|
327
|
+
...completionOptions,
|
|
328
|
+
maskValue: maskingEngine
|
|
329
|
+
? (value) => maskingEngine.maskValue(value)
|
|
330
|
+
: undefined,
|
|
331
|
+
});
|
|
312
332
|
return maskingEngine
|
|
313
|
-
? maskingEngine.maskToolResult(
|
|
314
|
-
|
|
333
|
+
? maskingEngine.maskToolResult(filtered, {
|
|
334
|
+
textFormat: isPlainTextResult ? "plain" : "json",
|
|
335
|
+
})
|
|
336
|
+
: filtered;
|
|
315
337
|
};
|
|
316
338
|
const logError = (error) => {
|
|
317
339
|
const durationMs = Date.now() - start;
|
|
@@ -370,6 +392,12 @@ function createServer() {
|
|
|
370
392
|
maskingEngine = sessionContext
|
|
371
393
|
? sessionAuthStore.run(sessionContext, selectMaskingPolicy)
|
|
372
394
|
: selectMaskingPolicy();
|
|
395
|
+
const jmespathSyntaxError = jmespathExpression
|
|
396
|
+
? getJmespathSyntaxError(jmespathExpression)
|
|
397
|
+
: undefined;
|
|
398
|
+
if (jmespathSyntaxError) {
|
|
399
|
+
return logCompletion({ content: [{ type: "text", text: jmespathSyntaxError }], isError: true }, { skipJmespath: true });
|
|
400
|
+
}
|
|
373
401
|
// Handle discover_tools meta-tool directly (needs access to mcpServer and filteredTools)
|
|
374
402
|
if (toolName === "discover_tools") {
|
|
375
403
|
const category = request.params.arguments?.category?.trim()?.toLowerCase();
|
|
@@ -477,12 +505,15 @@ function createServer() {
|
|
|
477
505
|
text: `Tool "${toolName}" requires confirmation. This tool is marked as requiring approval before execution. Re-call with _confirmed: true to proceed.`,
|
|
478
506
|
},
|
|
479
507
|
],
|
|
480
|
-
});
|
|
508
|
+
}, { skipJmespath: true });
|
|
481
509
|
}
|
|
482
510
|
// Strip _confirmed from args before forwarding to handler
|
|
483
511
|
const { _confirmed, ...cleanArgs } = request.params.arguments || {};
|
|
484
512
|
request.params.arguments = cleanArgs;
|
|
485
513
|
}
|
|
514
|
+
if (request.params.arguments && typeof request.params.arguments === "object") {
|
|
515
|
+
request.params.arguments = omitJmespathArgument(request.params.arguments);
|
|
516
|
+
}
|
|
486
517
|
if (sessionContext) {
|
|
487
518
|
// Policy selection and the tool handler must use the same request-local
|
|
488
519
|
// API URL and project allowlist.
|
package/build/schemas.js
CHANGED
|
@@ -1355,6 +1355,9 @@ export const GitLabDiffSchema = z.object({
|
|
|
1355
1355
|
new_file: z.coerce.boolean(),
|
|
1356
1356
|
renamed_file: z.coerce.boolean(),
|
|
1357
1357
|
deleted_file: z.coerce.boolean(),
|
|
1358
|
+
generated_file: z.coerce.boolean().optional(),
|
|
1359
|
+
too_large: z.coerce.boolean().optional(),
|
|
1360
|
+
collapsed: z.coerce.boolean().optional(),
|
|
1358
1361
|
});
|
|
1359
1362
|
// Response schemas for operations
|
|
1360
1363
|
export const GitLabCreateUpdateFileResponseSchema = z.object({
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { GitLabDiffSchema } from "../schemas.js";
|
|
4
|
+
test("diff schema keeps GitLab's generated_file, too_large and collapsed flags", () => {
|
|
5
|
+
const parsed = GitLabDiffSchema.parse({
|
|
6
|
+
diff: "@@ -1 +1 @@",
|
|
7
|
+
new_path: "src/Foo.php",
|
|
8
|
+
old_path: "src/Foo.php",
|
|
9
|
+
a_mode: "100644",
|
|
10
|
+
b_mode: "100644",
|
|
11
|
+
new_file: false,
|
|
12
|
+
renamed_file: false,
|
|
13
|
+
deleted_file: false,
|
|
14
|
+
generated_file: true,
|
|
15
|
+
too_large: true,
|
|
16
|
+
collapsed: false,
|
|
17
|
+
});
|
|
18
|
+
assert.equal(parsed.generated_file, true);
|
|
19
|
+
assert.equal(parsed.too_large, true);
|
|
20
|
+
assert.equal(parsed.collapsed, false);
|
|
21
|
+
});
|
|
22
|
+
test("diff schema still parses entries without the optional flags", () => {
|
|
23
|
+
const parsed = GitLabDiffSchema.parse({
|
|
24
|
+
diff: "@@ -1 +1 @@",
|
|
25
|
+
new_path: "src/Foo.php",
|
|
26
|
+
old_path: "src/Foo.php",
|
|
27
|
+
a_mode: "100644",
|
|
28
|
+
b_mode: "100644",
|
|
29
|
+
new_file: false,
|
|
30
|
+
renamed_file: false,
|
|
31
|
+
deleted_file: false,
|
|
32
|
+
});
|
|
33
|
+
assert.equal("generated_file" in parsed, false);
|
|
34
|
+
assert.equal("too_large" in parsed, false);
|
|
35
|
+
assert.equal("collapsed" in parsed, false);
|
|
36
|
+
});
|
|
@@ -188,6 +188,39 @@ describe("managed response masking at the MCP boundary", { timeout: 30_000 }, ()
|
|
|
188
188
|
"/api/v4/projects/12/merge_requests",
|
|
189
189
|
]);
|
|
190
190
|
});
|
|
191
|
+
test("applies jmespath to JSON tool results at the MCP boundary", async (t) => {
|
|
192
|
+
const { client } = await startMaskingServer(t, {
|
|
193
|
+
env: { ENABLE_STRICT_PROJECT_SCOPE: "true", GITLAB_ALLOWED_PROJECT_IDS: "12" },
|
|
194
|
+
});
|
|
195
|
+
const { tools } = await client.listTools();
|
|
196
|
+
const listMrs = tools.find(tool => tool.name === "list_merge_requests");
|
|
197
|
+
const jmespathSchema = listMrs?.inputSchema?.properties?.jmespath;
|
|
198
|
+
assert.ok(jmespathSchema, "tools/list should inject optional jmespath on every tool schema");
|
|
199
|
+
assert.ok((jmespathSchema.description?.length ?? 0) < 120, "jmespath schema description should stay short for tools/list token cost");
|
|
200
|
+
const result = await client.callTool({
|
|
201
|
+
name: "list_issues",
|
|
202
|
+
arguments: { jmespath: "[].title" },
|
|
203
|
+
});
|
|
204
|
+
const text = result.content[0]?.text;
|
|
205
|
+
assert.deepEqual(JSON.parse(text), ["[one]"]);
|
|
206
|
+
const probe = await client.callTool({
|
|
207
|
+
name: "list_issues",
|
|
208
|
+
arguments: { jmespath: "[].starts_with(title, `ProjectOne`)" },
|
|
209
|
+
});
|
|
210
|
+
const probeText = probe.content[0]?.text;
|
|
211
|
+
assert.deepEqual(JSON.parse(probeText), [false], "jmespath must run on masked JSON so callers cannot probe hidden substrings");
|
|
212
|
+
});
|
|
213
|
+
test("rejects invalid jmespath before the handler reaches GitLab", async (t) => {
|
|
214
|
+
const { client, requests } = await startMaskingServer(t, {
|
|
215
|
+
env: { ENABLE_STRICT_PROJECT_SCOPE: "true", GITLAB_ALLOWED_PROJECT_IDS: "12" },
|
|
216
|
+
});
|
|
217
|
+
const result = await client.callTool({
|
|
218
|
+
name: "list_issues",
|
|
219
|
+
arguments: { jmespath: "[[[" },
|
|
220
|
+
});
|
|
221
|
+
assert.equal(result.isError, true);
|
|
222
|
+
assert.deepEqual(requests, []);
|
|
223
|
+
});
|
|
191
224
|
test("keeps optional default-project handlers available with their managed policy", async (t) => {
|
|
192
225
|
const { client, requests } = await startMaskingServer(t);
|
|
193
226
|
const fileResult = await client.callTool({
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { applyJmespathFilter, applyJmespathToToolResult, getJmespathSyntaxError, omitJmespathArgument, readJmespathExpression, shouldApplyJmespathFilter, } from "../../utils/jmespath-tool-result.js";
|
|
4
|
+
describe("jmespath tool result helpers", () => {
|
|
5
|
+
test("readJmespathExpression trims and rejects blank values", () => {
|
|
6
|
+
assert.equal(readJmespathExpression({ jmespath: " [].title " }), "[].title");
|
|
7
|
+
assert.equal(readJmespathExpression({ jmespath: " " }), undefined);
|
|
8
|
+
assert.equal(readJmespathExpression({ jmespath: 1 }), undefined);
|
|
9
|
+
});
|
|
10
|
+
test("omitJmespathArgument removes MCP-only key", () => {
|
|
11
|
+
assert.deepEqual(omitJmespathArgument({ project_id: "1", jmespath: "[].id" }), {
|
|
12
|
+
project_id: "1",
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
test("applyJmespathFilter projects array fields", () => {
|
|
16
|
+
const data = [
|
|
17
|
+
{ title: "a", description: "long" },
|
|
18
|
+
{ title: "b", description: "longer" },
|
|
19
|
+
];
|
|
20
|
+
assert.deepEqual(applyJmespathFilter(data, "[].title"), ["a", "b"]);
|
|
21
|
+
});
|
|
22
|
+
test("applyJmespathToToolResult filters JSON text content", () => {
|
|
23
|
+
const result = applyJmespathToToolResult({
|
|
24
|
+
content: [
|
|
25
|
+
{
|
|
26
|
+
type: "text",
|
|
27
|
+
text: JSON.stringify([{ title: "mr-one", iid: 1 }]),
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
}, "[].title");
|
|
31
|
+
assert.equal(result.content[0].text, JSON.stringify(["mr-one"]));
|
|
32
|
+
});
|
|
33
|
+
test("applyJmespathToToolResult applies maskValue before JMESPath", () => {
|
|
34
|
+
const result = applyJmespathToToolResult({
|
|
35
|
+
content: [
|
|
36
|
+
{
|
|
37
|
+
type: "text",
|
|
38
|
+
text: JSON.stringify([{ title: "ProjectOneSecret" }]),
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
}, "[].starts_with(title, `ProjectOne`)", {
|
|
42
|
+
maskValue: value => {
|
|
43
|
+
if (!Array.isArray(value))
|
|
44
|
+
return value;
|
|
45
|
+
return value.map(entry => entry && typeof entry === "object" && "title" in entry
|
|
46
|
+
? { ...entry, title: "[one]" }
|
|
47
|
+
: entry);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
assert.deepEqual(JSON.parse(result.content[0].text), [false]);
|
|
51
|
+
});
|
|
52
|
+
test("shouldApplyJmespathFilter is false for isError and explicit skip", () => {
|
|
53
|
+
const errorResult = {
|
|
54
|
+
content: [{ type: "text", text: 'Unknown category "foo"' }],
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
assert.equal(shouldApplyJmespathFilter(errorResult, "[].title"), false);
|
|
58
|
+
assert.equal(shouldApplyJmespathFilter({ content: [{ type: "text", text: "requires confirmation" }] }, "[].title", { skipJmespath: true }), false);
|
|
59
|
+
assert.equal(shouldApplyJmespathFilter({ content: [{ type: "text", text: "[]" }] }, "[].title"), true);
|
|
60
|
+
});
|
|
61
|
+
test("applyJmespathToToolResult preserves isError tool results", () => {
|
|
62
|
+
const original = {
|
|
63
|
+
content: [{ type: "text", text: 'Unknown category "foo"' }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
assert.deepEqual(applyJmespathToToolResult(original, "[].title"), original);
|
|
67
|
+
});
|
|
68
|
+
test("applyJmespathToToolResult preserves approval-style plain text when skipJmespath", () => {
|
|
69
|
+
const original = {
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: 'Tool "create_issue" requires confirmation. Re-call with _confirmed: true to proceed.',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
assert.deepEqual(applyJmespathToToolResult(original, "[].title", { skipJmespath: true }), original);
|
|
78
|
+
});
|
|
79
|
+
test("applyJmespathToToolResult passes non-JSON success text through unchanged", () => {
|
|
80
|
+
const original = { content: [{ type: "text", text: "Created pipeline #1 for main." }] };
|
|
81
|
+
assert.deepEqual(applyJmespathToToolResult(original, "id"), original);
|
|
82
|
+
});
|
|
83
|
+
test("applyJmespathToToolResult returns the original result when evaluation fails", () => {
|
|
84
|
+
const original = { content: [{ type: "text", text: JSON.stringify({ title: 1 }) }] };
|
|
85
|
+
assert.deepEqual(applyJmespathToToolResult(original, "starts_with(title, `a`)"), original);
|
|
86
|
+
});
|
|
87
|
+
test("getJmespathSyntaxError rejects invalid expressions before execution", () => {
|
|
88
|
+
assert.match(getJmespathSyntaxError("[[[") ?? "", /Invalid jmespath expression/);
|
|
89
|
+
assert.equal(getJmespathSyntaxError("[].title"), undefined);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import jmespath from "jmespath";
|
|
2
|
+
/** MCP-only argument; stripped before GitLab handlers run (issue #766). */
|
|
3
|
+
export const JMESPATH_TOOL_ARGUMENT = "jmespath";
|
|
4
|
+
export const JMESPATH_TOOL_ARGUMENT_DESCRIPTION = "Optional JMESPath expression filtering the JSON result before return.";
|
|
5
|
+
export function readJmespathExpression(args) {
|
|
6
|
+
if (!args)
|
|
7
|
+
return undefined;
|
|
8
|
+
const raw = args[JMESPATH_TOOL_ARGUMENT];
|
|
9
|
+
if (typeof raw !== "string")
|
|
10
|
+
return undefined;
|
|
11
|
+
const trimmed = raw.trim();
|
|
12
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Syntax check before the handler runs, so side-effecting tools never execute with a bad expression. */
|
|
15
|
+
export function getJmespathSyntaxError(expression) {
|
|
16
|
+
try {
|
|
17
|
+
jmespath.compile(expression);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return `Invalid jmespath expression: ${error instanceof Error ? error.message : String(error)}`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function omitJmespathArgument(args) {
|
|
25
|
+
if (!(JMESPATH_TOOL_ARGUMENT in args))
|
|
26
|
+
return args;
|
|
27
|
+
const { [JMESPATH_TOOL_ARGUMENT]: _omit, ...rest } = args;
|
|
28
|
+
return rest;
|
|
29
|
+
}
|
|
30
|
+
export function applyJmespathFilter(data, expression) {
|
|
31
|
+
return jmespath.search(data, expression);
|
|
32
|
+
}
|
|
33
|
+
function isContentBlock(value) {
|
|
34
|
+
return Boolean(value && typeof value === "object");
|
|
35
|
+
}
|
|
36
|
+
function prepareDataForJmespath(data, maskValue) {
|
|
37
|
+
return maskValue ? maskValue(data) : data;
|
|
38
|
+
}
|
|
39
|
+
function parseJson(text) {
|
|
40
|
+
try {
|
|
41
|
+
return { ok: true, value: JSON.parse(text) };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { ok: false };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** When false, return the tool result unchanged (masking still runs afterward). */
|
|
48
|
+
export function shouldApplyJmespathFilter(result, expression, options) {
|
|
49
|
+
return Boolean(expression && !options?.skipJmespath && !result.isError);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Apply JMESPath to JSON serialized in text content blocks (and structuredContent when present).
|
|
53
|
+
* The handler has already run, so this never turns a result into an error: non-JSON text and
|
|
54
|
+
* evaluation failures return the original result.
|
|
55
|
+
*/
|
|
56
|
+
export function applyJmespathToToolResult(result, expression, options) {
|
|
57
|
+
if (!expression || !shouldApplyJmespathFilter(result, expression, options))
|
|
58
|
+
return result;
|
|
59
|
+
const maskValue = options?.maskValue;
|
|
60
|
+
const filter = (data) => applyJmespathFilter(prepareDataForJmespath(data, maskValue), expression);
|
|
61
|
+
try {
|
|
62
|
+
const content = Array.isArray(result.content)
|
|
63
|
+
? result.content.map(block => {
|
|
64
|
+
if (!isContentBlock(block) || block.type !== "text" || typeof block.text !== "string") {
|
|
65
|
+
return block;
|
|
66
|
+
}
|
|
67
|
+
const parsed = parseJson(block.text);
|
|
68
|
+
return parsed.ok ? { ...block, text: JSON.stringify(filter(parsed.value)) } : block;
|
|
69
|
+
})
|
|
70
|
+
: result.content;
|
|
71
|
+
return result.structuredContent === undefined
|
|
72
|
+
? { ...result, content }
|
|
73
|
+
: { ...result, content, structuredContent: filter(result.structuredContent) };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.67",
|
|
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": [
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"express": "^5.1.0",
|
|
79
79
|
"express-rate-limit": "^8.5.2",
|
|
80
80
|
"fetch-cookie": "^3.1.0",
|
|
81
|
+
"jmespath": "^0.16.0",
|
|
81
82
|
"open": "^10.2.0",
|
|
82
83
|
"pino": "^9.7.0",
|
|
83
84
|
"pino-pretty": "^13.0.0",
|
|
@@ -90,6 +91,7 @@
|
|
|
90
91
|
},
|
|
91
92
|
"devDependencies": {
|
|
92
93
|
"@types/express": "^5.0.2",
|
|
94
|
+
"@types/jmespath": "^0.15.2",
|
|
93
95
|
"@types/node": "^22.13.10",
|
|
94
96
|
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
|
95
97
|
"@typescript-eslint/parser": "^8.21.0",
|